* 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>
298 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|