From 9a7f067fdd4ed205389540d3e30d763f820f32dc Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:20:17 +0200 Subject: [PATCH] Revert "fix(tic): call /enrichment before /collect to avoid session-consume bug (#367)" (#368) This reverts commit 34955b04183313c4ba401560d12babfd6cbb64d5. --- .../tic/__tests__/bankid-complete.test.ts | 152 +----------------- extensions/general/tic/index.ts | 82 ++++------ 2 files changed, 30 insertions(+), 204 deletions(-) diff --git a/extensions/general/tic/__tests__/bankid-complete.test.ts b/extensions/general/tic/__tests__/bankid-complete.test.ts index b61b756b..3dda2d0f 100644 --- a/extensions/general/tic/__tests__/bankid-complete.test.ts +++ b/extensions/general/tic/__tests__/bankid-complete.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), })) -import { collectBankIdResult, requestEnrichment, fetchEnrichmentData } from '../lib/bankid-client' +import { collectBankIdResult } from '../lib/bankid-client' import { createServiceClient } from '@/lib/supabase/server' import { ticExtension } from '../index' @@ -237,156 +237,8 @@ describe('POST /bankid/complete', () => { const { status } = await parseJsonResponse(await findCompleteHandler()(req)) expect(status).toBe(400) - // Neither TIC call should fire — input validation happens first. + // collectBankIdResult should never be called — validation happens first. expect(collectBankIdResult).not.toHaveBeenCalled() - expect(requestEnrichment).not.toHaveBeenCalled() - }) - }) - - // Regression suite for the TIC session-consume bug confirmed 2026-04-24. - // TIC marks a session as "consumed" on any /poll or /collect read after - // status=complete, after which /enrichment refuses the sessionId. The fix - // is to call /enrichment BEFORE /collect. - describe('TIC session-consume bug regression — call order', () => { - it('calls requestEnrichment BEFORE collectBankIdResult', async () => { - vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) - vi.mocked(requestEnrichment).mockResolvedValue({ - enrichmentId: 'e-1', - sessionId: 'test-session', - status: 'Completed', - requestedTypes: ['CompanyRoles'], - completedTypes: ['CompanyRoles'], - secureUrl: '/api/v1/enrichment/data/abc', - secureUrlExpiresAtUtc: '2099-01-01T00:00:00Z', - }) - vi.mocked(fetchEnrichmentData).mockResolvedValue({ - personalNumber: '199001011234', - name: 'Anna Andersson', - enrichedAtUtc: '2026-04-27T00:00:00Z', - companyRoles: [], - }) - mockServiceClient([ - { data: null }, // pnr lookup → not linked - ]) - - const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { - method: 'POST', - body: { sessionId: 'test-session', mode: 'login' }, - }) - await findCompleteHandler()(req) - - const enrichOrder = vi.mocked(requestEnrichment).mock.invocationCallOrder[0] - const collectOrder = vi.mocked(collectBankIdResult).mock.invocationCallOrder[0] - - expect(enrichOrder).toBeDefined() - expect(collectOrder).toBeDefined() - expect(enrichOrder).toBeLessThan(collectOrder) - }) - - it('login still succeeds and storeEnrichment is skipped when enrichment throws', async () => { - vi.mocked(requestEnrichment).mockRejectedValue(new Error('TIC unreachable')) - vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) - const { admin, client } = mockServiceClient([ - { data: { user_id: 'existing-user' } }, // pnr lookup → linked - ]) - - const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { - method: 'POST', - body: { sessionId: 'test-session', mode: 'login' }, - }) - const { status, body } = await parseJsonResponse<{ - data?: { tokenHash?: string } - }>(await findCompleteHandler()(req)) - - expect(status).toBe(200) - expect(body.data?.tokenHash).toBe('magic-token-hash') - expect(admin.generateLink).toHaveBeenCalled() - - // No extension_data write — the existing flow only touched bankid_identities. - const fromTables = vi.mocked(client.from).mock.calls.map((c) => c[0]) - expect(fromTables).not.toContain('extension_data') - }) - - it('login still succeeds when /enrichment returns the "Session not completed" body shape', async () => { - // The exact shape TIC returns today for our tenant. Reproduces the - // production failure mode that prompted this fix. - vi.mocked(requestEnrichment).mockResolvedValue({ - enrichmentId: '00000000-0000-0000-0000-000000000000', - sessionId: 'test-session', - status: 'failed', - requestedTypes: ['CompanyRoles'], - completedTypes: [], - error: 'Session not completed', - secureUrl: '', - secureUrlExpiresAtUtc: '', - } as unknown as Awaited>) - vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) - const { admin, client } = mockServiceClient([ - { data: { user_id: 'existing-user' } }, // pnr lookup → linked - ]) - - const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { - method: 'POST', - body: { sessionId: 'test-session', mode: 'login' }, - }) - const { status, body } = await parseJsonResponse<{ - data?: { tokenHash?: string } - }>(await findCompleteHandler()(req)) - - expect(status).toBe(200) - expect(body.data?.tokenHash).toBe('magic-token-hash') - expect(admin.generateLink).toHaveBeenCalled() - expect(fetchEnrichmentData).not.toHaveBeenCalled() - - const fromTables = vi.mocked(client.from).mock.calls.map((c) => c[0]) - expect(fromTables).not.toContain('extension_data') - }) - - it('writes extension_data when enrichment succeeds', async () => { - vi.mocked(requestEnrichment).mockResolvedValue({ - enrichmentId: 'e-1', - sessionId: 'test-session', - status: 'Completed', - requestedTypes: ['CompanyRoles'], - completedTypes: ['CompanyRoles'], - secureUrl: '/api/v1/enrichment/data/abc', - secureUrlExpiresAtUtc: '2099-01-01T00:00:00Z', - }) - const enrichmentBlob = { - personalNumber: '199001011234', - name: 'Anna Andersson', - enrichedAtUtc: '2026-04-27T00:00:00Z', - companyRoles: [ - { - companyId: 1, - companyRegistrationNumber: '5560000001', - legalName: 'Acme AB', - legalEntityType: 'AB', - positionTypes: ['Ledamot'], - positionDescriptions: ['Styrelseledamot'], - positionStart: '2024-01-01', - positionEnd: null, - companyStatus: 'Active', - }, - ], - } - vi.mocked(fetchEnrichmentData).mockResolvedValue(enrichmentBlob) - vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) - const { client } = mockServiceClient([ - { data: { user_id: 'existing-user' } }, // pnr lookup → linked - ]) - - const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { - method: 'POST', - body: { sessionId: 'test-session', mode: 'login' }, - }) - const { status } = await parseJsonResponse(await findCompleteHandler()(req)) - - expect(status).toBe(200) - expect(fetchEnrichmentData).toHaveBeenCalledWith('/api/v1/enrichment/data/abc') - - const fromTables = vi.mocked(client.from).mock.calls.map((c) => c[0]) - expect(fromTables).toContain('extension_data') }) }) }) diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index c68b04bf..d23110ea 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -19,7 +19,7 @@ import { } from './lib/bankid-client' import { TICAPIError } from './lib/tic-types' import type { TICCompanyProfile } from './lib/tic-types' -import type { BankIdCompleteRequest, EnrichmentData } from './lib/bankid-types' +import type { BankIdCompleteRequest } from './lib/bankid-types' import type { CompanyLookupResult } from '@/lib/company-lookup/types' import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid' import { createServiceClient } from '@/lib/supabase/server' @@ -30,19 +30,20 @@ import crypto from 'crypto' const log = createLogger('tic/bankid') /** - * Fetch CompanyRoles enrichment for a completed BankID session. + * Request CompanyRoles enrichment for a completed BankID session and cache + * the result in `extension_data` so /select-company can pre-fill the picker. + * Non-blocking: any failure is logged and swallowed — BankID auth must still + * succeed even if enrichment is down. * - * MUST be called before collectBankIdResult: TIC's session state machine marks - * a session as "consumed" when /poll or /collect is read after it reaches - * `complete`, after which /enrichment refuses the sessionId with - * `error: 'Session not completed'` (confirmed by TIC support 2026-04-24). - * Calling /enrichment first, then /collect, avoids the bug — the consume flag - * only blocks subsequent /enrichment calls, of which there are none. - * - * Non-blocking: any failure is logged and returns null so BankID auth still - * succeeds even if enrichment is down. + * Only types currently enabled on the TIC tenant are requested — see the + * block comment inside the function. If Address (formerly SPAR) is enabled + * later, add it here to restore address pre-fill in the manual wizard. */ -async function fetchEnrichmentSafely(sessionId: string): Promise { +async function fetchAndStoreEnrichment( + sessionId: string, + userId: string, + supabase: SupabaseClient, +): Promise { try { // IMPORTANT: only request types that are actually enabled on the TIC // tenant. Requesting an unknown/disabled type (e.g. 'SPAR', which TIC @@ -73,6 +74,7 @@ async function fetchEnrichmentSafely(sessionId: string): Promise { - try { await supabase .from('extension_data') .upsert({ user_id: userId, extension_id: 'tic', key: 'bankid_enrichment', - value: data, + value: enrichmentData, }, { onConflict: 'user_id,extension_id,key' }) - } catch (storeError) { - log.warn('storeEnrichment failed (non-blocking)', storeError) + } catch (enrichError) { + log.warn('enrichment failed (non-blocking)', enrichError) } } @@ -637,14 +619,6 @@ export const ticExtension: Extension = { ) } - // CRITICAL ORDER: /enrichment must run BEFORE /collect. TIC's session - // state machine marks the session as "consumed" on any /poll or /collect - // read after `complete`, after which /enrichment refuses the sessionId - // with `error: 'Session not completed'`. Confirmed by TIC support - // 2026-04-24. Calling /enrichment first leaves us free to call /collect - // afterward — the consume flag only blocks subsequent /enrichment calls. - const enrichmentData = await fetchEnrichmentSafely(sessionId) - // Verify BankID session is complete const session = await collectBankIdResult(sessionId) if (session.status !== 'complete' || !session.user) { @@ -697,7 +671,7 @@ export const ticExtension: Extension = { } // Refresh enrichment so /select-company sees current Bolagsverket roles. - if (enrichmentData) await storeEnrichment(existing.user_id, supabase, enrichmentData) + await fetchAndStoreEnrichment(sessionId, existing.user_id, supabase) return NextResponse.json({ data: { @@ -797,7 +771,7 @@ export const ticExtension: Extension = { } // Enrichment (CompanyRoles) — pre-fills /select-company picker. - if (enrichmentData) await storeEnrichment(userId, supabase, enrichmentData) + await fetchAndStoreEnrichment(sessionId, userId, supabase) return NextResponse.json({ data: {