Files
accounted/lib/company/__tests__/context.test.ts
T
Mattsson 288915c152 Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries

The 20260723003000 hardening dropped attachment_filename from
list_invoice_delivery_summaries, so the delivery history UI always fell
back to the generic "faktura.pdf" label. Recreate the RPC with the
filename included: it is derived from company name, customer name,
invoice number, and date, all already visible to every company member,
so the minimization boundary is unchanged. Addresses stay masked and
message content, BCC, and checksums stay server-side.

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

* fix(reconciliation): surface own-account transfer legs in match-to-voucher by default

The second (incoming) leg of a transfer between two of the company's own
bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog
because the voucher counted as 'already matched' once its outgoing leg was
linked, even though the incoming account's line had no settling transaction.
Users read the empty default list as 'the app won't let me link this'.

get_account_gl_lines_for_matching now counts links per settlement account:
a transaction provably on another cash account no longer marks the voucher
as matched for the requested account, so the unsettled transfer leg surfaces
by default (and auto-selects on an exact match). Same-account N:1 stays
behind the 'Visa aven matchade verifikationer' opt-in, and transactions
without a resolvable cash account conservatively keep counting everywhere.
get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile).

Companion guard: mark_entry_as_opening_balance now refuses entries with
linked bank transactions, since half-settled transfer vouchers became
reachable in the reconciliation view's unmatched table where 'Mark som IB'
renders; re-tagging one would strand its transaction against a movement-
excluded entry. getReconciliationStatus counts unmatched GL lines with the
account-scoped RPC so the status card agrees with the table.

Fixes #1026

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

* perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs

Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of
requests over 300ms. Target: p95 under 300ms.

- requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of
  a second network getUser per request; getUser fallback keeps HS256
  self-hosted and existing test mocks working; middleware still
  revocation-checks every /api request
- resolve_active_company RPC (20260723161000): one round trip replaces
  2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall
  back to the legacy query path
- arsredovisning build-data: ~33 sequential round trips down to ~7,
  output byte-identical (snapshot-proven)
- currency rate route: stop bypassing the exchange_rates cache (missing
  supabase arg caused an external Riksbanken call on every request)
- document.get: parallelize row fetch, signed URL and audit event
- list_company_accounts RPC (20260723170000): accounts list in one round
  trip instead of paging past PostgREST's 1000-row cap
- vat-declaration route: drop a dead sequential company_settings query
- get_kpi_report_aggregates RPC (20260723180000): KPI report's three
  full-period line scans collapsed into one aggregate call; dimension-
  filtered path unchanged
- lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to
  warn, zero the eslint baseline ratchet

All four gates green: lint 0 errors, 9163 tests, check:guards, build.
Migrations applied idempotently to staging only; prod receives them via
Supabase branching on merge.

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

* fix(review): resolve PR review findings across auth, VAT declaration, and IB retag

- requireAuth getClaims fast path: pin iss (project URL) and aud
  ('authenticated'), log every fallback to getUser (ASVS V9.1 finding)
- remove the ignored accountingMethod parameter from calculateVatDeclaration
  and the dead company_settings.accounting_method reads in xlsx/pdf/eskd
  routes; v1 API keeps accepting the query param but documents it as a no-op
- close the mark_entry_as_opening_balance TOCTOU race with a transactions
  trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests;
  applied to staging and smoke-verified both directions
- re-add the 42501 tenant guard to branch-local migration 20260723160000
  (function body had silently reverted to the pre-20260619130100 definition)
- document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt
  opening balance per BFNAR 2012:1 ch.29)
- add KPI VAT-liability test covering reduced-rate output accounts 2621/2631

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

* fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard

The re-added tenant guard carried the pre-20260703180000 raw
NOT IN (SELECT user_company_ids()) pattern, which the
null-safe-tenant-guards ratchet blocks. Staging re-synced.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:16:55 +02:00

