* 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>
167 lines
6.8 KiB
TypeScript
167 lines
6.8 KiB
TypeScript
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)
|
||
})
|
||
})
|