Files
accounted/extensions/general/skatteverket/__tests__/resolve-auth.test.ts
T
MattssonandClaude Fable 5 1fa34aa7ca feat(skatteverket): repair notification recipients + make the agent the SKV notification surface (#1887)
* feat(skatteverket): repair notification recipients + make the agent the SKV notification surface

The company_members -> profiles!inner(email) PostgREST embed has no FK to
traverse (company_members.user_id references auth.users), so it 400'd and
silently killed all four notification emails since they shipped. Recipient
lookup is now a shared two-step helper (lib/notifications/member-email):
kvittens confirmations, skattekonto drift alerts (tax-contact routing
preserved via the plural variant) and backup alerts deliver again. The
connection-expired email is deleted instead of fixed: with SKV's 65-minute
personal sessions it was one mail per connect (see DECISIONS.md); the event
and needs_reconsent flagging stay.

For MCP-first users the agent is the notification surface, so:
- SKATTEVERKET_NOT_CONNECTED copy is now agent-directive: session expiry is
  normal (~1h by SKV design), only a person can reconnect with BankID, do
  not retry until they confirm. Inline strings (declaration-status, read
  routes, v1 pitfalls, accounted-api skill) aligned.
- gnubok_get_agent_briefing gains an optional skatteverket_connection block
  (status/source/connected_at + directive message on needs_reconsent),
  emitted only when a connection or verified system grant exists, so agents
  warn the user at session start instead of failing mid-task. Payload bench
  ceiling bumped 59.95K -> 60.15K for the outputSchema contract.

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

* fix(skatteverket): drift email resolves recipients via service client; review fixes

The skeptic pass refuted the drift-email repair: skattekonto.drift_detected
is emitted only by the nightly cron, and the extension registry builds each
event handler a fresh ctx from the anonymous cookie client (or none at all
on cookieless requests), so RLS returned zero company_members rows and the
two-step lookup still resolved no recipient. The handler now builds its own
service-role client, the same documented pattern as the retired
connection-expired handler; drift tests exercise the handler without ctx,
matching the cron reality.

CodeRabbit findings: resolveMemberEmails pages both queries through
fetchAllRows with stable ordering (PostgREST caps unpaged reads at 1000
rows); the v1 vat-declarations pitfall and regenerated accounted-api docs
now name both auth paths (member BankID connection or verified ombud
grant); the briefing's system-before-user priority carries a cross-reference
to resolveReadAuth explaining why it is not reused. member-email.ts JSDoc
states the service-role-client requirement (profiles RLS is own-row-only).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:09:20 +02:00

298 lines
12 KiB
TypeScript

/**
* resolveReadAuth preference matrix: system credentials when the flag is on
* and the grant is verified; the company's user token otherwise; explicit
* no_token / needs_reconsent outcomes for the crons' quiet buckets.
*
* Token rows are per (user, company): the caller's own row wins when it
* exists, any other member's active row serves otherwise (#1673), and two
* connected members must never degrade to "nobody connected".
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGetConnection = vi.fn()
vi.mock('../lib/connection-store', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, getConnection: (...a: unknown[]) => mockGetConnection(...a) }
})
const mockMode = vi.fn()
const mockConfigured = vi.fn()
vi.mock('../lib/system-auth/config', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
getSystemAuthMode: () => mockMode(),
isSystemAuthConfigured: () => mockConfigured(),
}
})
import { resolveReadAuth, hasVerifiedGrant, findCompanyTokenUser } from '../lib/resolve-auth'
import type { SupabaseClient } from '@supabase/supabase-js'
type TokenRow = { user_id: string; status: string; created_at?: string }
/**
* Chain stub for the company token lookup: `.select().eq().order()` resolves
* to the given rows, already in created_at DESC order (the DB does the
* ordering; the resolver only picks). Pass a single row for the one-member
* case, an array for several members, null for no rows.
*/
function makeSupabase(
tokenRows: TokenRow | TokenRow[] | null,
opts: { error?: { message: string } } = {},
) {
const rows = tokenRows === null ? [] : Array.isArray(tokenRows) ? tokenRows : [tokenRows]
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq']) {
chain[m] = vi.fn(() => chain)
}
chain.order = vi.fn().mockResolvedValue(
opts.error ? { data: null, error: opts.error } : { data: rows, error: null },
)
// Regression guard for #1673: the company lookup must never go through
// maybeSingle(), which errors as soon as two members have connected.
chain.maybeSingle = vi.fn(() => {
throw new Error('maybeSingle() must not be used for the company token lookup')
})
return { from: vi.fn(() => chain), _chain: chain } as unknown as SupabaseClient & {
_chain: Record<string, ReturnType<typeof vi.fn>>
}
}
const GRANTED_CONNECTION = {
status: 'verified',
lasombud_status: 'granted',
moms_ombud_status: 'granted',
}
beforeEach(() => {
vi.clearAllMocks()
mockMode.mockReturnValue('off')
mockConfigured.mockReturnValue(false)
})
describe('resolveReadAuth', () => {
it('mode off -> user token by company lookup (pre-hybrid behavior)', async () => {
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user', tokenUserId: 'user-1' })
expect(mockGetConnection).not.toHaveBeenCalled()
})
it('mode on + verified grant -> system auth', async () => {
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(true)
mockGetConnection.mockResolvedValue(GRANTED_CONNECTION)
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'system', tokenUserId: 'user-1' })
if (result.ok) expect(result.auth).toEqual({ mode: 'system' })
})
it('mode on but grant denied -> falls back to user token', async () => {
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(true)
mockGetConnection.mockResolvedValue({
...GRANTED_CONNECTION,
lasombud_status: 'denied',
status: 'partial',
})
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user' })
})
it('mode on but unconfigured -> never consults the connection table', async () => {
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(false)
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user' })
expect(mockGetConnection).not.toHaveBeenCalled()
})
it('shadow mode never selects system auth', async () => {
mockMode.mockReturnValue('shadow')
mockConfigured.mockReturnValue(true)
mockGetConnection.mockResolvedValue(GRANTED_CONNECTION)
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user' })
})
it('explicit userId with no company row at all -> no_token (no longer short-circuits to the caller)', async () => {
const supabase = makeSupabase(null)
const result = await resolveReadAuth(supabase, 'company-1', {
requires: 'moms_ombud',
userId: 'user-9',
})
expect(result).toEqual({ ok: false, reason: 'no_token' })
})
it('orders the company lookup by created_at desc and filters by company', async () => {
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(supabase._chain.eq).toHaveBeenCalledWith('company_id', 'company-1')
expect(supabase._chain.order).toHaveBeenCalledWith('created_at', { ascending: false })
})
it('lookup error -> no_token (never throws into the caller)', async () => {
const supabase = makeSupabase(null, { error: { message: 'boom' } })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toEqual({ ok: false, reason: 'no_token' })
})
it('no token row -> no_token', async () => {
const supabase = makeSupabase(null)
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toEqual({ ok: false, reason: 'no_token' })
})
it('needs_reconsent row -> needs_reconsent', async () => {
const supabase = makeSupabase({ user_id: 'user-1', status: 'needs_reconsent' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toEqual({ ok: false, reason: 'needs_reconsent' })
})
})
describe('resolveReadAuth: two members of the same company (#1673)', () => {
const A = 'user-a'
const B = 'user-b'
const NEWER = '2026-08-18T10:00:00Z'
const OLDER = '2026-08-18T09:00:00Z'
it('one connects, both read: the member who never connected resolves the connected token', async () => {
const supabase = makeSupabase({ user_id: A, status: 'active', created_at: NEWER })
const asA = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud', userId: A })
expect(asA).toMatchObject({ ok: true, source: 'user', tokenUserId: A })
const asB = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud', userId: B })
expect(asB).toMatchObject({ ok: true, source: 'user', tokenUserId: A })
// The auth handed to skvRequest carries the token OWNER: the refresh
// writes back to A's row, never to B's (missing) one.
if (asB.ok) expect(asB.auth).toEqual({ mode: 'user', supabase, userId: A, companyId: 'company-1' })
})
it('both connect, both read: each member gets their own token, nobody degrades to no_token', async () => {
const supabase = makeSupabase([
{ user_id: B, status: 'active', created_at: NEWER },
{ user_id: A, status: 'active', created_at: OLDER },
])
const asA = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud', userId: A })
expect(asA).toMatchObject({ ok: true, tokenUserId: A })
const asB = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud', userId: B })
expect(asB).toMatchObject({ ok: true, tokenUserId: B })
})
it('both connect, background read (no caller): the most recently issued row wins', async () => {
const supabase = makeSupabase([
{ user_id: B, status: 'active', created_at: NEWER },
{ user_id: A, status: 'active', created_at: OLDER },
])
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, tokenUserId: B })
})
it('a dead own row does not shadow another member\'s live token', async () => {
const supabase = makeSupabase([
{ user_id: B, status: 'needs_reconsent', created_at: NEWER },
{ user_id: A, status: 'active', created_at: OLDER },
])
const asB = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud', userId: B })
expect(asB).toMatchObject({ ok: true, tokenUserId: A })
})
it('every row flagged needs_reconsent -> needs_reconsent (own row reported when present)', async () => {
const supabase = makeSupabase([
{ user_id: A, status: 'needs_reconsent', created_at: NEWER },
{ user_id: B, status: 'needs_reconsent', created_at: OLDER },
])
expect(await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud', userId: B })).toEqual({
ok: false,
reason: 'needs_reconsent',
})
expect(await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })).toEqual({
ok: false,
reason: 'needs_reconsent',
})
})
it('system mode keeps the caller as notification recipient, falls back to the token owner', async () => {
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(true)
mockGetConnection.mockResolvedValue(GRANTED_CONNECTION)
const supabase = makeSupabase({ user_id: A, status: 'active', created_at: NEWER })
expect(
await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud', userId: B }),
).toMatchObject({ ok: true, source: 'system', tokenUserId: B })
expect(await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })).toMatchObject({
ok: true,
source: 'system',
tokenUserId: A,
})
})
})
describe('findCompanyTokenUser', () => {
it('returns null for a company with no rows', async () => {
expect(await findCompanyTokenUser(makeSupabase(null), 'company-1')).toBeNull()
})
it('prefers the given user, then active status, then recency', async () => {
const supabase = makeSupabase([
{ user_id: 'user-c', status: 'needs_reconsent', created_at: '3' },
{ user_id: 'user-b', status: 'active', created_at: '2' },
{ user_id: 'user-a', status: 'active', created_at: '1' },
])
expect(await findCompanyTokenUser(supabase, 'company-1')).toEqual({
userId: 'user-b',
needsReconsent: false,
createdAt: '2',
})
expect(await findCompanyTokenUser(supabase, 'company-1', { preferUserId: 'user-a' })).toEqual({
userId: 'user-a',
needsReconsent: false,
createdAt: '1',
})
// Preferring a user whose row is dead still yields the live row.
expect(await findCompanyTokenUser(supabase, 'company-1', { preferUserId: 'user-c' })).toEqual({
userId: 'user-b',
needsReconsent: false,
createdAt: '2',
})
})
})
describe('hasVerifiedGrant', () => {
it('requires both the grant and a verified/partial aggregate status', async () => {
mockGetConnection.mockResolvedValue({
status: 'revoked',
lasombud_status: 'granted',
moms_ombud_status: 'unknown',
})
expect(await hasVerifiedGrant('company-1', 'lasombud')).toBe(false)
mockGetConnection.mockResolvedValue({
status: 'partial',
lasombud_status: 'granted',
moms_ombud_status: 'denied',
})
expect(await hasVerifiedGrant('company-1', 'lasombud')).toBe(true)
expect(await hasVerifiedGrant('company-1', 'moms_ombud')).toBe(false)
})
it('returns false when no connection row exists', async () => {
mockGetConnection.mockResolvedValue(null)
expect(await hasVerifiedGrant('company-1', 'lasombud')).toBe(false)
})
})