Files
accounted/tests/pg/oauth-flows.pg.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

166 lines
6.3 KiB
TypeScript

import { randomBytes } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getClient, getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
/**
* Migration 20260907120000_oauth_flows.sql: the one-row-per-flow table behind
* lib/auth/oauth-flows.ts. Locks in the three things the application relies
* on and cannot prove with mocks:
*
* - the table is service-role only (RLS enabled, browser roles revoked);
* - consuming the state and consuming the handoff are single statements
* whose WHERE clause carries the whole check, so two concurrent
* deliveries yield exactly one winner;
* - the handoff can only be claimed from the origin it was minted for.
*/
async function insertFlow(params: {
companyId: string
userId: string
origin?: string
expiresInSeconds?: number
}): Promise<string> {
const id = randomBytes(32).toString('base64url')
await getPool().query(
`INSERT INTO public.oauth_flows
(id, kind, company_id, user_id, origin, redirect_uri, expires_at)
VALUES ($1, 'skatteverket', $2, $3, $4, 'https://oauth.testbrand.example/cb',
now() + make_interval(secs => $5))`,
[id, params.companyId, params.userId, params.origin ?? 'https://app.testbrand.example', params.expiresInSeconds ?? 600],
)
return id
}
// Mirrors consumeOAuthFlowState's PostgREST statement.
const CONSUME_STATE = `
UPDATE public.oauth_flows SET used_at = now()
WHERE id = $1 AND kind = 'skatteverket' AND used_at IS NULL AND expires_at > now()
RETURNING id`
// Mirrors consumeOAuthFlowHandoff's PostgREST statement.
const CONSUME_HANDOFF = `
DELETE FROM public.oauth_flows
WHERE handoff_id = $1 AND origin = $2 AND kind = 'skatteverket'
AND handoff_expires_at > now()
RETURNING id, handoff_code`
async function mintHandoff(id: string, expiresInSeconds = 120): Promise<string> {
const handoffId = randomBytes(32).toString('base64url')
await getPool().query(
`UPDATE public.oauth_flows
SET handoff_id = $2, handoff_code = 'v1:ciphertext',
handoff_expires_at = now() + make_interval(secs => $3)
WHERE id = $1`,
[id, handoffId, expiresInSeconds],
)
return handoffId
}
describe('oauth_flows (pg)', () => {
async function expectDenied(role: 'anon' | 'authenticated', sql: string) {
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(`SET LOCAL ROLE ${role}`)
await expect(client.query(sql)).rejects.toThrow(/permission denied/i)
} finally {
await client.query('ROLLBACK').catch(() => {})
client.release()
}
}
it('denies the browser-facing roles entirely', async () => {
await expectDenied('anon', 'SELECT * FROM public.oauth_flows LIMIT 1')
await expectDenied('authenticated', 'SELECT * FROM public.oauth_flows LIMIT 1')
await expectDenied(
'authenticated',
`INSERT INTO public.oauth_flows (id, kind, company_id, user_id, origin, redirect_uri, expires_at)
VALUES ('x', 'skatteverket', gen_random_uuid(), gen_random_uuid(), 'https://a', 'https://b', now())`,
)
})
it('lets exactly one of two concurrent deliveries consume the state', async () => {
const { companyId, userId } = await seedCompany()
const id = await insertFlow({ companyId, userId })
const a = await getClient()
const b = await getClient()
try {
const [ra, rb] = await Promise.all([
a.query(CONSUME_STATE, [id]),
b.query(CONSUME_STATE, [id]),
])
expect(ra.rowCount! + rb.rowCount!).toBe(1)
} finally {
a.release()
b.release()
}
// And nothing after that: the state is spent.
const again = await getPool().query(CONSUME_STATE, [id])
expect(again.rowCount).toBe(0)
})
it('refuses an expired state', async () => {
const { companyId, userId } = await seedCompany()
const id = await insertFlow({ companyId, userId, expiresInSeconds: -1 })
const res = await getPool().query(CONSUME_STATE, [id])
expect(res.rowCount).toBe(0)
})
it('claims the handoff once, and only from the recorded origin', async () => {
const { companyId, userId } = await seedCompany()
const id = await insertFlow({ companyId, userId, origin: 'https://brand.testbrand.example' })
await getPool().query(CONSUME_STATE, [id])
const handoffId = await mintHandoff(id)
// Wrong origin: nothing, and the row is still there for the right one.
const wrong = await getPool().query(CONSUME_HANDOFF, [handoffId, 'https://app.testbrand.example'])
expect(wrong.rowCount).toBe(0)
const a = await getClient()
const b = await getClient()
try {
const [ra, rb] = await Promise.all([
a.query(CONSUME_HANDOFF, [handoffId, 'https://brand.testbrand.example']),
b.query(CONSUME_HANDOFF, [handoffId, 'https://brand.testbrand.example']),
])
expect(ra.rowCount! + rb.rowCount!).toBe(1)
const winner = ra.rowCount === 1 ? ra : rb
expect(winner.rows[0]!.handoff_code).toBe('v1:ciphertext')
} finally {
a.release()
b.release()
}
// DELETE RETURNING: the held code left the database with the claim.
const gone = await getPool().query('SELECT 1 FROM public.oauth_flows WHERE id = $1', [id])
expect(gone.rowCount).toBe(0)
})
it('refuses an expired handoff', async () => {
const { companyId, userId } = await seedCompany()
const id = await insertFlow({ companyId, userId, origin: 'https://brand.testbrand.example' })
await getPool().query(CONSUME_STATE, [id])
const handoffId = await mintHandoff(id, -1)
const res = await getPool().query(CONSUME_HANDOFF, [handoffId, 'https://brand.testbrand.example'])
expect(res.rowCount).toBe(0)
})
it('rejects a handoff written onto an unconsumed state', async () => {
// The shape constraint: a handoff only exists for a state hop 1 consumed,
// so a stray write can never make an unconsumed state claimable twice.
const { companyId, userId } = await seedCompany()
const id = await insertFlow({ companyId, userId })
await expect(
getPool().query(
`UPDATE public.oauth_flows
SET handoff_id = 'h', handoff_code = 'v1:x', handoff_expires_at = now() + interval '2 minutes'
WHERE id = $1`,
[id],
),
).rejects.toThrow(/oauth_flows_handoff_shape/)
})
})