Files
accounted/extensions/general/arcim-migration/__tests__/provider-client-oauth-state.test.ts
T
a769bc9d03 feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)
* feat(migration): Björn Lundén activation through Lundify's redirect flow

BL issued our integration activation key on 2026-09-07. With
BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i
Lundify": the customer logs in at Lundify, picks the company and accepts
the scopes, and Lundify returns the company's User-Key to our callback as
publicKey with our one-time state echoed as extra. The manual User-Key
field stays as a folded fallback for companies that activated inside
Lundify already.

The callback folds publicKey/extra into the OAuth-shaped locals, so the
atomic state consumption, initiator binding and white-label handoff run
unchanged; only the final step differs: submitProviderToken (the same
client-credentials probe as the manual field) instead of an OAuth code
exchange, owned by the consent's company read from the server-written row.
consumeOAuthState/consumeHandoff now return that company id.

Closes #2323.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L

* fix(migration): reset the previous connect attempt before a new provider request

Review follow-up: a failed /connect used to leave the earlier consent id
and one-time activation URL in place, so the step kept offering a link
that completed the previous consent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:38:07 +02:00

365 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { encryptHandoffValue } from '../lib/handoff-crypto'
/**
* Unit-level guard on the two tenant boundaries in provider-client:
*
* 1. consumeOAuthState() must consume the OAuth state row with a SINGLE
* conditional UPDATE (`used_at IS NULL` + `expires_at > now` in the WHERE
* clause). A read-then-write would leave a window where two concurrent
* callbacks both see an unused row and both bind tokens.
* 2. getConsent() must filter on company_id. It runs on the service client,
* which bypasses RLS, so that predicate is the only thing stopping an
* authenticated user from reading another tenant's consent status.
*/
type QueryResult = { data?: unknown; error?: unknown }
type RecordedCall = { table: string; ops: [string, unknown[]][] }
/**
* Chainable Supabase stub that RECORDS the predicates it was given. The shared
* helper in tests/helpers.ts intentionally discards arguments; here the
* arguments are the thing under test.
*/
function createRecordingSupabase(results: QueryResult[]) {
const calls: RecordedCall[] = []
let index = 0
const from = (table: string) => {
const result = results[index++] ?? {}
const record: RecordedCall = { table, ops: [] }
calls.push(record)
const chain: unknown = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
}
return (...args: unknown[]) => {
record.ops.push([String(prop), args])
return chain
}
},
},
)
return chain
}
return { supabase: { from: vi.fn(from) }, calls }
}
const findOp = (call: RecordedCall, name: string, firstArg?: unknown) =>
call.ops.find(([op, args]) => op === name && (firstArg === undefined || args[0] === firstArg))
let serviceClient: { from: ReturnType<typeof vi.fn> }
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceClient,
createServiceClientNoCookies: () => serviceClient,
createClient: vi.fn(),
}))
import {
consumeOAuthState,
mintHandoff,
consumeHandoff,
generateOtc,
getConsent,
ConsentNotFoundError,
} from '../lib/provider-client'
function useResults(results: QueryResult[]) {
const recording = createRecordingSupabase(results)
serviceClient = recording.supabase
return recording
}
describe('consumeOAuthState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('consumes the state row with one conditional UPDATE, not a read then a write', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1', user_id: 'user-1' } },
{ data: { provider: 'fortnox', company_id: 'company-1' } },
])
await consumeOAuthState('state-token')
const otcCall = calls[0]
expect(otcCall.table).toBe('provider_otc')
// The very first thing done to the row is the UPDATE: there is no
// select-then-decide window for a concurrent replay to slip through.
expect(otcCall.ops[0][0]).toBe('update')
expect(findOp(otcCall, 'eq', 'code')?.[1]).toEqual(['code', 'state-token'])
// Single-use: the row is only claimed while it is still unclaimed.
expect(findOp(otcCall, 'is', 'used_at')?.[1]).toEqual(['used_at', null])
// Expiry is enforced in the same statement, not in JavaScript afterwards.
expect(findOp(otcCall, 'gt', 'expires_at')).toBeDefined()
expect(findOp(otcCall, 'select', 'consent_id, user_id, origin')).toBeDefined()
expect(findOp(otcCall, 'is', 'provider_code')?.[1]).toEqual(['provider_code', null])
expect(findOp(otcCall, 'is', 'provider_error')?.[1]).toEqual(['provider_error', null])
})
it('returns the consent and the provider read from the server-side rows', async () => {
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'visma', company_id: 'company-1' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'visma',
companyId: 'company-1',
userId: 'user-1',
origin: null,
})
})
it('reads the provider from provider_consents, never from the caller', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1', user_id: 'user-1' } },
{ data: { provider: 'fortnox', company_id: 'company-1' } },
])
await consumeOAuthState('state-token')
expect(calls[1].table).toBe('provider_consents')
expect(findOp(calls[1], 'eq', 'id')?.[1]).toEqual(['id', 'consent-1'])
})
it('returns null for an unknown or forged state token', async () => {
// No row matched the code: PostgREST answers with no representation.
useResults([{ data: null }])
await expect(consumeOAuthState('forged-token')).resolves.toBeNull()
})
it('returns null for an expired state token', async () => {
// The `expires_at > now` predicate excluded the row, so 0 rows updated.
useResults([{ data: null }])
await expect(consumeOAuthState('expired-token')).resolves.toBeNull()
})
it('returns null on replay: the second consume of the same token loses', async () => {
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'fortnox', company_id: 'company-1' } }])
await expect(consumeOAuthState('one-time-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
companyId: 'company-1',
userId: 'user-1',
origin: null,
})
// Replay: used_at is now set, so `is('used_at', null)` matches nothing.
useResults([{ data: null }])
await expect(consumeOAuthState('one-time-token')).resolves.toBeNull()
})
it('returns userId null for a row minted before the initiator column existed', async () => {
// The callback refuses these (nobody to bind the completion to); this
// function only has to report the absence honestly, never invent a user.
useResults([{ data: { consent_id: 'consent-1', user_id: null } }, { data: { provider: 'fortnox', company_id: 'company-1' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
companyId: 'company-1',
userId: null,
origin: null,
})
})
it('returns null when the consent behind a valid token is gone', async () => {
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: null }])
await expect(consumeOAuthState('state-token')).resolves.toBeNull()
})
it('returns null when the update itself errors', async () => {
useResults([{ data: null, error: { message: 'boom' } }])
await expect(consumeOAuthState('state-token')).resolves.toBeNull()
})
})
describe('generateOtc', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('defaults the OAuth state lifetime to 10 minutes', async () => {
// The state only has to survive the round trip to the provider's login
// page. The old 60-minute default left a phished or leaked state
// consumable for an hour.
const { calls } = useResults([{ data: null }])
const before = Date.now()
const { expiresAt } = await generateOtc('consent-1', 'user-1', 'https://solbo.accounted.se')
const after = Date.now()
expect(calls[0].table).toBe('provider_otc')
const inserted = findOp(calls[0], 'insert')?.[1][0] as { consent_id: string; user_id: string; expires_at: string }
expect(inserted.consent_id).toBe('consent-1')
// The initiator travels with the row: the callback binds the completing
// session to it, so it must be written here and nowhere else.
expect(inserted.user_id).toBe('user-1')
expect(inserted).toHaveProperty('origin', 'https://solbo.accounted.se')
const tenMinutes = 10 * 60 * 1000
const expiry = new Date(expiresAt).getTime()
expect(expiry).toBeGreaterThanOrEqual(before + tenMinutes)
expect(expiry).toBeLessThanOrEqual(after + tenMinutes)
expect(new Date(inserted.expires_at).getTime()).toBe(expiry)
})
})
describe('OAuth handoff storage', () => {
const origin = 'https://solbo.accounted.se'
beforeEach(() => {
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-only-handoff-encryption-key')
})
afterEach(() => vi.unstubAllEnvs())
it.each([{ providerCode: 'auth-code' }, { providerError: 'access denied' }])(
'mints a fresh two-minute handoff holding %j', async (result) => {
const { calls } = useResults([{}, {}])
const before = Date.now()
const first = await mintHandoff('consent-1', 'user-1', origin, result)
const second = await mintHandoff('consent-1', 'user-1', origin, result)
expect(first.code).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(first.code).not.toBe(second.code)
expect(Date.parse(first.expiresAt)).toBeGreaterThanOrEqual(before + 120_000)
expect(Date.parse(first.expiresAt)).toBeLessThanOrEqual(Date.now() + 120_000)
const inserted = findOp(calls[0], 'insert')?.[1][0] as Record<string, unknown>
expect(inserted).toEqual({
code: first.code, consent_id: 'consent-1', user_id: 'user-1', origin,
expires_at: first.expiresAt,
provider_code: result.providerCode ? expect.stringMatching(/^v1:/) : null,
provider_error: result.providerError ? expect.stringMatching(/^v1:/) : null,
})
expect(JSON.stringify(inserted)).not.toContain(result.providerCode ?? result.providerError)
useResults([{ data: inserted }, { data: { provider: 'fortnox', company_id: 'company-1' } }])
await expect(consumeHandoff(first.code, origin)).resolves.toMatchObject({
providerCode: result.providerCode ?? null, providerError: result.providerError ?? null,
})
},
)
it('deletes atomically, binding origin, expiry, unused status and handoff purpose', async () => {
const { calls } = useResults([
{ data: {
consent_id: 'consent-1', user_id: 'user-1', origin, provider_error: null,
provider_code: encryptHandoffValue('stored-code', JSON.stringify(['handoff-token', 'consent-1', 'user-1', origin, 'provider_code'])),
} },
{ data: { provider: 'visma', company_id: 'company-1' } },
])
await expect(consumeHandoff('handoff-token', origin)).resolves.toEqual({
consentId: 'consent-1', userId: 'user-1', origin, provider: 'visma', companyId: 'company-1',
providerCode: 'stored-code', providerError: null,
})
expect(calls[0].ops[0][0]).toBe('delete')
expect(findOp(calls[0], 'eq', 'code')?.[1]).toEqual(['code', 'handoff-token'])
expect(findOp(calls[0], 'eq', 'origin')?.[1]).toEqual(['origin', origin])
expect(findOp(calls[0], 'is', 'used_at')?.[1]).toEqual(['used_at', null])
expect(findOp(calls[0], 'gt', 'expires_at')).toBeDefined()
expect(findOp(calls[0], 'or')?.[1]).toEqual(['provider_code.not.is.null,provider_error.not.is.null'])
expect(calls[1].table).toBe('provider_consents')
expect(findOp(calls[1], 'eq', 'id')?.[1]).toEqual(['id', 'consent-1'])
})
it('rejects unmatched or already consumed handoffs without reading a consent', async () => {
const { calls } = useResults([{ data: null }])
await expect(consumeHandoff('spent-token', origin)).resolves.toBeNull()
expect(calls).toHaveLength(1)
})
it('fails closed when the delete errors or the consent no longer exists', async () => {
useResults([{ error: { message: 'unavailable' } }])
await expect(consumeHandoff('token', origin)).resolves.toBeNull()
useResults([{ data: { consent_id: 'deleted-consent' } }, { data: null }])
await expect(consumeHandoff('token', origin)).resolves.toBeNull()
})
it('fails the flow if the handoff cannot be stored', async () => {
useResults([{ error: { message: 'unavailable' } }])
await expect(mintHandoff('consent-1', 'user-1', origin, { providerCode: 'code' }))
.rejects.toThrow('Failed to mint OAuth handoff')
})
it('rejects unencrypted or corrupted handoff credentials after deleting the row', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1', user_id: 'user-1', origin, provider_code: 'plaintext-code', provider_error: null } },
{ data: { provider: 'fortnox', company_id: 'company-1' } },
])
await expect(consumeHandoff('token', origin)).resolves.toBeNull()
expect(calls[0].ops[0][0]).toBe('delete')
})
})
describe('getConsent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('filters on the owning company', async () => {
const { calls } = useResults([
{ data: { id: 'consent-1', name: 'n', provider: 'fortnox', status: 1 } },
])
await getConsent('consent-1', 'company-1')
expect(calls[0].table).toBe('provider_consents')
expect(findOp(calls[0], 'eq', 'id')?.[1]).toEqual(['id', 'consent-1'])
expect(findOp(calls[0], 'eq', 'company_id')?.[1]).toEqual(['company_id', 'company-1'])
})
it('throws ConsentNotFoundError when the consent belongs to another company', async () => {
// The company_id predicate excluded the row: indistinguishable from a
// consent id that does not exist at all, which is the point.
useResults([{ data: null }])
await expect(getConsent('other-tenants-consent', 'company-1')).rejects.toBeInstanceOf(
ConsentNotFoundError,
)
})
it('leaks no consent state in the not-found error', async () => {
useResults([{ data: null }])
const error = await getConsent('other-tenants-consent', 'company-1').catch((e) => e)
expect(error).toBeInstanceOf(ConsentNotFoundError)
expect((error as Error).message).toBe('Consent not found')
expect((error as Error).message).not.toContain('other-tenants-consent')
})
it('returns the consent when the caller owns it', async () => {
useResults([
{
data: {
id: 'consent-1',
name: 'gnubok-migration-user-1',
provider: 'fortnox',
status: 1,
org_number: '5566778899',
company_name: 'Test AB',
},
},
])
await expect(getConsent('consent-1', 'company-1')).resolves.toEqual({
id: 'consent-1',
name: 'gnubok-migration-user-1',
provider: 'fortnox',
status: 1,
orgNumber: '5566778899',
companyName: 'Test AB',
})
})
})