import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createMockRequest, parseJsonResponse } from '@/tests/helpers' vi.mock('../lib/bankid-client', () => ({ startBankIdAuth: vi.fn(), pollBankIdSession: vi.fn(), collectBankIdResult: vi.fn(), cancelBankIdSession: vi.fn(), requestEnrichment: vi.fn().mockResolvedValue({ status: 'failed', completedTypes: [] }), fetchEnrichmentData: vi.fn(), })) vi.mock('@/lib/supabase/server', () => ({ createServiceClient: vi.fn(), createClient: vi.fn(), })) // The confirmation mail (generateLink + platform email service + brand // lookup) is covered in bankid-confirmation-mail.test.ts; here it is a seam // so the route tests can assert WHEN it is sent and to WHOM. vi.mock('../lib/bankid-confirmation-mail', () => ({ sendBankIdSignupConfirmation: vi.fn(), })) // The invite-only brand gate reads the brands table for any non-empty host; // it has its own tests. Open here so a forwarded host can be asserted on the // mail without a database. vi.mock('@/lib/auth/brand-signup-gate', () => ({ evaluateBrandSignupGate: vi.fn().mockResolvedValue({ allowed: true }), readInviteTokenFromCookieHeader: vi.fn().mockReturnValue(null), })) import { cancelBankIdSession, collectBankIdResult, pollBankIdSession, requestEnrichment, fetchEnrichmentData, startBankIdAuth, } from '../lib/bankid-client' import { sendBankIdSignupConfirmation } from '../lib/bankid-confirmation-mail' import { createServiceClient } from '@/lib/supabase/server' import { ticExtension } from '../index' import { BANKID_FLOW_COOKIE, BANKID_FLOW_ID_HEADER, signBankIdFlow, verifyBankIdFlow, type BankIdFlowMode, } from '../lib/bankid-flow-cookie' const TEST_KEY = 'a'.repeat(64) const TEST_FLOW_ID = 'flow-1' /** * The session id and the mode now arrive in a signed HttpOnly cookie rather * than the request body, so nothing a caller can name decides which session * gets completed, or as which kind of flow. */ async function flowCookie( mode: BankIdFlowMode, sessionId = 'test-session', userId = 'user-1', ): Promise> { const value = await signBankIdFlow({ version: 1, sessionId, flowId: TEST_FLOW_ID, mode, // A link flow is owned by the user who opened it; login/signup have none. userId: mode === 'link' ? userId : undefined, startedAt: Date.now(), expiresAt: Date.now() + 60_000, }) return { cookie: `${BANKID_FLOW_COOKIE}=${encodeURIComponent(value)}`, [BANKID_FLOW_ID_HEADER]: TEST_FLOW_ID, } } function findCompleteHandler() { const route = ticExtension.apiRoutes!.find( (r) => r.method === 'POST' && r.path === '/bankid/complete' ) if (!route) throw new Error('POST /bankid/complete route not found in ticExtension.apiRoutes') return route.handler } function makeSession(overrides: Partial<{ status: string; user: unknown }> = {}) { return { sessionId: 'test-session', status: 'complete', user: { personalNumber: '199001011234', givenName: 'Anna', surname: 'Andersson', name: 'Anna Andersson', }, ...overrides, } as unknown as Awaited> } type QueuedResult = { data?: unknown; error?: unknown } function mockServiceClient( fromResults: QueuedResult[], // The single-use claim against bankid_consumed_sessions. Routed by table name // rather than taken from the queue, so each test's queue keeps describing // only the lookups it cares about. `{ error: { code: '23505' } }` is the // other tab having claimed the session first. consumed: QueuedResult = { error: null }, ) { const queue = [...fromResults] const chain = (): unknown => { const result = queue.shift() ?? { data: null, error: null } const handler: ProxyHandler = { get(_t, prop) { if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result) return () => chain2(result) }, } return new Proxy({}, handler) } const chain2 = (result: QueuedResult): unknown => { const handler: ProxyHandler = { get(_t, prop) { if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result) return () => chain2(result) }, } return new Proxy({}, handler) } const admin = { createUser: vi.fn().mockResolvedValue({ data: { user: { id: 'new-user-uuid' } }, error: null }), updateUserById: vi.fn().mockResolvedValue({ data: {}, error: null }), deleteUser: vi.fn().mockResolvedValue({ data: {}, error: null }), generateLink: vi.fn().mockResolvedValue({ data: { properties: { hashed_token: 'magic-token-hash' } }, error: null, }), getUserById: vi.fn().mockResolvedValue({ data: { user: { id: 'existing-user', email: 'existing@example.com' } }, }), } const client = { from: vi.fn().mockImplementation((table: string) => table === 'bankid_consumed_sessions' ? chain2(consumed) : chain() ), auth: { admin }, } vi.mocked(createServiceClient).mockReturnValue(client as unknown as ReturnType) return { admin, client } } /** A verified identity row, as every pre-2026-09 row is after the backfill. */ const VERIFIED_AT = '2026-01-01T00:00:00Z' beforeEach(() => { vi.clearAllMocks() vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY) vi.mocked(sendBankIdSignupConfirmation).mockResolvedValue({ ok: true }) }) afterEach(() => { vi.unstubAllEnvs() }) describe('POST /bankid/complete', () => { describe('signup mode: account_exists regression (CWE-287)', () => { it('returns 409 account_exists and performs NO side effects when email is already registered', async () => { // The guard is createUser's own auth.users uniqueness check, NOT a // profiles.email pre-check: anonymized tombstones (account deletion) // have no profiles.email but still hold the address in auth.users. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin, client } = mockServiceClient([ { data: null }, // bankid_identities pnr lookup → not linked ]) admin.createUser.mockResolvedValueOnce({ data: { user: null }, error: { status: 422, code: 'email_exists', message: 'A user with this email address has already been registered' }, } as never) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'victim@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string; data?: unknown }>( await findCompleteHandler()(req) ) expect(status).toBe(409) expect(body.error).toBe('account_exists') expect(body.data).toBeUndefined() // Critical: no account mutation or session issuance happened. expect(admin.updateUserById).not.toHaveBeenCalled() expect(admin.generateLink).not.toHaveBeenCalled() expect(admin.deleteUser).not.toHaveBeenCalled() // No insert into bankid_identities: the only from() call is the pnr lookup. const fromCalls = vi.mocked(client.from).mock.calls expect(fromCalls.map((c) => c[0])).toEqual(['bankid_identities']) }) it('returns 500 internal_error for createUser failures that are NOT email_exists', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: null }, // pnr lookup → not linked ]) admin.createUser.mockResolvedValueOnce({ data: { user: null }, error: { status: 500, code: 'unexpected_failure', message: 'boom' }, } as never) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(500) expect(body.error).toBe('internal_error') expect(admin.generateLink).not.toHaveBeenCalled() }) }) describe('signup mode: happy path (account pre-hijacking fix, audit 2026-09)', () => { it('creates an UNCONFIRMED user with a PENDING identity, mails the typed address, and returns no session', async () => { // The address came from the request body and nothing proved it belongs // to the BankID holder. The old flow confirmed it, granted the MFA // exemption and handed the browser a magic link; the real owner of the // address could later adopt the account while the BankID kept a login. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin, client } = mockServiceClient([ { data: null }, // pnr lookup → not linked ]) const insertSpy = vi.fn().mockResolvedValue({ error: null }) const origFrom = client.from as unknown as ReturnType const queuedFrom = origFrom.getMockImplementation() as (table: string) => unknown let identityCalls = 0 origFrom.mockImplementation((table: string) => { if (table === 'bankid_identities' && ++identityCalls === 2) { return { insert: insertSpy } } return queuedFrom(table) }) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: { ...(await flowCookie('signup')), 'x-forwarded-host': 'app.gnubok.se', 'x-forwarded-proto': 'https' }, body: { email: 'Fresh@Example.com ' }, }) const response = await findCompleteHandler()(req) const raw = await response.clone().text() const { status, body } = await parseJsonResponse<{ data?: { status?: string; email?: string; tokenHash?: string } }>(response) expect(status).toBe(200) // Same shape as POST /api/auth/signup: the register page shows its // "check your inbox" screen. Nothing here signs anyone in. expect(body.data).toEqual({ status: 'confirmation_sent', email: 'fresh@example.com' }) expect(raw).not.toContain('magic-token-hash') expect(raw).not.toContain('tokenHash') // The route itself mints no link; only the mail helper does, server-side. expect(admin.generateLink).not.toHaveBeenCalled() expect(admin.createUser).toHaveBeenCalledWith( expect.objectContaining({ email: 'fresh@example.com', email_confirm: false }) ) // Pending, not linked: bankid_linked (the MFA exemption) is granted by // /auth/callback once the mail is clicked. expect(admin.updateUserById).toHaveBeenCalledWith( 'new-user-uuid', expect.objectContaining({ app_metadata: { bankid_pending: true, has_password: false }, }) ) expect(insertSpy).toHaveBeenCalledWith( expect.objectContaining({ user_id: 'new-user-uuid', email_verified_at: null }) ) expect(insertSpy.mock.calls[0][0]).not.toHaveProperty('bankid_linked') expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1) expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith({ supabase: client, email: 'fresh@example.com', host: 'app.gnubok.se', proto: 'https', }) expect(admin.deleteUser).not.toHaveBeenCalled() }) }) describe('signup mode: pnr already linked', () => { it('returns 409 already_linked before email lookup', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin, client } = mockServiceClient([ { data: { user_id: 'some-other-user', email_verified_at: VERIFIED_AT } }, // pnr lookup → LINKED ]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'x@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(409) expect(body.error).toBe('already_linked') expect(admin.createUser).not.toHaveBeenCalled() // Only the pnr lookup ran: no profiles query. expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual(['bankid_identities']) }) }) describe('signup mode: rollback on partial failure', () => { // A half-created account strands the user: retrying signup hits // account_exists/already_linked, but the account only has a random // password they never saw. Every failure after createUser must delete // the created user so a retry starts clean. it('deletes the created user when the bankid_identities insert fails', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: null }, // pnr lookup → not linked { error: { message: 'insert boom', code: 'XX000' } }, // identity insert FAILS ]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(500) expect(body.error).toBe('internal_error') expect(admin.createUser).toHaveBeenCalled() expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid') expect(admin.generateLink).not.toHaveBeenCalled() }) it('deletes the created user when the confirmation mail cannot be sent', async () => { // An account whose address will never receive its confirmation link is // unusable AND blocks the address; roll it back so a retry starts clean. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: null }, // pnr lookup → not linked { error: null }, // identity insert OK ]) vi.mocked(sendBankIdSignupConfirmation).mockResolvedValueOnce({ ok: false, step: 'send', message: 'Email service not configured', }) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(500) expect(body.error).toBe('internal_error') expect(body.message).toBe('Kunde inte skicka bekräftelsemailet. Försök igen.') expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid') }) it('deletes the created user when the app_metadata update fails', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: null }, // pnr lookup → not linked ]) admin.updateUserById.mockResolvedValueOnce({ data: null, error: { message: 'meta boom', code: 'XX000' }, } as never) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(500) expect(body.error).toBe('internal_error') expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid') expect(admin.generateLink).not.toHaveBeenCalled() }) it('does NOT delete anything on the happy path', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: null }, { error: null }, ]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) const { status } = await parseJsonResponse(await findCompleteHandler()(req)) expect(status).toBe(200) expect(admin.deleteUser).not.toHaveBeenCalled() }) }) describe('login mode', () => { it('returns 404 no_account when the BankID pnr is not linked to any user', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: null }, // pnr lookup → not linked ]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(404) expect(body.error).toBe('no_account') expect(admin.generateLink).not.toHaveBeenCalled() }) it('answers 503 (not no_account) when the identity lookup itself fails', async () => { // A schema behind the code or a lost connection must not send every // returning BankID user to signup, nor create anything. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: null, error: { code: '42703', message: 'column "email_verified_at" does not exist' } }, ]) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) ) ) expect(status).toBe(503) expect(body.error).toBe('service_unavailable') expect(admin.generateLink).not.toHaveBeenCalled() expect(admin.createUser).not.toHaveBeenCalled() }) it('treats PGRST116 (no row) as not linked', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) mockServiceClient([ { data: null, error: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' } }, ]) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) ) ) expect(status).toBe(404) expect(body.error).toBe('no_account') }) it('signs a VERIFIED identity in with a magic link, as before', async () => { // Every identity that existed before the pending column was added is // backfilled as verified; their login must be byte-identical. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }, ]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) const { status, body } = await parseJsonResponse<{ data?: { tokenHash?: string; type?: string; isNewUser?: boolean } }>(await findCompleteHandler()(req)) expect(status).toBe(200) expect(body.data).toEqual({ tokenHash: 'magic-token-hash', type: 'magiclink', isNewUser: false }) expect(admin.generateLink).toHaveBeenCalledWith({ type: 'magiclink', email: 'existing@example.com', }) expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled() }) }) describe('pending identities (address never proven, audit 2026-09)', () => { /** The auth user a BankID signup leaves behind until the mail is clicked. */ const pendingShell = { id: 'pending-user', email: 'typed@example.com', email_confirmed_at: undefined, identities: [{ provider: 'email' }], app_metadata: { bankid_pending: true, has_password: false }, } function clearedFlow(response: Response): boolean { return response.headers .getSetCookie() .some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c)) } it('login: refuses a pending identity, re-sends the confirmation mail, and mints nothing', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin, client } = mockServiceClient([ { data: { user_id: 'pending-user', email_verified_at: null } }, ]) admin.getUserById.mockResolvedValue({ data: { user: pendingShell }, error: null } as never) const response = await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: { ...(await flowCookie('login')), 'x-forwarded-host': 'app.gnubok.se' }, }) ) const raw = await response.clone().text() const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>(response) expect(status).toBe(403) expect(body.error).toBe('email_unconfirmed') expect(body.message).toMatch(/^Bekräfta din e-postadress först/) expect(raw).not.toContain('tokenHash') expect(admin.generateLink).not.toHaveBeenCalled() expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith( expect.objectContaining({ email: 'typed@example.com', host: 'app.gnubok.se' }) ) // The flag was already set, so nothing to heal; the row stays for the click. expect(admin.updateUserById).not.toHaveBeenCalled() expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual(['bankid_identities']) // Terminal for this identification: the flow is spent. expect(clearedFlow(response)).toBe(true) }) it('login: heals a missing bankid_pending flag before re-sending (rows from the old flow)', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: { user_id: 'legacy-user', email_verified_at: null } }, ]) admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'legacy-user', email_confirmed_at: '2026-09-02T08:00:00Z', app_metadata: { bankid_linked: true, has_password: false }, }, }, error: null, } as never) const { status } = await parseJsonResponse( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) ) ) expect(status).toBe(403) // /auth/callback only promotes when the flag is present. expect(admin.updateUserById).toHaveBeenCalledWith('legacy-user', { app_metadata: { bankid_linked: true, has_password: false, bankid_pending: true }, }) expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1) }) it('login: revokes the pending link and answers no_account once the address owner adopted the account', async () => { // The victim signed in with Google (or set a password): the account is // theirs. The BankID holder who typed their address gets no login into // it, now or after any later confirmation click. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin, client } = mockServiceClient([ { data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending { error: null }, // bankid_identities delete OK ]) admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'victim-user', email: 'victim@example.com', identities: [{ provider: 'email' }, { provider: 'google' }], }, }, error: null, } as never) const { status, body } = await parseJsonResponse<{ error?: string; givenName?: string }>( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) ) ) expect(status).toBe(404) expect(body.error).toBe('no_account') expect(body.givenName).toBe('Anna') expect(admin.generateLink).not.toHaveBeenCalled() expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled() // Row deleted, flag dropped, no MFA exemption granted. expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual([ 'bankid_identities', 'bankid_identities', ]) expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', { app_metadata: { bankid_pending: null, has_password: false }, }) expect(admin.deleteUser).not.toHaveBeenCalled() }) it('login: a pending identity whose user set a password is treated as adopted', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: { user_id: 'victim-user', email_verified_at: null } }, { error: null }, ]) admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'victim-user', app_metadata: { bankid_pending: true, has_password: true } }, }, error: null, } as never) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) ) ) expect(status).toBe(404) expect(body.error).toBe('no_account') expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled() }) it('signup: replaces a stale unadopted pending account from the same BankID (typo, lost mail)', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: { user_id: 'stale-user', email_verified_at: null } }, // pnr lookup → pending { error: null }, // new bankid_identities insert OK ]) admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'stale-user', email: 'typo@exmaple.com' } }, error: null, } as never) const { status, body } = await parseJsonResponse<{ data?: { status?: string } }>( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'correct@example.com' }, }) ) ) expect(status).toBe(200) expect(body.data?.status).toBe('confirmation_sent') // The unconfirmed shell (cascades its identity row) goes; a fresh one is made. expect(admin.deleteUser).toHaveBeenCalledTimes(1) expect(admin.deleteUser).toHaveBeenCalledWith('stale-user') expect(admin.createUser).toHaveBeenCalledWith( expect.objectContaining({ email: 'correct@example.com', email_confirm: false }) ) expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith( expect.objectContaining({ email: 'correct@example.com' }) ) }) it('signup: never deletes an adopted account; only the pending link is removed before the new signup', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin, client } = mockServiceClient([ { data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending { error: null }, // pending row delete OK { error: null }, // new bankid_identities insert OK ]) admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'victim-user', email_confirmed_at: '2026-09-01T00:00:00Z', identities: [{ provider: 'email' }, { provider: 'google' }], }, }, error: null, } as never) const { status } = await parseJsonResponse( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'mine@example.com' }, }) ) ) expect(status).toBe(200) expect(admin.deleteUser).not.toHaveBeenCalled() expect(vi.mocked(client.from).mock.calls.filter((c) => c[0] === 'bankid_identities')).toHaveLength(3) expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', { app_metadata: { bankid_pending: null, has_password: false }, }) expect(admin.createUser).toHaveBeenCalledWith( expect.objectContaining({ email: 'mine@example.com' }) ) }) it('signup: fails closed (500, flow kept) when the stale pending link cannot be cleared', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([ { data: { user_id: 'stale-user', email_verified_at: null } }, ]) admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'stale-user' } }, error: null } as never) admin.deleteUser.mockResolvedValueOnce({ data: null, error: { message: 'boom' } } as never) const response = await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'correct@example.com' }, }) ) expect(response.status).toBe(500) expect(admin.createUser).not.toHaveBeenCalled() expect(clearedFlow(response)).toBe(false) }) }) describe('enrichment: SPAR + CompanyRoles', () => { it('requests both SPAR and CompanyRoles, fetches data, and persists only companyRoles (no PII) to bankid_enrichment', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) vi.mocked(requestEnrichment).mockResolvedValueOnce({ enrichmentId: 'enr-1', sessionId: 'test-session', status: 'Completed', requestedTypes: ['SPAR', 'CompanyRoles'], completedTypes: ['SPAR', 'CompanyRoles'], secureUrl: '/api/v1/enrichment/data/abc', secureUrlExpiresAtUtc: '2026-05-06T12:00:00Z', }) vi.mocked(fetchEnrichmentData).mockResolvedValueOnce({ personalNumber: '199001011234', name: 'Anna Andersson', enrichedAtUtc: '2026-05-06T11:30:00Z', spar: { Person_IdNummer: '199001011234', Person_PersonIdTyp: 'PERSONNR', Skydd_Sekretessmarkering: false, Skydd_SkyddadFolkbokforing: false, Namn_Fornamn: 'Anna', Namn_Efternamn: 'Andersson', PersonDetaljer_Kon: 'K', PersonDetaljer_Fodelsedatum: '1990-01-01', Folkbokforingsadress_SvenskAdress_Utdelningsadress1: 'Storgatan 1', Folkbokforingsadress_SvenskAdress_PostNr: '11122', Folkbokforingsadress_SvenskAdress_Postort: 'Stockholm', }, companyRoles: [ { companyId: 12345, companyRegistrationNumber: '5566778899', legalName: 'Exempel AB', legalEntityType: 'AB', positionTypes: ['LED'], positionDescriptions: ['Styrelseledamot'], positionStart: '2020-01-15', positionEnd: null, companyStatus: 'Aktivt', }, ], }) const { client } = mockServiceClient([ { data: null }, // pnr lookup → not linked { error: null }, // bankid_identities insert OK ]) // Intercept the bankid_enrichment upsert so we can assert the persisted shape // contains no SPAR / personnummer / name. Other tables fall through to the // queued chain. const upsertSpy = vi.fn().mockResolvedValue({ error: null }) const origFrom = client.from as unknown as ReturnType const queuedFrom = origFrom.getMockImplementation() as (table: string) => unknown origFrom.mockImplementation((table: string) => { if (table === 'bankid_enrichment') { return { upsert: upsertSpy } } return queuedFrom(table) }) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) const { status, body } = await parseJsonResponse<{ data?: { status?: string } }>(await findCompleteHandler()(req)) expect(status).toBe(200) expect(body.data?.status).toBe('confirmation_sent') expect(vi.mocked(requestEnrichment)).toHaveBeenCalledWith( 'test-session', ['SPAR', 'CompanyRoles'] ) expect(vi.mocked(fetchEnrichmentData)).toHaveBeenCalledWith('/api/v1/enrichment/data/abc') // Persisted row must contain company_roles + enriched_at_utc only. // SPAR (personnummer / name / address / birth date) must NOT be stored, // even when TIC returns it: those fields live in bankid_identities (encrypted). expect(upsertSpy).toHaveBeenCalledTimes(1) const [persistedRow] = upsertSpy.mock.calls[0] as [Record] expect(persistedRow).toEqual({ user_id: expect.any(String), company_roles: expect.any(Array), enriched_at_utc: '2026-05-06T11:30:00Z', }) expect(persistedRow).not.toHaveProperty('spar') expect(persistedRow).not.toHaveProperty('personalNumber') expect(persistedRow).not.toHaveProperty('name') }) }) describe('input validation', () => { it('returns 400 session_invalid when BankID session is not complete', async () => { vi.mocked(collectBankIdResult).mockResolvedValue( makeSession({ status: 'pending', user: undefined }) ) mockServiceClient([]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'x@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(400) expect(body.error).toBe('session_invalid') }) it('returns 400 when email is missing in signup mode', async () => { mockServiceClient([]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), }) const { status } = await parseJsonResponse(await findCompleteHandler()(req)) expect(status).toBe(400) // collectBankIdResult should never be called: validation happens first. expect(collectBankIdResult).not.toHaveBeenCalled() }) }) describe('the flow cookie is the only thing that names a session', () => { /** Did the handler expire the flow cookie on this response? */ function clearedFlow(response: Response): boolean { return response.headers .getSetCookie() .some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c)) } it('refuses completion from a stale tab after the shared cookie was replaced', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) mockServiceClient([]) const headers = await flowCookie('signup') headers[BANKID_FLOW_ID_HEADER] = 'older-flow' const { status } = await parseJsonResponse( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers, body: { email: 'fresh@example.com' }, }) ) ) expect(status).toBe(400) expect(collectBankIdResult).not.toHaveBeenCalled() }) it('ignores a sessionId and mode supplied in the body', async () => { // The old contract took both from the body, which made /complete a // bearer endpoint: anyone who had seen a session id could complete it, // in whatever mode suited them. mockServiceClient([]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', body: { sessionId: 'attacker-session', mode: 'login' }, }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(400) expect(body.error).toBe('session_invalid') expect(collectBankIdResult).not.toHaveBeenCalled() }) it('refuses to complete a session that was opened as a link flow', async () => { // Linking happens on the authenticated /bankid/link route. Completing a // link session here would create or sign in an account off a session // opened for something else entirely. mockServiceClient([]) const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('link'), body: { email: 'attacker@example.com' }, }) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()(req) ) expect(status).toBe(400) expect(body.error).toBe('session_invalid') expect(collectBankIdResult).not.toHaveBeenCalled() }) it('spends the flow on success, so a second tab cannot send a rival confirmation mail', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) mockServiceClient([ { data: null }, // pnr lookup → not linked { error: null }, // bankid_identities insert OK ]) const response = await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) ) expect(response.status).toBe(200) expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1) // Without this, two tabs that both saw 'complete' would each mint a // link and the second would invalidate the first. expect(clearedFlow(response)).toBe(true) }) it('refuses a login whose session another tab already spent', async () => { // The Set-Cookie clear does NOT make this single-use: two requests that // both already carried the cookie both reach here. The unique index on // bankid_consumed_sessions is what stops the second from minting a rival // magic link that would invalidate the first tab's. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient( [{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }], // pnr is linked { error: { code: '23505', message: 'duplicate key' } }, ) const { status, body } = await parseJsonResponse<{ error?: string }>( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) ) ) expect(status).toBe(400) expect(body.error).toBe('session_invalid') expect(admin.generateLink).not.toHaveBeenCalled() }) it('rolls the new account back when a signup loses the same race', async () => { vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient( [{ data: null }, { error: null }], { error: { code: '23505', message: 'duplicate key' } }, ) const { status } = await parseJsonResponse( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'fresh@example.com' }, }) ) ) expect(status).toBe(400) expect(admin.generateLink).not.toHaveBeenCalled() // A half-created account would strand the address: the retry would hit // account_exists on an account whose password the user never saw. expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid') }) it('fails closed when the single-use claim errors for any other reason', async () => { // Minting a second magic link is worse than asking the user to // authenticate again, so an unreachable table must not be waved through. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient( [{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }], { error: { code: '42P01', message: 'relation does not exist' } }, ) const { status } = await parseJsonResponse( await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('login'), }) ) ) expect(status).toBe(400) expect(admin.generateLink).not.toHaveBeenCalled() }) it('keeps the flow alive when the e-mail is already taken, so it can be corrected', async () => { // account_exists consumes nothing and is usually a typo: forcing a // second BankID round trip to fix an address would be gratuitous. vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) const { admin } = mockServiceClient([{ data: null }]) admin.createUser.mockResolvedValueOnce({ data: { user: null }, error: { status: 422, code: 'email_exists', message: 'already registered' }, } as never) const response = await findCompleteHandler()( createMockRequest('/api/extensions/ext/tic/bankid/complete', { method: 'POST', headers: await flowCookie('signup'), body: { email: 'taken@example.com' }, }) ) expect(response.status).toBe(409) expect(clearedFlow(response)).toBe(false) }) }) }) describe('POST /bankid/start', () => { function findStartHandler() { const route = ticExtension.apiRoutes!.find( (r) => r.method === 'POST' && r.path === '/bankid/start' ) if (!route) throw new Error('POST /bankid/start route not found') return route.handler } it('rejects a request that does not name a flow', async () => { const { status } = await parseJsonResponse( await findStartHandler()( createMockRequest('/api/extensions/ext/tic/bankid/start', { method: 'POST', body: { mode: 'admin' }, }) ) ) expect(status).toBe(400) expect(startBankIdAuth).not.toHaveBeenCalled() }) it('withholds the session id from the response and puts it in the cookie', async () => { vi.mocked(startBankIdAuth).mockResolvedValue({ sessionId: 'secret-session', autoStartToken: 'ast', qrStartToken: 'qrt', qrStartSecret: 'qrs', } as never) const response = await findStartHandler()( createMockRequest('/api/extensions/ext/tic/bankid/start', { method: 'POST', body: { mode: 'signup' }, }) ) const payload = await response.clone().text() const parsed = JSON.parse(payload) as { data: { flowId: string } } // The id is a bearer credential for a personnummer and for a session. // The browser gets the autostart/QR tokens, which identify nobody. expect(payload).not.toContain('secret-session') expect(payload).toContain('ast') expect(parsed.data.flowId).toBeTruthy() const flowCookieHeader = response.headers .getSetCookie() .find((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`)) expect(flowCookieHeader).toMatch(/HttpOnly/i) const [, value] = /^[^=]+=([^;]*)/.exec(flowCookieHeader!)! const flow = await verifyBankIdFlow(decodeURIComponent(value)) expect(flow).toMatchObject({ sessionId: 'secret-session', mode: 'signup', }) expect(flow?.flowId).toBe(parsed.data.flowId) }) }) describe('POST /bankid/poll', () => { function findPollHandler() { const route = ticExtension.apiRoutes!.find( (r) => r.method === 'POST' && r.path === '/bankid/poll' ) if (!route) throw new Error('POST /bankid/poll route not found') return route.handler } it('answers 404 when the browser holds no flow, instead of polling a named session', async () => { const { status } = await parseJsonResponse( await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', body: { sessionId: 'attacker-session' }, }) ) ) expect(status).toBe(404) expect(pollBankIdSession).not.toHaveBeenCalled() }) it('never returns the personnummer', async () => { // This route is skipAuth, so whatever it returns is readable by whoever // holds the flow cookie. The UI only ever needed the names. vi.mocked(pollBankIdSession).mockResolvedValue({ status: 'complete', user: { personalNumber: '199001011234', givenName: 'Anna', surname: 'Andersson', name: 'Anna Andersson', }, } as never) const response = await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers: await flowCookie('signup'), body: { mode: 'signup' }, }) ) const payload = await response.clone().text() const { body } = await parseJsonResponse<{ data: { user?: Record } }>(response) expect(payload).not.toContain('199001011234') expect(body.data.user).toEqual({ givenName: 'Anna', surname: 'Andersson' }) }) it('withholds the holder name from a probe, but gives it to the active poll', async () => { // The mount probe runs before the person here has confirmed the flow is // theirs, so the name must not reach them (it would identify a stranger on // a shared machine). The active poll, reached only after ownership/confirm, // needs the name for the signup e-mail step. vi.mocked(pollBankIdSession).mockResolvedValue({ status: 'complete', user: { givenName: 'Anna', surname: 'Andersson', personalNumber: 'x' }, } as never) const probe = await parseJsonResponse<{ data: { flowId?: string; user?: unknown } }>( await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers: await flowCookie('signup'), body: { mode: 'signup', probe: true }, }) ) ) expect(probe.body.data.flowId).toBe(TEST_FLOW_ID) expect(probe.body.data.user).toBeUndefined() const active = await parseJsonResponse<{ data: { user?: unknown } }>( await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers: await flowCookie('signup'), body: { mode: 'signup' }, }) ) ) expect(active.body.data.user).toEqual({ givenName: 'Anna', surname: 'Andersson' }) }) it('refuses to poll a flow whose mode does not match the panel asking', async () => { // A login session started on /login must not be pollable by the signup // panel: the client would render the signup e-mail step and /complete // would then read mode 'login' off the cookie, either signing the user in // from the "Skapa konto" form or burning the identification on no_account. const { status, body } = await parseJsonResponse<{ error?: string }>( await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers: await flowCookie('login'), body: { mode: 'signup' }, }) ) ) expect(status).toBe(404) expect(body.error).toBe('no_session') expect(pollBankIdSession).not.toHaveBeenCalled() }) it('polls when both the panel mode and tab flow id match', async () => { vi.mocked(pollBankIdSession).mockResolvedValue({ status: 'pending' } as never) const { status } = await parseJsonResponse( await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers: await flowCookie('signup'), body: { mode: 'signup' }, }) ) ) expect(status).toBe(200) expect(pollBankIdSession).toHaveBeenCalledOnce() }) it('refuses a stale tab after a newer same-mode flow replaced the shared cookie', async () => { vi.mocked(pollBankIdSession).mockResolvedValue({ status: 'complete' } as never) const headers = await flowCookie('signup') headers[BANKID_FLOW_ID_HEADER] = 'older-flow' const { status, body } = await parseJsonResponse<{ error?: string }>( await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers, body: { mode: 'signup' }, }) ) ) expect(status).toBe(404) expect(body.error).toBe('no_session') expect(pollBankIdSession).not.toHaveBeenCalled() }) it('does NOT clear the cookie when TIC has forgotten the session', async () => { // A clearing Set-Cookie cannot be aimed at one flow, so a slow response // about a dead session would delete whatever flow is in the jar by the // time it lands, including one the user just started in another tab. const { TICAPIError } = await import('../lib/tic-types') vi.mocked(pollBankIdSession).mockRejectedValueOnce( new TICAPIError('gone', 404) ) const response = await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers: await flowCookie('login'), body: { mode: 'login' }, }) ) const { status, body } = await parseJsonResponse<{ error?: string }>(response) expect(status).toBe(404) expect(body.error).toBe('no_session') const cleared = response.headers .getSetCookie() .some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c)) expect(cleared).toBe(false) }) it('extends the window to the verified budget once identification completes', async () => { // The signup e-mail step is a person typing; the order window (300s) is // too short for it, so completion re-issues at the longer budget. vi.mocked(pollBankIdSession).mockResolvedValue({ status: 'complete', user: { givenName: 'Anna', surname: 'Andersson', personalNumber: 'x' }, } as never) const response = await findPollHandler()( createMockRequest('/api/extensions/ext/tic/bankid/poll', { method: 'POST', headers: await flowCookie('signup'), body: { mode: 'signup' }, }) ) // A fresh signed cookie is issued (Max-Age well past the 300s order window). const reissued = response.headers .getSetCookie() .find((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`)) expect(reissued).toBeDefined() const maxAge = Number(/Max-Age=(\d+)/i.exec(reissued!)?.[1]) expect(maxAge).toBeGreaterThan(300) }) }) describe('POST /bankid/cancel', () => { function findCancelHandler() { const route = ticExtension.apiRoutes!.find( (r) => r.method === 'POST' && r.path === '/bankid/cancel' ) if (!route) throw new Error('POST /bankid/cancel route not found') return route.handler } function clearsFlow(response: Response): boolean { return response.headers .getSetCookie() .some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c)) } it('cancels the flow this browser holds, with no id to aim it', async () => { const response = await findCancelHandler()( createMockRequest('/api/extensions/ext/tic/bankid/cancel', { method: 'POST', headers: await flowCookie('signup'), // A named session in the body must be ignored: the old DELETE route // took one, which let a caller cancel a session they merely knew of. body: { sessionId: 'someone-elses-session' }, }) ) expect(response.status).toBe(200) expect(clearsFlow(response)).toBe(true) expect(cancelBankIdSession).toHaveBeenCalledWith('test-session') }) it('still clears the cookie when TIC cannot be reached', async () => { // Otherwise pressing Avbryt during a TIC outage leaves a flow in the // browser that the next page load resumes. vi.mocked(cancelBankIdSession).mockRejectedValueOnce(new Error('network down')) const response = await findCancelHandler()( createMockRequest('/api/extensions/ext/tic/bankid/cancel', { method: 'POST', headers: await flowCookie('signup'), }) ) expect(response.status).toBe(200) expect(clearsFlow(response)).toBe(true) }) it('does not cancel or clear a newer flow from a stale tab', async () => { const headers = await flowCookie('signup') headers[BANKID_FLOW_ID_HEADER] = 'older-flow' const response = await findCancelHandler()( createMockRequest('/api/extensions/ext/tic/bankid/cancel', { method: 'POST', headers, }) ) const { body } = await parseJsonResponse<{ data?: { cancelled?: boolean; replaced?: boolean } }>(response.clone()) expect(response.status).toBe(200) expect(body.data).toEqual({ cancelled: false, replaced: true }) expect(clearsFlow(response)).toBe(false) expect(cancelBankIdSession).not.toHaveBeenCalled() }) it('is a no-op that still succeeds when there is no flow', async () => { const response = await findCancelHandler()( createMockRequest('/api/extensions/ext/tic/bankid/cancel', { method: 'POST' }) ) expect(response.status).toBe(200) expect(cancelBankIdSession).not.toHaveBeenCalled() }) })