* fix(migration): resumable underlag import without inline extraction, same-origin MCP storage URLs The Fortnox underlag import ran every file's AI extraction inline inside one request and hit the hosted 300 s function limit after ~17 of 113 files (twice on 2026-08-21); the UI showed the generic "underlagen kunde inte importeras" although the files it did reach were linked. The import now works in time-budgeted slices with a stable cursor (the UI loops until the server reports the end and shows "x av y") and opts out of extraction (extractionOwner 'none', stamped skipped:opted_out): every file is linked to its posted verifikat on arrival, so the booking is already known. MCP signed Storage URLs (upload_url, signed_url, download_url) are served through a same-origin proxy, /api/storage/[...path], because Claude Desktop's sandbox only reaches the MCP host and blocked the PUT to <project>.supabase.co. The signed token stays the only credential; the proxy forwards only signed documents-bucket paths to our own Storage host and is a no-op rewrite when NEXT_PUBLIC_APP_URL is unset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm * fix(mcp): keep the storage-proxy note out of the size-capped tool descriptions The per-tool 280-char cap and the tools/list payload ceiling both tripped on the two sentences added to gnubok_create_document_upload and gnubok_get_document_content; the why now lives in a code comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm * fix(review): id cursor, stall = error, capped upload body, encoded dot segments Review follow-ups on #1783: - the import cursor is the last handled provider attachment id, not an index, so a file Fortnox adds or removes mid-sweep shifts nothing - a partial answer whose cursor does not advance (or the round guard) is reported as ARCIM_DOCUMENT_IMPORT_STALLED instead of "complete"; the slices already landed stay reported and the retry button resumes - the storage proxy reads the PUT body as a capped stream instead of buffering an unbounded payload before measuring it - object paths are rejected when any segment decodes to "." or ".." (or holds a separator), and the URL fetch() would actually request is re-checked against the allowlist after normalisation - download_url description no longer claims a direct Storage URL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013YoZ8iboyTj221axW6Gdtm --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
290 lines
9.7 KiB
TypeScript
290 lines
9.7 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('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')
|
|
})
|
|
})
|