Files
accounted/lib/company/__tests__/other-account-hint.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

128 lines
4.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
}))
import { createServiceClient } from '@/lib/supabase/server'
import { shouldShowOtherAccountHint } from '../other-account-hint'
import type { SupabaseClient } from '@supabase/supabase-js'
const mockCreateServiceClient = vi.mocked(createServiceClient)
/**
* Chainable query mock keyed by table name: every method returns the chain,
* awaiting it resolves with the configured { data, error } for that table.
*/
function buildClient(resultsByTable: Record<string, { data?: unknown; error?: unknown }>) {
return {
from: vi.fn((table: string) => {
const result = {
data: resultsByTable[table]?.data ?? null,
error: resultsByTable[table]?.error ?? null,
}
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'is', 'limit', 'order', 'range']) {
chain[m] = () => chain
}
;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(result)
return chain
}),
}
}
const OWN_COMPANY = { id: 'own-co', org_number: '5560125790' }
beforeEach(() => {
vi.clearAllMocks()
mockCreateServiceClient.mockReturnValue(
buildClient({ companies: { data: [] }, journal_entries: { data: [] } }) as never,
)
})
describe('shouldShowOtherAccountHint', () => {
it('is false when the account has journal entries (common case, no probe)', async () => {
const supabase = buildClient({
companies: { data: [OWN_COMPANY] },
journal_entries: { data: [{ id: 'je1' }] },
})
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
expect(mockCreateServiceClient).not.toHaveBeenCalled()
})
it('is true when the account is empty and a same-orgnr company elsewhere has entries', async () => {
const supabase = buildClient({
companies: { data: [OWN_COMPANY] },
journal_entries: { data: [] },
})
mockCreateServiceClient.mockReturnValue(
buildClient({
companies: { data: [{ id: 'own-co' }, { id: 'other-co' }] },
journal_entries: { data: [{ id: 'je-other' }] },
}) as never,
)
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(true)
})
it('is false when the same-orgnr company elsewhere is also empty', async () => {
const supabase = buildClient({
companies: { data: [OWN_COMPANY] },
journal_entries: { data: [] },
})
mockCreateServiceClient.mockReturnValue(
buildClient({
companies: { data: [{ id: 'own-co' }, { id: 'other-co' }] },
journal_entries: { data: [] },
}) as never,
)
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
})
it('is false when no other account shares the org number', async () => {
const supabase = buildClient({
companies: { data: [OWN_COMPANY] },
journal_entries: { data: [] },
})
mockCreateServiceClient.mockReturnValue(
buildClient({
companies: { data: [{ id: 'own-co' }] },
journal_entries: { data: [{ id: 'je-other' }] },
}) as never,
)
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
})
it('is false when the user has no companies', async () => {
const supabase = buildClient({ companies: { data: [] }, journal_entries: { data: [] } })
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
})
it('is false when own companies have no org number', async () => {
const supabase = buildClient({
companies: { data: [{ id: 'own-co', org_number: null }] },
journal_entries: { data: [] },
})
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
expect(mockCreateServiceClient).not.toHaveBeenCalled()
})
it('fails soft to false on query errors', async () => {
const supabase = buildClient({
companies: { error: { message: 'boom' } },
journal_entries: { data: [] },
})
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
})
it('fails soft to false when the service client throws', async () => {
const supabase = buildClient({
companies: { data: [OWN_COMPANY] },
journal_entries: { data: [] },
})
mockCreateServiceClient.mockImplementation(() => {
throw new Error('no service key')
})
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
})
})