Files
accounted/extensions/general/skatteverket/__tests__/resolve-auth.test.ts
T
1af5846adf fix(skatteverket): let company members read data connected by another member (#1691)
Closes #1673. Token rows are per (user, company), but the read resolver
short-circuited to the caller's own row whenever a userId was passed, so a
member who never pressed "Anslut" resolved to "no token" for a company that
was connected. The company-wide fallback used .maybeSingle(), which errors
as soon as two members have both connected and turned that into "nobody
connected" for everyone.

- resolve-auth: findCompanyTokenUser() reads all of the company's rows
  ordered by created_at desc and picks the caller's own active row first,
  then any other member's active row, then needs_reconsent rows; the userId
  branch no longer short-circuits. The auth carries the token OWNER's userId
  so refresh writes back to the owner's row.
- /skattekonto/saldo and /skattekonto/sync resolve the company token instead
  of getTokens(caller); /declaration/submitted and /decided answer a
  SESSION_EXPIRED reconnect prompt for needs_reconsent instead of
  NOT_CONNECTED. Connect/disconnect//status stay on the caller's own row.
- The connection.expired event names the token owner, not the caller who
  triggered the sync; the notification lookup filters by company too.
- The two kvittens crons flag needs_reconsent through the same shared pick
  instead of .maybeSingle().

Tests: two members, one connects, both read; both connect, both read; no
row -> NOT_CONNECTED; dead-only rows -> reconnect prompt; sync auth carries
the owner; event recipient is the owner.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:40:10 +02:00

295 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,
})
expect(await findCompanyTokenUser(supabase, 'company-1', { preferUserId: 'user-a' })).toEqual({
userId: 'user-a',
needsReconsent: false,
})
// 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,
})
})
})
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)
})
})