Files
accounted/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts
T
473b1fd2eb fix(providers): name the real Björn Lundén connect failure (integration not activated, not bad credentials) (#2322)
* fix(providers): name the real Björn Lundén connect failure: integration not activated, not bad credentials

Every Björn Lundén connect in prod has failed with "Leverantören avvisade
autentiseringen" (10 consents since June; only BL's own sandbox company ever
received tokens). Live-verified against a real customer User-Key today: BL
answers 403 "<service>:READ is out of allowed scope for service provider
Arcim" on every read endpoint. The key is right and binds the company; the
company has simply never activated our integration, and it cannot until BL
moves the listing out of sandbox. The generic 403 mapping told the user to
re-check what they pasted, which can never help.

- BjornLundenClient: isBjornLundenScopeError / isBjornLundenUnknownKeyError,
  matching the verbatim live 403 and 500 bodies.
- submitProviderToken: 403-with-scope-body -> ProviderTokenInvalidError kind
  'integration-not-activated'; 500/404 -> 'company-key-not-found'; 401 (our
  own client_credentials token refused) rethrows as a generic submit failure
  instead of blaming the pasted key.
- New 422 structured errors BL_INTEGRATION_NOT_ACTIVATED and
  BL_COMPANY_KEY_NOT_FOUND with Swedish/English copy that names the fix
  (activate under Integrationer in Lundify, else SIE) and where the GUID is.
- Wizard copy for BL moved to i18n keys and reordered: activate first, then
  paste the key; the key only works once the integration is activated.
- Tests: route mapping for both kinds, probe classification incl. the
  captured live bodies, registry entries pinned to 422.

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

* fix(providers): drop the unknown-key body matcher, the live BL 500 body is not stable

Verifying through BjornLundenClient against apigateway.blinfo.se, a made-up
User-Key answered 500 with a Spring BeanCreationException for
databaseConnector, not the null getCurrentUser() message captured earlier.
The unknown-key verdict already keys on the status alone in
submitProviderToken; keep only the 403 scope matcher, whose body IS stable.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 17:20:08 +02:00

340 lines
12 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'
| 'integration-not-activated'
| 'company-key-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('passes a non-empty string cursor through and restarts from the top for anything else', async () => {
;(importProviderDocuments as Mock).mockResolvedValue({
provider: 'fortnox',
scanned: 1,
linked: 1,
skipped: 0,
unmatched: 0,
failed: 0,
dryRun: false,
unmatchedSamples: [],
total: 113,
partial: true,
nextCursor: 'file-18',
})
const withCursor = createMockRequest(
'http://localhost/api/extensions/ext/arcim-migration/import-documents',
{ method: 'POST', body: { consentId: 'consent-1', dryRun: false, cursor: 'file-17' } },
)
const { status, body } = await parseJsonResponse<{
result: { partial: boolean; nextCursor: string | null }
}>(await handler(withCursor, buildContext()))
expect(status).toBe(200)
expect(body.result).toMatchObject({ partial: true, nextCursor: 'file-18' })
expect(importProviderDocuments).toHaveBeenLastCalledWith(
expect.objectContaining({ consentId: 'consent-1', dryRun: false, cursor: 'file-17' }),
)
const garbage = createMockRequest(
'http://localhost/api/extensions/ext/arcim-migration/import-documents',
{ method: 'POST', body: { consentId: 'consent-1', cursor: 17 } },
)
await handler(garbage, buildContext())
expect(importProviderDocuments).toHaveBeenLastCalledWith(
expect.objectContaining({ cursor: null }),
)
})
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')
})
})
describe('POST /submit-token Björn Lundén error mapping', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('reports a valid key whose company never activated the integration as an activation problem, not bad credentials', async () => {
;(submitProviderToken as Mock).mockRejectedValue(
new ProviderTokenInvalidError(
'Björn Lundén: the company behind this User-Key has not activated the integration',
'integration-not-activated',
),
)
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('BL_INTEGRATION_NOT_ACTIVATED')
expect(body.error.message).toContain('Aktivera integrationen')
expect(body.error.message).not.toContain('avvisade autentiseringen')
expect(body.error.message_en).toContain('Activate the integration')
})
it('reports an unknown User-Key as a key problem with the place to copy it from', async () => {
;(submitProviderToken as Mock).mockRejectedValue(
new ProviderTokenInvalidError(
'Björn Lundén found no company for the key (HTTP 500)',
'company-key-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('BL_COMPANY_KEY_NOT_FOUND')
expect(body.error.message).toContain('hittade inget företag')
expect(body.error.message_en).toContain('found no company')
})
})