diff --git a/app/api/company/[id]/delete/__tests__/route.test.ts b/app/api/company/[id]/delete/__tests__/route.test.ts index 0688dd1a..5c43a2de 100644 --- a/app/api/company/[id]/delete/__tests__/route.test.ts +++ b/app/api/company/[id]/delete/__tests__/route.test.ts @@ -24,7 +24,10 @@ const mockCreateServiceClient = vi.mocked(createServiceClient) type Row = { data: unknown; error: unknown } -function mockService(rowsByTable: Record) { +function mockService( + rowsByTable: Record, + updateResults: Record = {}, +) { const insertSpy = vi.fn().mockResolvedValue({ data: null, error: null }) const updateSpies: Record> = {} @@ -32,12 +35,22 @@ function mockService(rowsByTable: Record) { const queue = rowsByTable[table] ?? [] const next = queue.shift() ?? { data: null, error: null } - const updateFn = vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - eq: vi.fn().mockResolvedValue({ data: null, error: null }), - then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }), - }), - }) + // Awaitable at any depth: update().eq(...), update().eq(...).eq(...), + // update().eq(...).neq(...).select(...) all resolve to the table's + // configured update result (default: one revoked row, no error). + const updateResult = updateResults[table] ?? { data: [{ id: `${table}-1` }], error: null } + const updateChain: { + eq: ReturnType + neq: ReturnType + select: ReturnType + then: (resolve: (v: unknown) => void) => void + } = { + eq: vi.fn(() => updateChain), + neq: vi.fn(() => updateChain), + select: vi.fn(() => updateChain), + then: (resolve: (v: unknown) => void) => resolve(updateResult), + } + const updateFn = vi.fn().mockReturnValue(updateChain) updateSpies[table] = updateFn return { @@ -244,4 +257,117 @@ describe('POST /api/company/[id]/delete', () => { expect(emitted).toHaveLength(1) expect(emitted[0]).toMatchObject({ companyId: 'c1', userId: 'user-1' }) }) + + it('revokes store connections on archive so the store-uniqueness indexes free up', async () => { + // Regression: archived companies are hidden by user_company_ids(), so an + // 'active' connection left behind blocks reconnecting the same store from + // a new company ("Butiken är redan ansluten till ett företag") with no + // user-reachable disconnect. + mockAuth('user-1') + const { updateSpies } = mockService({ + companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], + company_members: [{ data: { role: 'owner' }, error: null }], + }) + + const req = createMockRequest('/api/company/c1/delete', { + method: 'POST', + body: { confirm_name: 'Acme AB' }, + }) + const { status } = await parseJsonResponse( + await POST(req, createMockRouteParams({ id: 'c1' })) + ) + expect(status).toBe(200) + + expect(updateSpies.woocommerce_connections).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'revoked', + disconnected_at: expect.any(String), + consumer_key_encrypted: null, + consumer_secret_encrypted: null, + oauth_state: null, + }) + ) + expect(updateSpies.shopify_connections).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'revoked', + disconnected_at: expect.any(String), + client_id_encrypted: null, + client_secret_encrypted: null, + }) + ) + expect(updateSpies.stripe_connections).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'revoked', + disconnected_at: expect.any(String), + oauth_state: null, + }) + ) + }) + + it('writes audit_log rows for each revoked connection', async () => { + mockAuth('user-1') + const { insertSpy } = mockService({ + companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], + company_members: [{ data: { role: 'owner' }, error: null }], + }) + + const req = createMockRequest('/api/company/c1/delete', { + method: 'POST', + body: { confirm_name: 'Acme AB' }, + }) + const { status } = await parseJsonResponse( + await POST(req, createMockRouteParams({ id: 'c1' })) + ) + expect(status).toBe(200) + + // One audit insert per revoked connection row. + for (const table of [ + 'woocommerce_connections', + 'shopify_connections', + 'stripe_connections', + ]) { + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'UPDATE', + table_name: table, + record_id: `${table}-1`, + company_id: 'c1', + user_id: 'user-1', + new_state: expect.objectContaining({ status: 'revoked' }), + }), + ) + } + }) + + it('still archives the company when a connection revoke update fails', async () => { + mockAuth('user-1') + const { insertSpy, updateSpies } = mockService( + { + companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }], + company_members: [{ data: { role: 'owner' }, error: null }], + }, + { + woocommerce_connections: { data: null, error: { message: 'boom' } }, + }, + ) + + const req = createMockRequest('/api/company/c1/delete', { + method: 'POST', + body: { confirm_name: 'Acme AB' }, + }) + const { status } = await parseJsonResponse( + await POST(req, createMockRouteParams({ id: 'c1' })) + ) + + // Non-fatal by design: the archive already happened. + expect(status).toBe(200) + expect(updateSpies.companies).toHaveBeenCalled() + // No audit rows for the failed table, but the other tables still processed. + expect(insertSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ table_name: 'woocommerce_connections' }), + ) + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ table_name: 'shopify_connections' }), + ) + }) }) diff --git a/app/api/company/[id]/delete/route.ts b/app/api/company/[id]/delete/route.ts index 2c69db0a..103e83e1 100644 --- a/app/api/company/[id]/delete/route.ts +++ b/app/api/company/[id]/delete/route.ts @@ -136,7 +136,95 @@ export async function POST( .eq('user_id', user.id) .eq('active_company_id', companyId) - // 6. Write audit log row. companies has no auto-audit trigger, so do it + // 6. Revoke the company's store connections. The store-uniqueness indexes + // (e.g. woocommerce_connections_store_active_uniq) allow a store to be + // actively connected to at most ONE company, and user_company_ids() hides + // archived companies, so an 'active' row left behind would block + // reconnecting the store from any new company with no user-reachable + // disconnect. Same status flip as the manual disconnect paths, secrets + // nulled. Non-fatal: the archive already happened, but never silent. + // .neq('revoked') rather than pending/active: 'error'-state rows also + // carry credentials and are just as unreachable after the archive. Each + // update carries its payload as an object literal so the phantom-column + // scanner can resolve every column. + const revokes = [ + { + table: 'woocommerce_connections', + result: await service + .from('woocommerce_connections') + .update({ + status: 'revoked', + disconnected_at: archivedAt, + consumer_key_encrypted: null, + consumer_secret_encrypted: null, + oauth_state: null, + }) + .eq('company_id', companyId) + .neq('status', 'revoked') + .select('id'), + }, + { + table: 'shopify_connections', + result: await service + .from('shopify_connections') + .update({ + status: 'revoked', + disconnected_at: archivedAt, + client_id_encrypted: null, + client_secret_encrypted: null, + }) + .eq('company_id', companyId) + .neq('status', 'revoked') + .select('id'), + }, + { + table: 'stripe_connections', + result: await service + .from('stripe_connections') + .update({ + status: 'revoked', + disconnected_at: archivedAt, + oauth_state: null, + }) + .eq('company_id', companyId) + .neq('status', 'revoked') + .select('id'), + }, + ] + for (const { table, result } of revokes) { + if (result.error) { + log.error('Failed to revoke store connections on company archive', { + companyId, + table, + error: result.error.message, + }) + continue + } + // Credential revocation is a compliance-critical mutation: audit it like + // the archive itself (the tables have no auto-audit trigger). Non-fatal, + // same doctrine as the archive's own audit write below. + for (const row of (result.data ?? []) as Array<{ id: string }>) { + const { error: revokeAuditError } = await service.from('audit_log').insert({ + user_id: user.id, + company_id: companyId, + action: 'UPDATE', + table_name: table, + record_id: row.id, + actor_id: user.id, + new_state: { status: 'revoked', disconnected_at: archivedAt }, + description: `Store connection revoked on company archive: ${company.name}`, + }) + if (revokeAuditError) { + log.error('Failed to write audit_log row for connection revocation', { + companyId, + table, + error: revokeAuditError.message, + }) + } + } + } + + // 7. Write audit log row. companies has no auto-audit trigger, so do it // explicitly. Service client bypasses audit_log RLS (no INSERT policy). // The archive already happened — don't fail the request, but an audit // write failing on an irreversible action must never be silent. @@ -158,13 +246,13 @@ export async function POST( }) } - // 7. Emit event + // 8. Emit event await eventBus.emit({ type: 'company.deleted', payload: { companyId, userId: user.id, archivedAt }, }) - // 8. Build response and clear company cookie if it matched + // 9. Build response and clear company cookie if it matched const response = NextResponse.json({ data: { companyId, archivedAt } }) const cookieCompanyId = request.headers.get('cookie')?.match(/gnubok-company-id=([^;]+)/)?.[1]