Files
accounted/extensions/general/arcim-migration/__tests__/oauth-callback-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

768 lines
32 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* Locks the tenant boundary on the unauthenticated OAuth callback
* (GET /callback, skipAuth: true).
*
* The callback used to decode `state` as plain base64url JSON and trust
* `consentId` / `provider` straight out of it. Nothing was signed and no
* server-side session row was checked, so anyone who learned a victim's consent
* id (it is handed to the browser in the success redirect and postMessage)
* could run OAuth against their OWN provider account and call the callback with
* `state=base64url({consentId: victim})`. The attacker's provider tokens landed
* on the victim's consent, and the victim's next migration imported the
* attacker's ledger.
*
* The callback now resolves everything from a server-written provider_otc row
* that it consumes atomically. These tests pin that: nothing from the query
* string reaches exchangeAuthToken, and every state failure looks identical
* from outside.
*/
vi.mock('../lib/migration-orchestrator', () => ({
executeMigration: vi.fn().mockResolvedValue({}),
}))
// index.ts imports many helpers from provider-client at module load; stub the
// whole module. The two error classes are real classes because index.ts
// branches on `instanceof`.
vi.mock('../lib/provider-client', () => ({
createConsent: vi.fn(),
getConsent: vi.fn(),
listConsents: vi.fn(),
generateOtc: vi.fn(),
consumeOAuthState: vi.fn(),
mintHandoff: vi.fn(),
consumeHandoff: vi.fn(),
getAuthUrl: vi.fn(),
exchangeAuthToken: vi.fn(),
submitProviderToken: vi.fn(),
acceptConsent: vi.fn(),
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
// The /connect handler unconditionally imports this module (for its
// pending-consent token check); the real one pulls in next/headers.
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/branding/resolve', () => ({ resolveBrandByHost: vi.fn().mockResolvedValue(null) }))
// The callback also binds the completing browser session to the user recorded
// on the state row. That check has its own tests
// (oauth-callback-initiator.test.ts); here it always passes so these tests
// stay about the state token itself.
vi.mock('@/lib/auth/oauth-flow-binding', () => ({
requireFlowInitiator: vi.fn(),
FLOW_INITIATOR_MISMATCH_MESSAGE: 'initiator mismatch',
}))
import { arcimMigrationExtension } from '../index'
import { requireFlowInitiator } from '@/lib/auth/oauth-flow-binding'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import {
consumeOAuthState,
mintHandoff,
consumeHandoff,
exchangeAuthToken,
getConsent,
createConsent,
listConsents,
generateOtc,
getAuthUrl,
ConsentNotFoundError,
} from '../lib/provider-client'
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
const findRoute = (method: string, path: string) =>
(arcimMigrationExtension.apiRoutes ?? []).find(
(r) => r.method === method && r.path === path,
)!
const callbackHandler = findRoute('GET', '/callback').handler as RouteHandler
const previewHandler = findRoute('GET', '/preview').handler as RouteHandler
// Every state row below was minted by 'user-1', and 'user-1' is the one
// completing the flow. Set per test, after each describe's clearAllMocks.
beforeEach(() => {
;(requireFlowInitiator as Mock).mockResolvedValue({ ok: true, userId: 'user-1' })
})
const APP_URL = 'https://app.example.test'
/** The exact string the callback shows for every state failure. */
const GENERIC_REJECTION = 'Ingen giltig migrationssession hittades'
function callbackRequest(params: Record<string, string>) {
return createMockRequest(
'http://localhost/api/extensions/ext/arcim-migration/callback',
{ searchParams: params },
)
}
/** The forged payload the old implementation would have trusted. */
function forgedLegacyState(consentId: string, provider: string) {
return Buffer.from(JSON.stringify({ consentId, provider })).toString('base64url')
}
describe('white-label OAuth callback handoff', () => {
const BRAND_ORIGIN = 'https://solbo.accounted.se'
const path = '/api/extensions/ext/arcim-migration/callback'
const state = { consentId: 'consent-1', provider: 'fortnox', companyId: 'company-1', userId: 'user-1', origin: BRAND_ORIGIN } as const
const request = (origin: string, params: Record<string, string>) =>
createMockRequest(`${origin}${path}`, { searchParams: params })
const storedHandoff = { ...state, providerCode: 'stored-code', providerError: null }
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.stubEnv('VISMA_REDIRECT_URI', '')
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(consumeOAuthState).mockResolvedValue(state)
vi.mocked(mintHandoff).mockResolvedValue({ code: 'fresh-handoff', consentId: 'consent-1', expiresAt: '' })
vi.mocked(consumeHandoff).mockResolvedValue(storedHandoff)
vi.mocked(requireFlowInitiator).mockResolvedValue({ ok: true, userId: 'user-1' })
vi.mocked(resolveBrandByHost).mockResolvedValue(null)
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('completes same-origin connects directly and targets that origin', async () => {
vi.mocked(consumeOAuthState).mockResolvedValue({ ...state, origin: APP_URL })
const response = await callbackHandler(request(APP_URL, { state: 'state', code: 'code' }))
const html = await response.text()
expect(response.status).toBe(200)
expect(mintHandoff).not.toHaveBeenCalled()
expect(exchangeAuthToken).toHaveBeenCalledWith('consent-1', 'fortnox', 'code', `${APP_URL}${path}`)
expect(html).toContain(`}, "${APP_URL}")`)
expect(html).toContain(`${APP_URL}/import?migration=connected`)
})
it('hands off without requiring an app-domain session or exposing the provider code', async () => {
vi.mocked(requireFlowInitiator).mockResolvedValue({ ok: false, reason: 'no_session', response: new Response(null, { status: 401 }) })
const response = await callbackHandler(request(APP_URL, {
state: 'one-time-state', code: 'secret-provider-code', origin: 'https://attacker.test',
}))
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe(`${BRAND_ORIGIN}${path}?handoff=fresh-handoff`)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('referrer-policy')).toBe('no-referrer')
expect(consumeOAuthState).toHaveBeenCalledOnce()
expect(mintHandoff).toHaveBeenCalledWith('consent-1', 'user-1', BRAND_ORIGIN, { providerCode: 'secret-provider-code' })
expect(requireFlowInitiator).not.toHaveBeenCalled()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(await response.text()).not.toContain('secret-provider-code')
})
it.each(['fortnox', 'visma'] as const)('finishes %s on the brand with stored credentials and the unchanged redirect URI', async (provider) => {
const redirectUri = `${APP_URL}${path}?configured=1`
vi.stubEnv(provider === 'fortnox' ? 'FORTNOX_REDIRECT_URI' : 'VISMA_REDIRECT_URI', redirectUri)
vi.mocked(consumeHandoff).mockResolvedValue({ ...storedHandoff, provider })
const req = request(BRAND_ORIGIN, {
handoff: 'fresh-handoff', code: 'injected-code', error: 'injected-error',
state: 'injected-state', provider: 'bokio', consentId: 'other-consent',
})
const response = await callbackHandler(req)
const html = await response.text()
expect(consumeHandoff).toHaveBeenCalledWith('fresh-handoff', BRAND_ORIGIN)
expect(consumeOAuthState).not.toHaveBeenCalled()
expect(mintHandoff).not.toHaveBeenCalled()
expect(requireFlowInitiator).toHaveBeenCalledWith(req, 'user-1', { flow: 'arcim-migration.callback' })
expect(exchangeAuthToken).toHaveBeenCalledWith('consent-1', provider, 'stored-code', redirectUri)
expect(html).toContain(`}, "${BRAND_ORIGIN}")`)
expect(html).toContain(`${BRAND_ORIGIN}/import?migration=connected`)
expect(html).toContain('window.close()')
expect(html).not.toContain('injected')
})
it('hands provider denial off and shows it on the brand without exchanging tokens', async () => {
const first = await callbackHandler(request(APP_URL, { state: 'state', error: 'access_denied' }))
expect(first.status).toBe(302)
const result = vi.mocked(mintHandoff).mock.calls[0][3]
expect(result.providerError).toContain('Du avbröt anslutningen')
expect(requireFlowInitiator).not.toHaveBeenCalled()
vi.mocked(consumeHandoff).mockResolvedValue({ ...storedHandoff, providerCode: null, providerError: result.providerError! })
const second = await callbackHandler(request(BRAND_ORIGIN, { handoff: 'fresh-handoff' }))
const html = await second.text()
expect(requireFlowInitiator).toHaveBeenCalledOnce()
expect(html).toContain('Du avbröt anslutningen')
expect(html).toContain(`}, "${BRAND_ORIGIN}")`)
expect(html).toContain(`${BRAND_ORIGIN}/import?migration=error`)
expect(html).not.toContain('window.close')
expect(exchangeAuthToken).not.toHaveBeenCalled()
})
it('safely embeds provider error text in both HTML and script contexts', async () => {
vi.mocked(consumeHandoff).mockResolvedValue({
...storedHandoff, providerCode: null, providerError: '</script><img src=x onerror=alert(1)>',
})
const html = await (await callbackHandler(request(BRAND_ORIGIN, { handoff: 'fresh-handoff' }))).text()
expect(html).not.toContain('<img')
expect(html).toContain('\\u003c/script>')
expect(html).toContain('&lt;/script&gt;')
})
it('rejects a replayed first hop before minting a second handoff', async () => {
vi.mocked(consumeOAuthState).mockResolvedValueOnce(state).mockResolvedValueOnce(null)
expect((await callbackHandler(request(APP_URL, { state: 'state', code: 'code' }))).status).toBe(302)
const replay = await callbackHandler(request(APP_URL, { state: 'state', code: 'code' }))
expect(await replay.text()).toContain(GENERIC_REJECTION)
expect(mintHandoff).toHaveBeenCalledOnce()
})
it('rejects replayed handoffs after exactly one exchange', async () => {
vi.mocked(consumeHandoff).mockResolvedValueOnce(storedHandoff).mockResolvedValueOnce(null)
await callbackHandler(request(BRAND_ORIGIN, { handoff: 'fresh-handoff' }))
const replay = await callbackHandler(request(BRAND_ORIGIN, { handoff: 'fresh-handoff' }))
expect(await replay.text()).toContain(GENERIC_REJECTION)
expect(exchangeAuthToken).toHaveBeenCalledOnce()
})
it.each(['expired', 'unknown', 'wrong-origin'])('rejects %s handoffs without exchanging', async (token) => {
vi.mocked(consumeHandoff).mockResolvedValue(null)
const response = await callbackHandler(request(BRAND_ORIGIN, { handoff: token }))
expect(await response.text()).toContain(GENERIC_REJECTION)
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(requireFlowInitiator).not.toHaveBeenCalled()
})
it.each(['no_session', 'mismatch'] as const)('refuses hop 2 with %s, including provider-error handoffs', async (reason) => {
vi.mocked(requireFlowInitiator).mockResolvedValue({ ok: false, reason, response: new Response(null, { status: 403 }), sessionUserId: 'other-user' })
for (const providerError of [null, 'provider rejected']) {
vi.mocked(consumeHandoff).mockResolvedValue({ ...storedHandoff, providerError })
const html = await (await callbackHandler(request(BRAND_ORIGIN, { handoff: 'token' }))).text()
expect(html).toContain('arcim-oauth-error')
expect(html).not.toContain('consent-1')
expect(html).not.toContain('provider rejected')
}
expect(exchangeAuthToken).not.toHaveBeenCalled()
})
it('refuses a legacy row with no initiator before minting a handoff', async () => {
vi.mocked(consumeOAuthState).mockResolvedValue({ ...state, userId: null })
const response = await callbackHandler(request(APP_URL, { state: 'state', code: 'code' }))
expect(await response.text()).toContain(GENERIC_REJECTION)
expect(mintHandoff).not.toHaveBeenCalled()
})
it.each([
[APP_URL, false, APP_URL],
[BRAND_ORIGIN, true, BRAND_ORIGIN],
['https://unknown.accounted.se', false, APP_URL],
['http://solbo.accounted.se', true, APP_URL],
['https://solbo.accounted.se:444', true, APP_URL],
])('connect from %s stores only an allowed origin', async (origin, knownBrand, expected) => {
vi.mocked(resolveBrandByHost).mockResolvedValue(knownBrand ? { domain: 'solbo.accounted.se' } as Awaited<ReturnType<typeof resolveBrandByHost>> : null)
vi.mocked(listConsents).mockResolvedValue([])
vi.mocked(createConsent).mockResolvedValue({ id: 'consent-new' } as Awaited<ReturnType<typeof createConsent>>)
vi.mocked(generateOtc).mockResolvedValue({ code: 'state', consentId: 'consent-new', expiresAt: '' })
vi.mocked(getAuthUrl).mockResolvedValue({ url: 'https://provider.test/login' })
const { supabase } = createMockSupabase()
;(supabase as unknown as { auth: unknown }).auth = {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
}
const connect = findRoute('POST', '/connect').handler as RouteHandler
const response = await connect(createMockRequest(`${origin}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST', body: { provider: 'fortnox', origin: 'https://attacker.test' },
}), { supabase, companyId: 'company-1' } as unknown as ExtensionContext)
expect(response.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-new', 'user-1', expected)
})
it.each([
['solbo.accounted.se', BRAND_ORIGIN],
['unknown.accounted.se', APP_URL],
['invalid host', APP_URL],
])('reconnect records the validated request Host %s', async (host, expected) => {
vi.mocked(resolveBrandByHost).mockImplementation(async (value) =>
value === 'solbo.accounted.se' ? { domain: value } as Awaited<ReturnType<typeof resolveBrandByHost>> : null)
vi.mocked(listConsents).mockResolvedValue([{ id: 'consent-1', provider: 'fortnox', status: 1 }] as Awaited<ReturnType<typeof listConsents>>)
vi.mocked(generateOtc).mockResolvedValue({ code: 'state', consentId: 'consent-1', expiresAt: '' })
vi.mocked(getAuthUrl).mockResolvedValue({ url: 'https://provider.test/login' })
const ctx = {
companyId: 'company-1',
supabase: { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) } },
} as unknown as ExtensionContext
const connect = findRoute('POST', '/connect').handler as RouteHandler
const response = await connect(createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST', headers: { host }, body: { provider: 'fortnox', reconnect: true },
}), ctx)
expect(response.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-1', 'user-1', expected)
})
})
describe('GET /callback: OAuth state binding', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
// The exchange redirect_uri now consults the per-provider override; keep
// these tests on the NEXT_PUBLIC_APP_URL fallback regardless of local env.
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.stubEnv('VISMA_REDIRECT_URI', '')
// The callback route is dispatched without an ExtensionContext (skipAuth
// routes get no ctx), so console is the logger. Keep the output quiet.
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('rejects a forged state: an unknown token never reaches token exchange', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({
code: 'provider-auth-code',
state: forgedLegacyState('victim-consent-id', 'fortnox'),
}),
)
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain(GENERIC_REJECTION)
// The response must not echo anything the attacker put in the state.
expect(html).not.toContain('victim-consent-id')
})
it('rejects an expired state with the same generic message as a forged one', async () => {
// consumeOAuthState collapses expired into "no row": the expiry predicate
// lives in the UPDATE's WHERE clause (see provider-client tests).
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'expired-token' }),
)
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain(GENERIC_REJECTION)
})
it('rejects a replayed state: the second callback with the same token fails', async () => {
// First delivery consumes the row, second finds nothing left to consume.
;(consumeOAuthState as Mock)
.mockResolvedValueOnce({ consentId: 'consent-1', provider: 'fortnox', userId: 'user-1' })
.mockResolvedValueOnce(null)
const first = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const second = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
expect(await first.text()).toContain('Anslutningen lyckades')
expect(await second.text()).toContain(GENERIC_REJECTION)
// Exactly one exchange: the replay bought the attacker nothing.
expect(exchangeAuthToken).toHaveBeenCalledTimes(1)
expect(consumeOAuthState).toHaveBeenNthCalledWith(1, 'one-time-token')
expect(consumeOAuthState).toHaveBeenNthCalledWith(2, 'one-time-token')
})
it('takes consent and provider from the state ROW, never from the query string', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-owned-by-caller',
provider: 'visma',
userId: 'user-1',
})
// The token names a different consent and provider. It must be ignored:
// the row wins.
const res = await callbackHandler(
callbackRequest({
code: 'provider-auth-code',
state: forgedLegacyState('victim-consent-id', 'fortnox'),
}),
)
expect(exchangeAuthToken).toHaveBeenCalledTimes(1)
expect(exchangeAuthToken).toHaveBeenCalledWith(
'consent-owned-by-caller',
'visma',
'provider-auth-code',
`${APP_URL}/api/extensions/ext/arcim-migration/callback`,
)
expect(await res.text()).toContain('consent-owned-by-caller')
})
})
/**
* The callback's no-opener arm used to be near-dead: the wizard only ever
* reached this route through a popup, which always has a window.opener.
* ArcimMigrationWorkspace now falls back to a full-page OAuth flow when the
* popup is blocked (a discarded window.open return value made a blocked popup
* look exactly like a successful one), so that arm is a live user path and the
* only way a popup-blocked user finishes the migration.
*
* These pin the URL it navigates to, because the wizard reads it on the other
* end: `/import?migration=...` sets mode='migration'
* (app/(dashboard)/import/page.tsx:1990) and handleOAuthReturn consumes
* `consentId` / `reason` from there. Dropping the arm, or renaming a param,
* would strand every popup-blocked user on this HTML page.
*/
describe('GET /callback: full-page fallback when there is no opener', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
/** The URL the page navigates to when window.opener is absent. */
function fallbackNavigation(html: string): URL {
// Both arms are emitted; the opener arm postMessages instead of navigating.
expect(html).toContain('window.opener')
// replace(), not href: the callback URL carries a spent one-time state and
// must not stay in session history. See the replay describe below.
const match = html.match(/window\.location\.replace\("([^"]+)"\)/)
expect(match, 'callback HTML has no no-opener navigation').not.toBeNull()
return new URL(match![1])
}
it('sends a successful connect back to the wizard with the consent id', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const target = fallbackNavigation(await res.text())
expect(target.origin).toBe(APP_URL)
expect(target.pathname).toBe('/import')
expect(target.searchParams.get('migration')).toBe('connected')
expect(target.searchParams.get('consentId')).toBe('consent-1')
})
it('sends a failure back to the wizard with the reason attached', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'forged-token' }),
)
const target = fallbackNavigation(await res.text())
expect(target.pathname).toBe('/import')
expect(target.searchParams.get('migration')).toBe('error')
expect(target.searchParams.get('reason')).toContain(GENERIC_REJECTION)
})
it('includes the consent id when a full-page provider error can be resumed', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
callbackRequest({
error: 'access_denied',
error_description: 'User denied consent',
state: 'one-time-token',
}),
)
const target = fallbackNavigation(await res.text())
expect(target.searchParams.get('migration')).toBe('error')
expect(target.searchParams.get('consentId')).toBe('consent-1')
expect(exchangeAuthToken).not.toHaveBeenCalled()
})
})
/**
* The redirect_uri sent in the authorization request and the one sent in the
* token exchange must be byte-identical (RFC 6749 §4.1.3) or the provider
* rejects the code exchange. These broke apart once already: the authorize leg
* honored the FORTNOX_REDIRECT_URI override while the exchange hardcoded the
* NEXT_PUBLIC_APP_URL fallback, so when the app moved to app.accounted.se and
* the env var still pointed at app.gnubok.se, every Fortnox connect died at
* the exchange with no visible error (the error postMessage was then dropped
* by the opener's origin check). Both legs now resolve through
* resolveArcimCallbackUrl; these tests pin the symmetry.
*/
describe('OAuth redirect_uri symmetry between authorize and exchange', () => {
const OVERRIDE_URI = 'https://dev-tunnel.example.test/api/extensions/ext/arcim-migration/callback'
const connectHandler = findRoute('POST', '/connect').handler as RouteHandler
function connectCtx(): ExtensionContext {
const { supabase } = createMockSupabase()
;(supabase as unknown as { auth: unknown }).auth = {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
}
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', OVERRIDE_URI)
vi.spyOn(console, 'error').mockImplementation(() => {})
;(listConsents as Mock).mockResolvedValue([])
;(createConsent as Mock).mockResolvedValue({ id: 'consent-new' })
;(generateOtc as Mock).mockResolvedValue({ code: 'otc-code-1' })
;(getAuthUrl as Mock).mockResolvedValue({ url: 'https://apps.fortnox.se/oauth-v1/auth?x=1' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('authorize leg passes the env-override redirect URI to getAuthUrl', async () => {
const res = await connectHandler(
createMockRequest('http://localhost/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
body: { provider: 'fortnox' },
}),
connectCtx(),
)
expect(res.status).toBe(200)
expect(getAuthUrl).toHaveBeenCalledWith(
'fortnox',
'otc-code-1',
OVERRIDE_URI,
// A first connect never asks for the voucher-attachment scopes: those
// carry a Fortnox licence requirement and belong to the opt-in underlag
// reconnect only.
{ documentScopes: undefined },
)
})
// The underlag follow-up is the only caller allowed to widen the consent.
it('reconnect asks for the attachment scopes only when the underlag flow requests them', async () => {
;(listConsents as Mock).mockResolvedValue([
{ id: 'consent-1', provider: 'fortnox', status: 1 },
])
const reconnect = (documentScopes?: boolean) =>
connectHandler(
createMockRequest('http://localhost/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
body: { provider: 'fortnox', reconnect: true, ...(documentScopes === undefined ? {} : { documentScopes }) },
}),
connectCtx(),
)
expect((await reconnect(true)).status).toBe(200)
expect(getAuthUrl).toHaveBeenLastCalledWith(
'fortnox',
'otc-code-1',
OVERRIDE_URI,
{ documentScopes: true },
)
expect((await reconnect()).status).toBe(200)
expect(getAuthUrl).toHaveBeenLastCalledWith(
'fortnox',
'otc-code-1',
OVERRIDE_URI,
{ documentScopes: false },
)
})
it('exchange leg passes the SAME env-override redirect URI to exchangeAuthToken', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-new',
provider: 'fortnox',
userId: 'user-1',
})
await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
expect(exchangeAuthToken).toHaveBeenCalledWith(
'consent-new',
'fortnox',
'provider-auth-code',
OVERRIDE_URI,
)
})
})
/**
* The error page must stay open: its postMessage is dropped whenever the
* popup's origin differs from the opener's, and a window.close() right after
* turns that into "I approve in Fortnox and then nothing happens". The success
* page still closes itself.
*/
describe('GET /callback: error popup stays open, success popup closes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('keeps the error popup open with the reason visible', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'bad-token' }),
)
const html = await res.text()
expect(html).toContain('Anslutningen misslyckades')
expect(html).not.toContain('window.close')
})
it('still closes the success popup', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const html = await res.text()
expect(html).toContain('Anslutningen lyckades')
expect(html).toContain('window.close()')
})
})
describe('GET /preview: cross-tenant consent status oracle', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
function buildCtx(): ExtensionContext {
const { supabase } = createMockSupabase()
;(supabase as unknown as { auth: unknown }).auth = {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
}
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
}
it('scopes the consent read to the caller company', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 5, provider: 'fortnox' })
await previewHandler(
createMockRequest('http://localhost/api/extensions/ext/arcim-migration/preview', {
searchParams: { consentId: 'consent-1' },
}),
buildCtx(),
)
expect(getConsent).toHaveBeenCalledWith('consent-1', 'company-1')
})
it('answers 404 without a status for a consent owned by another company', async () => {
;(getConsent as Mock).mockRejectedValue(new ConsentNotFoundError())
const res = await previewHandler(
createMockRequest('http://localhost/api/extensions/ext/arcim-migration/preview', {
searchParams: { consentId: 'other-tenants-consent' },
}),
buildCtx(),
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: Record<string, unknown> }
}>(res)
expect(status).toBe(404)
expect(body.error.code).toBe('PROVIDER_CONSENT_NOT_FOUND')
// No consent state may leak: not the numeric status, not the provider.
// 404 with no state is exactly what a nonexistent consent returns too.
expect(body.error.details ?? {}).not.toHaveProperty('status')
expect(body.error.details ?? {}).not.toHaveProperty('provider')
})
})
/**
* A callback URL is single-use: the state it carries is spent the moment
* consumeOAuthState returns. Prod caught the consequence of leaving it in
* session history: a callback that had already succeeded was delivered a
* second time 19 seconds later, and the user was told "Ingen giltig
* migrationssession hittades" about a connection that had just worked.
*
* The page therefore replaces its history entry instead of pushing one, and
* the response is no-store so no Back/reload can serve it from cache. The
* state check itself is deliberately untouched: the callback is
* unauthenticated, so it still answers consumed, expired, forged and unknown
* with the same sentence.
*/
describe('GET /callback: the spent callback URL cannot come back', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('sends no-store and replaces history on a successful callback', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const html = await res.text()
expect(res.headers.get('Cache-Control')).toBe('no-store')
expect(html).toContain('window.location.replace(')
expect(html).not.toContain('window.location.href')
})
it('sends no-store and replaces history on a rejected callback too', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'spent-token' }),
)
const html = await res.text()
expect(res.headers.get('Cache-Control')).toBe('no-store')
expect(html).toContain('window.location.replace(')
expect(html).not.toContain('window.location.href')
// The anti-oracle property stands: a consumed state still reads exactly
// like a forged one.
expect(html).toContain(GENERIC_REJECTION)
})
})