360 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockCookieSet } = vi.hoisted(() => ({ mockCookieSet: vi.fn() }))
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mockCookieSet })),
}))
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
type CapturedCall = { table: string; method: string; args: unknown[] }
type TerminalResult = { data?: unknown; error?: unknown }
/**
* Chainable Supabase mock (same approach as actions.test.ts): a chain method
* terminates with `results[table][method]` when seeded, otherwise keeps
* chaining. setActiveCompany ends both its queries on `.single()`, on
* different tables, so seeding `single` per table drives each branch.
* A terminal seeded as an ARRAY is consumed in call order, for functions
* that query the same table twice (getActiveCompanyId's fallback fetch +
* preference validation both end on company_members.maybeSingle()).
*
* `rpcResult` seeds supabase.rpc('resolve_active_company'). The default is a
* PGRST202 "function not found" error so every pre-RPC test keeps passing
* unchanged: they now exercise the query fallback path, which is exactly the
* behavior on a not-yet-migrated database.
*/
function buildSupabase(
results: Record<string, Record<string, TerminalResult | TerminalResult[]>>,
rpcResult: TerminalResult = {
data: null,
error: { code: 'PGRST202', message: 'Could not find the function' },
},
) {
const calls: CapturedCall[] = []
function makeChain(table: string) {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
calls.push({ table, method: m, args })
const seeded = results[table]?.[m]
const terminal = Array.isArray(seeded) ? seeded.shift() : seeded
if (terminal) {
return Promise.resolve({ data: terminal.data ?? null, error: terminal.error ?? null })
}
return chain
}
}
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
}
const supabase = {
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
rpc: vi.fn(async () => ({
data: rpcResult.data ?? null,
error: rpcResult.error ?? null,
})),
}
return { supabase, calls }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('setActiveCompany', () => {
it('throws not_member and never writes when the user lacks membership', async () => {
const { supabase, calls } = buildSupabase({
company_members: { single: { data: null, error: { message: 'no rows' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('not_member')
expect(calls.find((c) => c.table === 'user_preferences')).toBeUndefined()
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('throws persist_failed and does NOT set the cookie when the upsert errors (#701)', async () => {
const { supabase } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2' } } },
user_preferences: { single: { data: null, error: { message: 'permission denied' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('persist_failed')
expect(err.message).toContain('permission denied')
// The exact regression from #701: cookie must not diverge from the DB.
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('throws persist_failed when the read-back does not return the new company', async () => {
// An RLS-filtered UPDATE affects zero rows without an error; the
// read-back is what catches it. Simulate a stale/foreign row coming back.
const { supabase } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2' } } },
user_preferences: { single: { data: { active_company_id: 'company-1' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('persist_failed')
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('sets the cookie only after the write is verified', async () => {
const { supabase, calls } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2' } } },
user_preferences: { single: { data: { active_company_id: 'company-2' } } },
})
await expect(setActiveCompany(supabase as never, 'user-1', 'company-2')).resolves.toBeUndefined()
const upsert = calls.find((c) => c.table === 'user_preferences' && c.method === 'upsert')
expect(upsert?.args[0]).toEqual({ user_id: 'user-1', active_company_id: 'company-2' })
expect(mockCookieSet).toHaveBeenCalledTimes(1)
expect(mockCookieSet).toHaveBeenCalledWith(
'gnubok-company-id',
'company-2',
expect.objectContaining({ httpOnly: true, path: '/' }),
)
})
})
describe('getActiveCompanyId', () => {
it('resolves the preferred company with ONE company_members query when it is the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-1')
// The parallel fallback fetch doubles as validation in the common
// single-company case: no second, sequential round trip.
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('validates a preference that differs from the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
{ data: { company_id: 'company-2' } }, // validation of the preference
],
},
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-2')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(2)
})
it('falls back to the first membership when the preference is stale', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-archived' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership
{ data: null }, // validation: preference archived / membership gone
],
},
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
it('falls back to the first membership when there is no preference row', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('returns null when the user has no non-archived memberships', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: null } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
})
// A failed query must throw, never read as "no companies": callers redirect
// the null state to the onboarding wizard, and a transient failure was
// enough to show onboarding to a fully onboarded user (issue #1053).
it('throws resolution_failed when the preferences query fails', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null, error: { message: 'fetch failed' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
})
it('throws resolution_failed when the membership query fails', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: null, error: { message: 'timeout' } } },
})
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
})
it('throws instead of silently switching company when preference validation fails', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
{ data: null, error: { message: 'connection reset' } }, // validation FAILS
],
},
})
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
// Falling back to company-1 here would silently flip a consultant onto
// the wrong company's books.
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
})
})
describe('getActiveCompanyId via resolve_active_company RPC', () => {
it('resolves from the RPC in one call without touching any table', async () => {
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
)
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-1')
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company')
// The whole point of the RPC: zero PostgREST table round trips.
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns null from an RPC row with a null company_id (no companies) without table queries', async () => {
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: null, locale: 'en', used_fallback: true }] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
expect(supabase.from).not.toHaveBeenCalled()
})
it('throws resolution_failed on a non-fallback RPC error instead of masking it', async () => {
const { supabase } = buildSupabase(
{},
{ data: null, error: { code: '57014', message: 'statement timeout' } },
)
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('resolution_failed')
expect(supabase.from).not.toHaveBeenCalled()
})
it('falls back to the query path on 42501 (service-role client lacks EXECUTE)', async () => {
// The mcp-oauth token route and the events route (API-key branch) call
// requireCompanyId with createServiceClientNoCookies(): EXECUTE is
// granted to `authenticated` only, so the RPC refuses with 42501 and the
// query path (filtered by the explicit userId param) must take over.
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
},
{ data: null, error: { code: '42501', message: 'permission denied for function' } },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
it('falls back to the query path on zero RPC rows (NULL auth.uid(), service client)', async () => {
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
},
{ data: [] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
})
describe('getCompanyDisplayName', () => {
it('returns company_settings.company_name and never reads companies when set', async () => {
const { supabase, calls } = buildSupabase({
company_settings: { maybeSingle: { data: { company_name: 'Ny Firma AB' } } },
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
})
const name = await getCompanyDisplayName(supabase as never, 'company-1')
expect(name).toBe('Ny Firma AB')
// companies.name is the frozen onboarding value: it must not be consulted
// when the user has set a current name in settings.
expect(calls.find((c) => c.table === 'companies')).toBeUndefined()
})
it('falls back to companies.name when company_settings has no row', async () => {
const { supabase } = buildSupabase({
company_settings: { maybeSingle: { data: null } },
companies: { maybeSingle: { data: { name: 'Aktiebolaget Grundstenen 000000' } } },
})
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe(
'Aktiebolaget Grundstenen 000000',
)
})
it('falls back to companies.name when company_settings.company_name is empty', async () => {
const { supabase } = buildSupabase({
company_settings: { maybeSingle: { data: { company_name: '' } } },
companies: { maybeSingle: { data: { name: 'Bolaget AB' } } },
})
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBe('Bolaget AB')
})
it('returns null when neither table resolves a name', async () => {
const { supabase } = buildSupabase({
company_settings: { maybeSingle: { data: null } },
companies: { maybeSingle: { data: null } },
})
expect(await getCompanyDisplayName(supabase as never, 'company-1')).toBeNull()
})
})