Files
accounted/lib/company/__tests__/actions.test.ts
T
Jakob WennbergandClaude Fable 5 4253afc343 fix(company): validate user_preferences write when switching company (#708)
Fixes #701. setActiveCompany upserted active_company_id without checking
the result, then set the gnubok-company-id cookie unconditionally. A failed
write — including an RLS-filtered UPDATE, which affects zero rows without
raising an error — looked like a successful switch: switchCompany returned
{}, the UI hard-reloaded, and middleware (which reads user_preferences, not
the cookie) resolved the old company.

- setActiveCompany now verifies the upsert with .select().single() and
  throws a typed CompanyContextError ('not_member' | 'persist_failed');
  the cookie is only set after the write is confirmed, so it can no longer
  diverge from the database.
- switchCompany logs the failure and returns distinct error codes instead
  of reporting every failure as a permissions problem.
- CompanySwitcher now shows a destructive toast on failure (it previously
  failed with no feedback); BankIdCompanyPicker translates the codes.
  Messages added to sv/en under company_switcher and select_company.
- The remaining fire-and-forget user_preferences writers (middleware
  fallback write-back, team invite accept, auth callback invite accept)
  now check and log errors; non-fatal by design since each has a working
  fallback path.
- New tests cover every failure mode, including cookie-not-set on a failed
  write and the silent zero-row write caught by the read-back.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:54:04 +02:00

332 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
// Keep the real CompanyContextError so instanceof checks in switchCompany
// see the same class the tests throw.
vi.mock('@/lib/company/context', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/company/context')>()),
setActiveCompany: vi.fn().mockResolvedValue(undefined),
}))
import { createClient } from '@/lib/supabase/server'
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
import { createCompanyFromOnboarding, switchCompany } from '../actions'
const mockCreateClient = vi.mocked(createClient)
const mockSetActiveCompany = vi.mocked(setActiveCompany)
type CapturedCall = { table: string; method: string; args: unknown[] }
/**
* Builds a chainable Supabase mock that records every method call, allows
* per-table result seeding, and returns a capture log the test can assert on.
*
* - `results[table][method]` (optional) is returned when the chain ends on
* that method. Chains otherwise resolve to `{ data: null, error: null }`.
* - Unknown methods on the chain no-op and return the chain so callers can
* keep chaining freely.
*/
function buildSupabase(opts: {
user: { id: string } | null
results?: Record<string, Record<string, { data?: unknown; error?: unknown }>>
rpcResults?: Record<string, { data?: unknown; error?: unknown }>
}) {
const calls: CapturedCall[] = []
const { user, results = {}, rpcResults = {} } = opts
function makeChain(table: string) {
const record = (method: string, args: unknown[]) => {
calls.push({ table, method, args })
}
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'in', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
record(m, args)
const canTerminate = results[table]?.[m]
if (canTerminate) {
return Promise.resolve({
data: canTerminate.data ?? null,
error: canTerminate.error ?? null,
})
}
return chain
}
}
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
}
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
},
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
rpc: vi.fn().mockImplementation((name: string) => {
const result = rpcResults[name]
if (result) {
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
}
return Promise.resolve({ data: null, error: null })
}),
}
return { supabase, calls }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('switchCompany', () => {
it('returns {} when the switch persists', async () => {
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
mockCreateClient.mockResolvedValue(supabase as never)
const result = await switchCompany('company-2')
expect(result).toEqual({})
expect(mockSetActiveCompany).toHaveBeenCalledWith(supabase, 'user-1', 'company-2')
})
it('returns Unauthorized when there is no user', async () => {
const { supabase } = buildSupabase({ user: null })
mockCreateClient.mockResolvedValue(supabase as never)
const result = await switchCompany('company-2')
expect(result).toEqual({ error: 'Unauthorized' })
expect(mockSetActiveCompany).not.toHaveBeenCalled()
})
it('maps a membership failure to the not_member code', async () => {
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
mockCreateClient.mockResolvedValue(supabase as never)
mockSetActiveCompany.mockRejectedValueOnce(
new CompanyContextError('User is not a member of this company', 'not_member'),
)
const result = await switchCompany('company-2')
expect(result).toEqual({ error: 'not_member' })
})
it('maps a failed user_preferences write to persist_failed, not a permissions error (#701)', async () => {
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
mockCreateClient.mockResolvedValue(supabase as never)
mockSetActiveCompany.mockRejectedValueOnce(
new CompanyContextError('Failed to persist active company: timeout', 'persist_failed'),
)
const result = await switchCompany('company-2')
expect(result).toEqual({ error: 'persist_failed' })
})
it('maps unexpected errors to persist_failed rather than claiming missing access', async () => {
const { supabase } = buildSupabase({ user: { id: 'user-1' } })
mockCreateClient.mockResolvedValue(supabase as never)
mockSetActiveCompany.mockRejectedValueOnce(new Error('cookies unavailable'))
const result = await switchCompany('company-2')
expect(result).toEqual({ error: 'persist_failed' })
})
})
describe('createCompanyFromOnboarding — org_number validation', () => {
it('rejects malformed org_numbers at the guard boundary', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
rpcResults: { create_company_with_owner: { data: 'x' } },
})
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Broken AB',
org_number: 'abc123', // not a 10- or 12-digit number
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
expect(result.error).toBe('org_number_invalid')
// Must NOT have reached the create RPC — otherwise we'd save a malformed
// org_number and poison SIE/SRU exports.
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
expect(rpcCreate).toBeUndefined()
})
it('rejects right-length org_numbers with invalid Luhn check digit', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
rpcResults: { create_company_with_owner: { data: 'x' } },
})
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Fake AB',
// 10 digits but Luhn check digit is wrong (real Volvo is 5560125790;
// the trailing 1 is an intentional off-by-one). Skatteverket SRU
// validators and receiving SIE4 consumers would reject this, so we
// refuse at the boundary.
org_number: '5560125791',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
expect(result.error).toBe('org_number_invalid')
const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner')
expect(rpcCreate).toBeUndefined()
})
})
describe('createCompanyFromOnboarding — TIC snapshot persistence', () => {
it('persists the supplied ticLookup to companies.tic_snapshot', async () => {
const { supabase, calls } = buildSupabase({
user: { id: 'user-1' },
rpcResults: {
create_company_with_owner: { data: 'new-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
const ticLookup = {
companyName: 'Acme AB',
isCeased: false,
address: { street: 'Storgatan 1', postalCode: '11122', city: 'Stockholm' },
registration: { fTax: true, vat: true },
bankAccounts: [],
email: null,
phone: null,
sniCodes: [{ code: '62010', name: 'Dataprogrammering' }],
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
legalEntityType: 'AB',
registrationDate: 0,
}
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Acme AB',
org_number: '5560125790',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
ticLookup,
})
expect(result.companyId).toBe('new-company-id')
// The lookup must have been UPDATEd onto the freshly-created company row.
// Two updates run on `companies`: one for org_number, one for tic_snapshot.
const companyUpdates = calls.filter(
(c) => c.table === 'companies' && c.method === 'update',
)
const snapshotUpdate = companyUpdates.find((c) => {
const payload = c.args[0] as Record<string, unknown>
return 'tic_snapshot' in payload
})
expect(snapshotUpdate).toBeDefined()
const payload = snapshotUpdate!.args[0] as Record<string, unknown>
expect(payload.tic_snapshot).toEqual(ticLookup)
expect(payload.tic_snapshot_fetched_at).toBeDefined()
})
it('skips the snapshot update when no ticLookup is supplied (manual signup)', async () => {
const { supabase, calls } = buildSupabase({
user: { id: 'user-1' },
rpcResults: {
create_company_with_owner: { data: 'new-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Manual AB',
// No org_number — exercises the path where the org_number UPDATE also
// doesn't run, so we can isolate the no-snapshot guarantee.
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
// ticLookup intentionally omitted
})
expect(result.companyId).toBe('new-company-id')
// No update touched tic_snapshot at all.
const snapshotUpdate = calls.find((c) => {
if (c.table !== 'companies' || c.method !== 'update') return false
const payload = c.args[0] as Record<string, unknown>
return 'tic_snapshot' in payload
})
expect(snapshotUpdate).toBeUndefined()
})
it('does NOT call the heavy /profile endpoint at signup (regression: was 13 calls/signup)', async () => {
// The signup path used to call ensureTicSnapshot which fetches /profile.
// We removed it because it timed out 100% of the time, costing 13 Lens
// calls each. This test prevents anyone from re-adding it by checking
// that fetch is never invoked during the action.
vi.stubGlobal('fetch', vi.fn())
const { supabase } = buildSupabase({
user: { id: 'user-1' },
rpcResults: {
create_company_with_owner: { data: 'new-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Acme AB',
org_number: '5560125790',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
expect(fetch).not.toHaveBeenCalled()
})
})