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>
251 lines
8.3 KiB
TypeScript
251 lines
8.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
|
import { createMockRequest, createMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
vi.mock('../lib/import-documents', () => {
|
|
class FortnoxDocumentScopesRequiredError extends Error {
|
|
readonly code = 'PROVIDER_DOCUMENT_SCOPES_REQUIRED'
|
|
|
|
constructor() {
|
|
super('Fortnox consent lacks archive/connectfile scope: reconnect required')
|
|
}
|
|
}
|
|
|
|
return {
|
|
FortnoxDocumentScopesRequiredError,
|
|
importProviderDocuments: vi.fn(),
|
|
}
|
|
})
|
|
|
|
const fortnoxOAuth = vi.hoisted(() => ({ documentScopesApproved: false }))
|
|
|
|
vi.mock('@/lib/providers/fortnox/oauth', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('@/lib/providers/fortnox/oauth')>()
|
|
return {
|
|
...actual,
|
|
get FORTNOX_DOCUMENT_SCOPES_APPROVED() {
|
|
return fortnoxOAuth.documentScopesApproved
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('../lib/provider-client', () => {
|
|
class ProviderTokenInvalidError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly kind: 'credentials' | 'company-not-found' = 'credentials',
|
|
) {
|
|
super(message)
|
|
}
|
|
}
|
|
|
|
return {
|
|
createConsent: vi.fn(),
|
|
getConsent: vi.fn(),
|
|
listConsents: vi.fn(),
|
|
generateOtc: vi.fn(),
|
|
consumeOAuthState: vi.fn(),
|
|
getAuthUrl: vi.fn(),
|
|
exchangeAuthToken: vi.fn(),
|
|
submitProviderToken: vi.fn(),
|
|
acceptConsent: vi.fn(),
|
|
deleteConsent: vi.fn(),
|
|
resolveConsent: vi.fn(),
|
|
fetchCompanyInfoDirect: vi.fn(),
|
|
ProviderTokenInvalidError,
|
|
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
|
|
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
|
|
}
|
|
})
|
|
|
|
import { arcimMigrationExtension } from '../index'
|
|
import {
|
|
FortnoxDocumentScopesRequiredError,
|
|
importProviderDocuments,
|
|
} from '../lib/import-documents'
|
|
import {
|
|
ProviderTokenInvalidError,
|
|
submitProviderToken,
|
|
} from '../lib/provider-client'
|
|
|
|
const route = (arcimMigrationExtension.apiRoutes ?? []).find(
|
|
(candidate) =>
|
|
candidate.method === 'POST' && candidate.path === '/import-documents',
|
|
)!
|
|
|
|
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
|
const handler = route.handler as RouteHandler
|
|
const submitTokenRoute = (arcimMigrationExtension.apiRoutes ?? []).find(
|
|
(candidate) => candidate.method === 'POST' && candidate.path === '/submit-token',
|
|
)!
|
|
const submitTokenHandler = submitTokenRoute.handler as RouteHandler
|
|
|
|
function buildContext(): 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
|
|
}
|
|
|
|
function request(dryRun: boolean) {
|
|
return createMockRequest(
|
|
'http://localhost/api/extensions/ext/arcim-migration/import-documents',
|
|
{
|
|
method: 'POST',
|
|
body: { consentId: 'consent-1', dryRun },
|
|
},
|
|
)
|
|
}
|
|
|
|
function submitTokenRequest() {
|
|
return createMockRequest(
|
|
'http://localhost/api/extensions/ext/arcim-migration/submit-token',
|
|
{
|
|
method: 'POST',
|
|
body: {
|
|
consentId: 'consent-1',
|
|
provider: 'bokio',
|
|
apiToken: 'not-a-real-token',
|
|
companyId: '9b408943-7a1e-47ac-85a7-ac52b2c210d3',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
describe('POST /import-documents', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
fortnoxOAuth.documentScopesApproved = false
|
|
})
|
|
|
|
it('passes dry-run discovery through without storing documents', async () => {
|
|
;(importProviderDocuments as Mock).mockResolvedValue({
|
|
provider: 'fortnox',
|
|
scanned: 4,
|
|
linked: 3,
|
|
skipped: 0,
|
|
unmatched: 1,
|
|
failed: 0,
|
|
dryRun: true,
|
|
unmatchedSamples: [],
|
|
})
|
|
|
|
const response = await handler(request(true), buildContext())
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
dryRun: boolean
|
|
result: { scanned: number }
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body).toMatchObject({ success: true, dryRun: true, result: { scanned: 4 } })
|
|
expect(importProviderDocuments).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: 'company-1',
|
|
consentId: 'consent-1',
|
|
dryRun: true,
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('asks the user to reconnect only once the connect request carries the scopes', async () => {
|
|
fortnoxOAuth.documentScopesApproved = true
|
|
;(importProviderDocuments as Mock).mockRejectedValue(
|
|
new FortnoxDocumentScopesRequiredError(),
|
|
)
|
|
|
|
const response = await handler(request(false), buildContext())
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; message: string; message_en?: string }
|
|
}>(response)
|
|
|
|
expect(status).toBe(403)
|
|
expect(body.error.code).toBe('PROVIDER_DOCUMENT_SCOPES_REQUIRED')
|
|
expect(body.error.message).toContain('Koppla om Fortnox')
|
|
expect(body.error.message_en).toContain('Reconnect Fortnox')
|
|
})
|
|
|
|
// Klura AB, 2026-08-20: the connect request does not ask Fortnox for Arkiv
|
|
// and Koppla fil at all, so the reconnect advice sent the user around a loop
|
|
// four times (and to buy the Fortnox Arkiv module) for nothing.
|
|
it('says the permission is missing on our side while the scopes are unapproved', async () => {
|
|
fortnoxOAuth.documentScopesApproved = false
|
|
;(importProviderDocuments as Mock).mockRejectedValue(
|
|
new FortnoxDocumentScopesRequiredError(),
|
|
)
|
|
|
|
const response = await handler(request(false), buildContext())
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; message: string; message_en?: string }
|
|
}>(response)
|
|
|
|
expect(status).toBe(403)
|
|
expect(body.error.code).toBe('PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE')
|
|
expect(body.error.message).not.toContain('Koppla om Fortnox')
|
|
expect(body.error.message).toContain('Att koppla om hjälper inte')
|
|
expect(body.error.message_en).toContain('Reconnecting will not help')
|
|
})
|
|
})
|
|
|
|
describe('POST /submit-token Bokio error mapping', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
it('reports a 401/403 authentication verdict as rejected integration details', async () => {
|
|
;(submitProviderToken as Mock).mockRejectedValue(
|
|
new ProviderTokenInvalidError('Bokio rejected the integration token (HTTP 403)'),
|
|
)
|
|
|
|
const response = await submitTokenHandler(submitTokenRequest(), buildContext())
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; message: string; message_en?: string }
|
|
}>(response)
|
|
|
|
expect(status).toBe(422)
|
|
expect(body.error.code).toBe('PROVIDER_TOKEN_INVALID')
|
|
expect(body.error.message).toContain('avvisade autentiseringen')
|
|
expect(body.error.message_en).toContain('rejected the authentication')
|
|
})
|
|
|
|
it('reports a Bokio 404 as a company-ID failure instead of rejected credentials', async () => {
|
|
;(submitProviderToken as Mock).mockRejectedValue(
|
|
new ProviderTokenInvalidError(
|
|
'Bokio does not know that company id',
|
|
'company-not-found',
|
|
),
|
|
)
|
|
|
|
const response = await submitTokenHandler(submitTokenRequest(), buildContext())
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; message: string; message_en?: string }
|
|
}>(response)
|
|
|
|
expect(status).toBe(422)
|
|
expect(body.error.code).toBe('BOKIO_COMPANY_NOT_FOUND')
|
|
expect(body.error.message).toContain('företags-ID')
|
|
expect(body.error.message_en).toContain('company ID')
|
|
})
|
|
|
|
it('keeps an unclassified provider/configuration failure generic', async () => {
|
|
;(submitProviderToken as Mock).mockRejectedValue(
|
|
new Error('Bokio company-information response is missing companyInformation'),
|
|
)
|
|
|
|
const response = await submitTokenHandler(submitTokenRequest(), buildContext())
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: { code: string; message: string; message_en?: string }
|
|
}>(response)
|
|
|
|
expect(status).toBe(500)
|
|
expect(body.error.code).toBe('PROVIDER_TOKEN_SUBMIT_FAILED')
|
|
expect(body.error.message).toContain('kontrollera integrationsuppgifterna')
|
|
expect(body.error.message_en).toContain('verify the integration details')
|
|
})
|
|
})
|