* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS Password reset, invite, email change and signup links now resolve the request host against brands.domain server-side. The env var was a second copy of that registry compiled into the browser; every new brand needed the row, the env var, the GoTrue allowlist and a redeploy, and two partners shipped with the env var stale, so their reset mails went out canonical-branded to the canonical host. - New POST /api/auth/password-reset: the login page no longer calls GoTrue directly, so the browser carries no domain list. - lib/domains/trusted-app-origin.ts is async and registry-backed; it also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so previews keep sending links to themselves. - Signup shares the same resolver instead of following the raw host. - Docs and .env.example describe the single registry; GoTrue keeps the redirect allowlist as backstop (hosted: *.accounted.se wildcard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): await the async origin resolver in the billing routes merged from main PR #2370 added resolveRequestAppOrigin callers in billing/checkout and billing/portal after this branch made the resolver async. Await them and move their tests from the removed env var to the brands mock; update the login source-assert test to the server-routed reset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs Skeptic and CI findings on #2376, one pass: - A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR, 503, retryable) instead of falling back to the canonical origin: a canonical link is the wrong-brand mail this PR removes. Password reset and email change answer 503 themselves; withRouteContext routes map the code. - A local canonical (dev) trusts other local hosts and ports on the same scheme, so lane servers on 3001-3003 confirm signups on themselves. - GoTrue matches the full redirect_to including the query and `*` stops at `.` and `/`: docs and decision line now prescribe https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**. - The Turnstile contract test asserts the server-routed reset forwards the captcha token (it still asserted the removed browser call). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
500 lines
18 KiB
TypeScript
500 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Entitlement gate passes in these tests; the gate itself is covered by
|
|
// capability-gate.test.ts.
|
|
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
|
return { ...actual, requireCapability: vi.fn() }
|
|
})
|
|
|
|
const { mockStartAuthorization, mockGetPreferredAuthMethod } = vi.hoisted(() => ({
|
|
mockStartAuthorization: vi.fn(),
|
|
mockGetPreferredAuthMethod: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../lib/api-client', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../lib/api-client')>()
|
|
return {
|
|
...actual,
|
|
startAuthorization: (...args: unknown[]) => mockStartAuthorization(...args),
|
|
// index.ts resolves the pinned auth method (with metadata for logging)
|
|
// through the details variant; both point at one mock for simplicity.
|
|
getPreferredAuthMethod: (...args: unknown[]) => mockGetPreferredAuthMethod(...args),
|
|
getPreferredAuthMethodDetails: (...args: unknown[]) => mockGetPreferredAuthMethod(...args),
|
|
}
|
|
})
|
|
|
|
// The connect handler records the initiating origin through the brands-table
|
|
// resolver; no brand host is registered in these tests.
|
|
vi.mock('@/lib/branding/resolve', () => ({
|
|
resolveBrandResultByHost: vi.fn(async () => ({ brand: null, lookupFailed: false })),
|
|
}))
|
|
|
|
import { enableBankingExtension } from '../index'
|
|
import { requireCapability } from '@/lib/entitlements/has-capability'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
interface RecordedCall {
|
|
method: string
|
|
args: unknown[]
|
|
}
|
|
|
|
interface RecordedChain {
|
|
_calls: RecordedCall[]
|
|
[key: string]: unknown
|
|
}
|
|
|
|
function makeChain(result: { data?: unknown; error?: unknown }): RecordedChain {
|
|
const calls: RecordedCall[] = []
|
|
const chain: Record<string, unknown> = { _calls: calls }
|
|
for (const m of ['select', 'eq', 'neq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) {
|
|
chain[m] = vi.fn((...args: unknown[]) => {
|
|
calls.push({ method: m, args })
|
|
return chain
|
|
})
|
|
}
|
|
chain.maybeSingle = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
|
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
|
chain.then = (resolve: (v: unknown) => void) => resolve({ data: result.data ?? null, error: result.error ?? null })
|
|
return chain as RecordedChain
|
|
}
|
|
|
|
function makeContext(fromImpl: (table: string) => unknown): ExtensionContext {
|
|
return {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
extensionId: 'enable-banking',
|
|
requestId: 'req_test',
|
|
supabase: {
|
|
auth: {
|
|
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
|
|
},
|
|
from: vi.fn(fromImpl),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any,
|
|
emit: vi.fn().mockResolvedValue(undefined),
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
|
|
settings: {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn().mockResolvedValue(undefined),
|
|
clear: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any
|
|
}
|
|
|
|
function connectRoute() {
|
|
const route = enableBankingExtension.apiRoutes?.find(
|
|
(r) => r.method === 'POST' && r.path === '/connect',
|
|
)
|
|
expect(route, 'POST /connect must be registered').toBeDefined()
|
|
return route!
|
|
}
|
|
|
|
function makeConnectRequest() {
|
|
return new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ aspsp_name: 'Nordea', aspsp_country: 'SE', psu_type: 'business' }),
|
|
})
|
|
}
|
|
|
|
describe('POST /connect never-activated row cleanup', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(requireCapability).mockResolvedValue(null)
|
|
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
|
|
mockStartAuthorization.mockResolvedValue({
|
|
url: 'https://bank.example/auth',
|
|
authorization_id: 'auth-1',
|
|
})
|
|
})
|
|
|
|
it('deletes stale pending and error zombies instead of parking them in error', async () => {
|
|
const chains: RecordedChain[] = []
|
|
let call = 0
|
|
const ctx = makeContext(() => {
|
|
call++
|
|
let chain: RecordedChain
|
|
if (call === 1) {
|
|
// Latest pending row is stale (45s > 30s threshold): not a live attempt.
|
|
chain = makeChain({
|
|
data: { id: 'stale-1', created_at: new Date(Date.now() - 45_000).toISOString() },
|
|
})
|
|
} else if (call === 2) {
|
|
// The sweep: returns the deleted never-activated rows.
|
|
chain = makeChain({ data: [{ id: 'stale-1' }, { id: 'old-error' }] })
|
|
} else if (call === 3) {
|
|
// Existing-connection guard: nothing established remains post-sweep.
|
|
chain = makeChain({ data: null })
|
|
} else {
|
|
// Insert of the fresh connection row.
|
|
chain = makeChain({ data: { id: 'new-conn' } })
|
|
}
|
|
chains.push(chain)
|
|
return chain
|
|
})
|
|
|
|
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
|
|
|
expect(response.status).toBe(200)
|
|
const body = (await response.json()) as { connection_id: string; authorization_url: string }
|
|
expect(body.connection_id).toBe('new-conn')
|
|
expect(body.authorization_url).toBe('https://bank.example/auth')
|
|
|
|
// The sweep DELETEs never-activated rows: stale pendings and error rows
|
|
// from failed attempts, guarded so established connections (session_id or
|
|
// accounts_data present) are untouched.
|
|
const sweep = chains[1]
|
|
const methods = sweep._calls.map((c) => c.method)
|
|
expect(methods).toContain('delete')
|
|
const inCall = sweep._calls.find((c) => c.method === 'in')
|
|
expect(inCall?.args).toEqual(['status', ['pending', 'error']])
|
|
const isCalls = sweep._calls.filter((c) => c.method === 'is')
|
|
expect(isCalls.map((c) => c.args)).toEqual(
|
|
expect.arrayContaining([
|
|
['session_id', null],
|
|
['accounts_data', null],
|
|
]),
|
|
)
|
|
|
|
// Nothing gets parked as status='error' anymore: no update on any chain.
|
|
for (const chain of chains) {
|
|
expect(chain._calls.some((c) => c.method === 'update')).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('still rejects a duplicate connect while a recent pending attempt is live', async () => {
|
|
const chains: RecordedChain[] = []
|
|
const ctx = makeContext(() => {
|
|
// Latest pending row is 5s old: the user is mid-redirect at the bank.
|
|
const chain = makeChain({
|
|
data: { id: 'live-1', created_at: new Date(Date.now() - 5_000).toISOString() },
|
|
})
|
|
chains.push(chain)
|
|
return chain
|
|
})
|
|
|
|
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
|
|
|
expect(response.status).toBe(409)
|
|
// No sweep while an attempt is live: the live pending row must survive.
|
|
for (const chain of chains) {
|
|
expect(chain._calls.some((c) => c.method === 'delete')).toBe(false)
|
|
}
|
|
expect(mockStartAuthorization).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sweeps error zombies even when no pending row exists', async () => {
|
|
const chains: RecordedChain[] = []
|
|
let call = 0
|
|
const ctx = makeContext(() => {
|
|
call++
|
|
let chain: RecordedChain
|
|
if (call === 1) {
|
|
chain = makeChain({ data: null })
|
|
} else if (call === 2) {
|
|
chain = makeChain({ data: [{ id: 'old-error' }] })
|
|
} else if (call === 3) {
|
|
// Existing-connection guard: the swept zombie is gone, nothing remains.
|
|
chain = makeChain({ data: null })
|
|
} else {
|
|
chain = makeChain({ data: { id: 'new-conn' } })
|
|
}
|
|
chains.push(chain)
|
|
return chain
|
|
})
|
|
|
|
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
|
|
|
expect(response.status).toBe(200)
|
|
const sweep = chains[1]
|
|
expect(sweep._calls.some((c) => c.method === 'delete')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('POST /connect auth-method pinning wired into startAuthorization', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(requireCapability).mockResolvedValue(null)
|
|
mockStartAuthorization.mockResolvedValue({
|
|
url: 'https://bank.example/auth',
|
|
authorization_id: 'auth-1',
|
|
})
|
|
})
|
|
|
|
// Fresh-connect from() sequence: recent-pending check, zombie sweep,
|
|
// existing-connection guard, insert. Explicit psu_type in the body skips
|
|
// the companies entity_type lookup.
|
|
function makeFreshConnectContext() {
|
|
let call = 0
|
|
return makeContext(() => {
|
|
call++
|
|
if (call === 1) return makeChain({ data: null })
|
|
if (call === 2) return makeChain({ data: [] })
|
|
if (call === 3) return makeChain({ data: null })
|
|
return makeChain({ data: { id: 'new-conn' } })
|
|
})
|
|
}
|
|
|
|
function makeHandelsbankenRequest() {
|
|
return new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ aspsp_name: 'Handelsbanken', aspsp_country: 'SE', psu_type: 'business' }),
|
|
})
|
|
}
|
|
|
|
it('forwards the pinned method name into the auth_method argument (Handelsbanken corporate regression)', async () => {
|
|
// The documented real Handelsbanken shape: Mobile BankID is a hidden
|
|
// DECOUPLED method carrying NO psu_types restriction.
|
|
mockGetPreferredAuthMethod.mockResolvedValue({
|
|
name: 'BANKID',
|
|
approach: 'DECOUPLED',
|
|
hidden_method: true,
|
|
title: 'Bank ID',
|
|
})
|
|
|
|
const ctx = makeFreshConnectContext()
|
|
const response = await connectRoute().handler(makeHandelsbankenRequest(), ctx)
|
|
expect(response.status).toBe(200)
|
|
|
|
expect(mockGetPreferredAuthMethod).toHaveBeenCalledWith('Handelsbanken', 'SE', 'business')
|
|
|
|
// startAuthorization(aspspName, aspspCountry, redirectUrl, state, psuType,
|
|
// authMethod): the pinned method's NAME must land in the auth_method
|
|
// position (index 5). This binds selection to the outgoing request: the
|
|
// selection tests alone cannot catch a route that resolves 'BANKID' and
|
|
// then starts a default REDIRECT authorization anyway.
|
|
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
|
|
const args = mockStartAuthorization.mock.calls[0]
|
|
expect(args[0]).toBe('Handelsbanken')
|
|
expect(args[1]).toBe('SE')
|
|
expect(args[4]).toBe('business')
|
|
expect(args[5]).toBe('BANKID')
|
|
|
|
// A pinned method without psu_types applies to all PSU types: the log must
|
|
// say '(all)', never '(aspsp default)', which would contradict
|
|
// auth_method='BANKID' on the same line.
|
|
expect(ctx.log.info).toHaveBeenCalledWith(
|
|
'[enable-banking] Starting bank connection',
|
|
expect.objectContaining({
|
|
auth_method: 'BANKID',
|
|
auth_method_psu_types: '(all)',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('passes undefined auth_method when no method is pinned (ASPSP default flow)', async () => {
|
|
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
|
|
|
|
const ctx = makeFreshConnectContext()
|
|
const response = await connectRoute().handler(makeHandelsbankenRequest(), ctx)
|
|
expect(response.status).toBe(200)
|
|
|
|
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
|
|
const args = mockStartAuthorization.mock.calls[0]
|
|
expect(args[5]).toBeUndefined()
|
|
|
|
// Only the unpinned case logs the '(aspsp default)' sentinel.
|
|
expect(ctx.log.info).toHaveBeenCalledWith(
|
|
'[enable-banking] Starting bank connection',
|
|
expect.objectContaining({
|
|
auth_method: '(aspsp default)',
|
|
auth_method_psu_types: '(aspsp default)',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('forwards the pinned method name on the reconnect path too', async () => {
|
|
mockGetPreferredAuthMethod.mockResolvedValue({
|
|
name: 'BANKID',
|
|
approach: 'DECOUPLED',
|
|
hidden_method: true,
|
|
title: 'Bank ID',
|
|
})
|
|
|
|
let call = 0
|
|
const ctx = makeContext(() => {
|
|
call++
|
|
if (call === 1) {
|
|
// The existing connection loaded up front: reconnect derives the bank
|
|
// identity and psu_type from this row. session_id null skips the
|
|
// sibling check + revoke.
|
|
return makeChain({
|
|
data: {
|
|
id: 'conn-1',
|
|
bank_name: 'Handelsbanken',
|
|
provider: 'handelsbanken-se',
|
|
session_id: null,
|
|
psu_type: 'business',
|
|
},
|
|
})
|
|
}
|
|
// CSRF-state staging update and the authorization_id follow-up write.
|
|
return makeChain({ data: null })
|
|
})
|
|
|
|
const req = new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ connection_id: 'conn-1' }),
|
|
})
|
|
|
|
const response = await connectRoute().handler(req, ctx)
|
|
expect(response.status).toBe(200)
|
|
|
|
// The reconnect branch calls startAuthorization from its own call site:
|
|
// the pinned name must reach the auth_method position there as well.
|
|
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
|
|
const args = mockStartAuthorization.mock.calls[0]
|
|
expect(args[0]).toBe('Handelsbanken')
|
|
expect(args[1]).toBe('SE')
|
|
expect(args[4]).toBe('business')
|
|
expect(args[5]).toBe('BANKID')
|
|
})
|
|
})
|
|
|
|
describe('POST /connect existing-connection guard', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(requireCapability).mockResolvedValue(null)
|
|
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
|
|
mockStartAuthorization.mockResolvedValue({
|
|
url: 'https://bank.example/auth',
|
|
authorization_id: 'auth-1',
|
|
})
|
|
})
|
|
|
|
it('returns 409 EXISTING_CONNECTION when a dead (expired) row for the same bank exists', async () => {
|
|
const chains: RecordedChain[] = []
|
|
let call = 0
|
|
const ctx = makeContext(() => {
|
|
call++
|
|
let chain: RecordedChain
|
|
if (call === 1) {
|
|
// No live pending attempt.
|
|
chain = makeChain({ data: null })
|
|
} else if (call === 2) {
|
|
// Sweep finds nothing to delete.
|
|
chain = makeChain({ data: [] })
|
|
} else {
|
|
// Guard: an established expired row for this bank survives the sweep.
|
|
chain = makeChain({ data: { id: 'existing-1', status: 'expired' } })
|
|
}
|
|
chains.push(chain)
|
|
return chain
|
|
})
|
|
|
|
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
|
|
|
expect(response.status).toBe(409)
|
|
const body = (await response.json()) as {
|
|
code: string
|
|
existing_connection_id: string
|
|
error: string
|
|
}
|
|
expect(body.code).toBe('EXISTING_CONNECTION')
|
|
expect(body.existing_connection_id).toBe('existing-1')
|
|
// The message names the bank and points at Förnya samtycke.
|
|
expect(body.error).toContain('Nordea')
|
|
expect(body.error).toContain('behöver förnyas')
|
|
expect(body.error).toContain('Förnya samtycke')
|
|
// The bank flow is never started and no duplicate row is inserted.
|
|
expect(mockStartAuthorization).not.toHaveBeenCalled()
|
|
for (const chain of chains) {
|
|
expect(chain._calls.some((c) => c.method === 'insert')).toBe(false)
|
|
}
|
|
// The guard only matches DEAD-BUT-ESTABLISHED rows: an active row (a
|
|
// legitimate second login at the same bank) and revoked rows never 409.
|
|
const guard = chains[2]
|
|
const inCall = guard._calls.find((c) => c.method === 'in')
|
|
expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']])
|
|
})
|
|
|
|
it('never 409s over an ACTIVE same-bank connection: the guard status filter excludes it', async () => {
|
|
const chains: RecordedChain[] = []
|
|
let call = 0
|
|
const ctx = makeContext(() => {
|
|
call++
|
|
let chain: RecordedChain
|
|
if (call === 1) {
|
|
chain = makeChain({ data: null })
|
|
} else if (call === 2) {
|
|
chain = makeChain({ data: [] })
|
|
} else if (call === 3) {
|
|
// Guard: only an ACTIVE row exists for this bank; the dead-status
|
|
// filter matches nothing, so the query returns null.
|
|
chain = makeChain({ data: null })
|
|
} else {
|
|
chain = makeChain({ data: { id: 'second-login' } })
|
|
}
|
|
chains.push(chain)
|
|
return chain
|
|
})
|
|
|
|
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
|
|
|
// The second legitimate login at the same bank goes through.
|
|
expect(response.status).toBe(200)
|
|
const body = (await response.json()) as { connection_id: string }
|
|
expect(body.connection_id).toBe('second-login')
|
|
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
|
|
|
|
// The guard queried with the dead-status filter (so an active row can
|
|
// never be returned) rather than neq('status', 'revoked').
|
|
const guard = chains[2]
|
|
const inCall = guard._calls.find((c) => c.method === 'in')
|
|
expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']])
|
|
expect(guard._calls.some((c) => c.method === 'neq')).toBe(false)
|
|
})
|
|
|
|
it('force_new: true bypasses the guard and inserts a fresh row', async () => {
|
|
const chains: RecordedChain[] = []
|
|
let call = 0
|
|
const ctx = makeContext(() => {
|
|
call++
|
|
let chain: RecordedChain
|
|
if (call === 1) {
|
|
chain = makeChain({ data: null })
|
|
} else if (call === 2) {
|
|
chain = makeChain({ data: [] })
|
|
} else {
|
|
// With force_new the guard query is skipped entirely: call 3 is the
|
|
// insert of the fresh connection row.
|
|
chain = makeChain({ data: { id: 'new-conn' } })
|
|
}
|
|
chains.push(chain)
|
|
return chain
|
|
})
|
|
|
|
const req = new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
aspsp_name: 'Nordea',
|
|
aspsp_country: 'SE',
|
|
psu_type: 'business',
|
|
force_new: true,
|
|
}),
|
|
})
|
|
|
|
const response = await connectRoute().handler(req, ctx)
|
|
|
|
expect(response.status).toBe(200)
|
|
const body = (await response.json()) as { connection_id: string }
|
|
expect(body.connection_id).toBe('new-conn')
|
|
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
|
|
// No guard query ran: nothing filtered on the guard's dead-status set
|
|
// (the sweep's in() uses ['pending', 'error'] and is expected).
|
|
for (const chain of chains) {
|
|
const guardIn = chain._calls.find(
|
|
(c) =>
|
|
c.method === 'in' &&
|
|
JSON.stringify(c.args) === JSON.stringify(['status', ['expired', 'error', 'pending_selection']]),
|
|
)
|
|
expect(guardIn).toBeUndefined()
|
|
}
|
|
})
|
|
})
|