Files
accounted/components/extensions/general/__tests__/arcim-document-import-flow.test.ts
T
f3e4fdcf32 fix(providers): stop the Fortnox reconnect loop, and make the attachment scopes opt-in (#1761)
The Fortnox document import needs the archive and connectfile scopes, which the
registered Fortnox app does not have. Since #1549 pulled them out of the connect
request (they broke every connect with invalid_scope before login), every
attachment call fails and the user was told "Koppla om Fortnox och godkann
behorigheterna", under a button that reruns an authorize URL still not asking
for those scopes. Klura AB followed that loop four times and bought the Fortnox
Arkiv module trying to satisfy it. Prod evidence: no Fortnox attachment has ever
imported, across 166 companies and 24 consents since the feature shipped, and no
live token carries the scopes.

The error and the scope list now derive from one flag,
FORTNOX_DOCUMENT_SCOPES_APPROVED. While it is false a permission failure maps to
a new PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, which says the permission is missing
on our side, that reconnecting will not help, and that the rest of the migration
came through; the card offers no button, because no user action can succeed.

The attachment scopes also become an opt-in consent rather than part of every
connect. Fortnox derives customer licence requirements from what an integration
requests, so asking everyone for Arkivplats would put a licence in front of
customers who never import a receipt; and keeping it off the default connect
caps the blast radius of a wrong portal registration at the underlag flow rather
than every Fortnox connection. buildFortnoxAuthUrl already took per-call scopes,
provider-client simply never passed any, so this threads documentScopes from
that one button through /connect into the authorize URL.

A document consent is always a superset of an ordinary one: the callback
overwrites the consent's tokens in place, so a narrower grant would revoke the
migration's own ledger access. Pinned by a test that holds either way the flag
is set, alongside one for the 400-with-behorighet answer that six companies hit
between 08-13 and 08-19 and saw only a generic retry for.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 08:33:34 +02:00

301 lines
9.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
ARCIM_DOCUMENT_OAUTH_RESUME_KEY,
INITIAL_ARCIM_DOCUMENT_IMPORT_STATE,
PROVIDER_DOCUMENT_SCOPES_REQUIRED,
PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
ArcimDocumentImportRequestError,
arcimDocumentImportReducer,
documentOAuthProblemFromReason,
parseArcimDocumentOAuthResume,
requestArcimDocumentImport,
resolveArcimDocumentFollowUpProvider,
watchArcimOAuthPopup,
type ArcimDocumentImportResult,
} from '../arcim-document-import-flow'
function result(
overrides: Partial<ArcimDocumentImportResult> = {},
): ArcimDocumentImportResult {
return {
provider: 'fortnox',
scanned: 7,
linked: 5,
skipped: 0,
unmatched: 2,
failed: 0,
dryRun: true,
unmatchedSamples: [],
...overrides,
}
}
describe('Fortnox document follow-up state', () => {
it('offers the prompt after a successful Fortnox migration using the honest found count', () => {
const discovering = arcimDocumentImportReducer(
INITIAL_ARCIM_DOCUMENT_IMPORT_STATE,
{ type: 'discovery-started', provider: 'fortnox', migrationSucceeded: true },
)
const offered = arcimDocumentImportReducer(discovering, {
type: 'discovery-succeeded',
result: result({ scanned: 7, linked: 5, unmatched: 2 }),
})
expect(offered.phase).toBe('offered')
expect(offered.found).toBe(7)
})
it('does not start discovery for a non-Fortnox migration', () => {
expect(
arcimDocumentImportReducer(INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, {
type: 'discovery-started',
provider: 'bokio',
migrationSucceeded: true,
}),
).toEqual(INITIAL_ARCIM_DOCUMENT_IMPORT_STATE)
})
it('keeps the document step visible when Fortnox has no attachments', () => {
const discovering = arcimDocumentImportReducer(
INITIAL_ARCIM_DOCUMENT_IMPORT_STATE,
{ type: 'discovery-started', provider: 'fortnox', migrationSucceeded: true },
)
const empty = arcimDocumentImportReducer(discovering, {
type: 'discovery-succeeded',
result: result({ scanned: 0, linked: 0, unmatched: 0 }),
})
expect(empty.phase).toBe('empty')
expect(empty.result?.scanned).toBe(0)
})
it('uses the completed preview provider before transient selection state', () => {
expect(resolveArcimDocumentFollowUpProvider('fortnox', null)).toBe('fortnox')
expect(resolveArcimDocumentFollowUpProvider('fortnox', 'bokio')).toBe('fortnox')
expect(resolveArcimDocumentFollowUpProvider(undefined, 'fortnox')).toBe('fortnox')
expect(resolveArcimDocumentFollowUpProvider('bokio', 'fortnox')).toBeNull()
})
it('keeps a dry-run failure in a retryable document state, separate from migration success', () => {
const problem = { code: 'TRANSIENT_ERROR', requestId: 'req_test', reconnectRequired: false }
const state = arcimDocumentImportReducer(
{ phase: 'discovering', found: 0, result: null, problem: null },
{ type: 'discovery-failed', problem },
)
expect(state).toEqual({
phase: 'discovery-error',
found: 0,
result: null,
problem,
})
})
it('keeps all outcome counts after a successful import', () => {
const imported = result({
dryRun: false,
scanned: 7,
linked: 3,
skipped: 2,
unmatched: 1,
failed: 1,
})
const state = arcimDocumentImportReducer(
{ phase: 'importing', found: 7, result: null, problem: null },
{ type: 'import-succeeded', result: imported },
)
expect(state.phase).toBe('complete')
expect(state.result).toMatchObject({
linked: 3,
skipped: 2,
unmatched: 1,
failed: 1,
})
})
it('keeps the dry-run result offered until the user explicitly starts import', () => {
const offered = arcimDocumentImportReducer(
{ phase: 'discovering', found: 0, result: null, problem: null },
{ type: 'discovery-succeeded', result: result({ dryRun: true }) },
)
expect(offered).toMatchObject({ phase: 'offered', result: { dryRun: true } })
expect(
arcimDocumentImportReducer(offered, { type: 'import-started' }),
).toMatchObject({ phase: 'importing' })
})
it('allows OAuth success to replace an earlier popup-close failure', () => {
const problem = { code: null, requestId: null, reconnectRequired: true }
const failed = arcimDocumentImportReducer(
{ phase: 'reconnecting', found: 7, result: result(), problem },
{ type: 'import-failed', problem },
)
const importing = arcimDocumentImportReducer(failed, { type: 'import-started' })
const complete = arcimDocumentImportReducer(importing, {
type: 'import-succeeded',
result: result({ dryRun: false, linked: 7, unmatched: 0 }),
})
expect(failed.phase).toBe('import-error')
expect(complete).toMatchObject({ phase: 'complete', result: { linked: 7 } })
})
it('marks the archive/connectfile scope error as reconnect-required', async () => {
const fetcher = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: {
code: PROVIDER_DOCUMENT_SCOPES_REQUIRED,
message: 'scope required',
requestId: 'req_scope',
},
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } },
),
)
const error = await requestArcimDocumentImport(
'consent-1',
true,
fetcher,
).catch((caught) => caught)
expect(error).toBeInstanceOf(ArcimDocumentImportRequestError)
expect(error.problem).toEqual({
code: PROVIDER_DOCUMENT_SCOPES_REQUIRED,
requestId: 'req_scope',
reconnectRequired: true,
})
})
// The scopes are missing from the connect request itself, so offering a
// reconnect would loop the user forever (Klura AB, 2026-08-20).
it('never offers a reconnect when the scopes are unavailable to the integration', async () => {
const fetcher = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: {
code: PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
message: 'scopes unavailable',
requestId: 'req_unavailable',
},
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } },
),
)
const error = await requestArcimDocumentImport(
'consent-1',
true,
fetcher,
).catch((caught) => caught)
expect(error).toBeInstanceOf(ArcimDocumentImportRequestError)
expect(error.problem).toEqual({
code: PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE,
requestId: 'req_unavailable',
reconnectRequired: false,
})
})
})
describe('document import endpoint request', () => {
it('uses POST dry-run discovery without downloading automatically', async () => {
const fetcher = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true, result: result() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
await requestArcimDocumentImport('consent-1', true, fetcher)
expect(fetcher).toHaveBeenCalledWith(
'/api/extensions/ext/arcim-migration/import-documents',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ consentId: 'consent-1', dryRun: true }),
}),
)
})
it('rejects a success payload without unmatched samples', async () => {
const invalid = result()
const { unmatchedSamples: _unmatchedSamples, ...withoutSamples } = invalid
const fetcher = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true, result: withoutSamples }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
await expect(
requestArcimDocumentImport('consent-1', true, fetcher),
).rejects.toBeInstanceOf(ArcimDocumentImportRequestError)
})
})
describe('document scope OAuth recovery', () => {
it('round-trips the full-page redirect resume action and rejects malformed state', () => {
expect(ARCIM_DOCUMENT_OAUTH_RESUME_KEY).toBe('arcim-document-oauth-resume')
expect(parseArcimDocumentOAuthResume('import')).toEqual({
action: 'import',
})
expect(parseArcimDocumentOAuthResume('unknown')).toBeNull()
})
it('only treats scope and consent failures as reconnectable', () => {
expect(
documentOAuthProblemFromReason('Tredjepartsappen saknar rätt behörigheter'),
).toMatchObject({
code: PROVIDER_DOCUMENT_SCOPES_REQUIRED,
reconnectRequired: true,
})
expect(documentOAuthProblemFromReason('Du avbröt anslutningen')).toMatchObject({
code: null,
reconnectRequired: true,
})
expect(documentOAuthProblemFromReason('Leverantören är tillfälligt nere')).toEqual({
code: null,
requestId: null,
reconnectRequired: false,
message: 'Leverantören är tillfälligt nere',
})
})
it('restores retry controls when the OAuth popup is closed', () => {
vi.useFakeTimers()
const popup = { closed: false }
const onClosed = vi.fn()
const stopWatching = watchArcimOAuthPopup(popup, onClosed, 10, 20)
vi.advanceTimersByTime(20)
expect(onClosed).not.toHaveBeenCalled()
popup.closed = true
vi.advanceTimersByTime(10)
expect(onClosed).not.toHaveBeenCalled()
vi.advanceTimersByTime(20)
expect(onClosed).toHaveBeenCalledOnce()
stopWatching()
vi.useRealTimers()
})
it('lets a queued OAuth success cancel the popup-close grace period', () => {
vi.useFakeTimers()
const popup = { closed: true }
const onClosed = vi.fn()
const stopWatching = watchArcimOAuthPopup(popup, onClosed, 10, 20)
vi.advanceTimersByTime(10)
stopWatching()
vi.advanceTimersByTime(20)
expect(onClosed).not.toHaveBeenCalled()
vi.useRealTimers()
})
})