Files
accounted/app/api/company/check-org-number/__tests__/route.test.ts
T
Jakob WennbergandClaude Fable 5 46c0b72ab0 feat(auth): surface duplicate-account traps around BankID login (#1234)
* feat(auth): surface duplicate-account traps around BankID login

Three escape hatches for the stale-duplicate-account trap (#1231, the
Chillen support case): a user whose BankID resolves to an abandoned
account got an empty app with no hint that their real bookkeeping
lives in another account.

- check-org-number: new exists_elsewhere signal (service role, reduced
  to one boolean) + a warn chip in the onboarding journey when the org
  number already exists in an account the user is not a member of.
- Hem: one AttnLine under the greeting when the whole account has zero
  journal entries but a same-orgnr company elsewhere has real
  bookkeeping, with a sign-out action. Common case costs one indexed
  existence probe.
- scripts/support/unlink-bankid.ts: dry-run-by-default support action
  that unlinks a BankID identity (delete + app_metadata clear +
  append-only SECURITY_EVENT audit_log row). Replaces the raw SQL used
  to resolve the original ticket.

Closes #1231

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): harden unlink script and paginate hint queries per review

- other-account-hint: fetchAllRows() on both company listings (PostgREST
  1000-row cap; byrå users can hold many memberships); the journal probes
  stay limit(1) existence checks.
- unlink-bankid: audit_log row is written BEFORE the delete so a partial
  failure can never delete without a trace; context queries fail closed
  instead of rendering an unknown account as empty; stdout no longer
  prints the personnummer hash or ciphertext (the unsalted hash is
  brute-forceable over the personnummer space); record_id now carries the
  identity row id and the snapshot includes id + linked_at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:00:15 +02:00

167 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { GET } from '../route'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
const mockCreateClient = vi.mocked(createClient)
const mockCreateServiceClient = vi.mocked(createServiceClient)
/** Service-client mock for the cross-account probe: one companies query. */
function buildServiceClient(result: { data?: unknown; error?: unknown }) {
const resolved = { data: result.data ?? null, error: result.error ?? null }
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'is', 'limit']) {
chain[m] = () => chain
}
;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(resolved)
return { from: vi.fn(() => chain) }
}
/**
* Minimal authenticated-client mock. `companies.data` seeds what the RLS-scoped
* `from('companies').select().eq().is()` chain resolves to. In production RLS
* filters this to the caller's own memberships; the route does no extra
* filtering, so the test just controls what the query returns.
*/
function buildSupabase(opts: {
user: { id: string } | null
companies?: { data?: unknown; error?: unknown }
}) {
const result = {
data: opts.companies?.data ?? null,
error: opts.companies?.error ?? null,
}
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'is', 'limit', 'order']) {
chain[m] = () => chain
}
;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(result)
return {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }) },
from: vi.fn(() => chain),
}
}
beforeEach(() => {
vi.clearAllMocks()
// Default: nothing exists anywhere else. Individual tests override.
mockCreateServiceClient.mockReturnValue(buildServiceClient({ data: [] }) as never)
})
describe('GET /api/company/check-org-number', () => {
it('returns 401 when unauthenticated', async () => {
mockCreateClient.mockResolvedValue(buildSupabase({ user: null }) as never)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when org_number is missing', async () => {
mockCreateClient.mockResolvedValue(buildSupabase({ user: { id: 'u1' } }) as never)
const res = await GET(createMockRequest('/api/company/check-org-number'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns exists:false for malformed org_number without querying', async () => {
const supabase = buildSupabase({ user: { id: 'u1' } })
mockCreateClient.mockResolvedValue(supabase as never)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=not-a-number'))
const { status, body } = await parseJsonResponse<{
data: { exists: boolean; companies: unknown[] }
}>(res)
expect(status).toBe(200)
expect(body.data.exists).toBe(false)
expect(body.data.companies).toEqual([])
expect(supabase.from).not.toHaveBeenCalled()
})
it("reports the user's own matching companies (account-scoped via RLS)", async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({
user: { id: 'u1' },
companies: { data: [{ id: 'c1', name: 'Acme AB' }] },
}) as never,
)
// Hyphenated input still matches the stored 10-digit canonical.
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=556012-5790'))
const { status, body } = await parseJsonResponse<{
data: { exists: boolean; companies: { id: string; name: string }[] }
}>(res)
expect(status).toBe(200)
expect(body.data.exists).toBe(true)
expect(body.data.companies).toEqual([{ id: 'c1', name: 'Acme AB' }])
})
it('returns exists:false when the user has no company with that org number', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status, body } = await parseJsonResponse<{ data: { exists: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.exists).toBe(false)
})
it('reports exists_elsewhere when the org number lives in another account', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never,
)
mockCreateServiceClient.mockReturnValue(
buildServiceClient({ data: [{ id: 'other-account-co' }] }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status, body } = await parseJsonResponse<{
data: { exists: boolean; companies: unknown[]; exists_elsewhere: boolean }
}>(res)
expect(status).toBe(200)
expect(body.data.exists).toBe(false)
// Existence only: the other account's company is never listed.
expect(body.data.companies).toEqual([])
expect(body.data.exists_elsewhere).toBe(true)
})
it('does not report exists_elsewhere when all matches are the caller’s own', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({
user: { id: 'u1' },
companies: { data: [{ id: 'c1', name: 'Acme AB' }] },
}) as never,
)
mockCreateServiceClient.mockReturnValue(
buildServiceClient({ data: [{ id: 'c1' }] }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { body } = await parseJsonResponse<{ data: { exists_elsewhere: boolean } }>(res)
expect(body.data.exists_elsewhere).toBe(false)
})
it('fails soft to exists_elsewhere:false when the probe errors', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never,
)
mockCreateServiceClient.mockReturnValue(
buildServiceClient({ error: { message: 'probe boom' } }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status, body } = await parseJsonResponse<{ data: { exists_elsewhere: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.exists_elsewhere).toBe(false)
})
it('returns 500 when the query errors', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { error: { message: 'boom' } } }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(500)
})
})