Files
accounted/extensions/general/invoice-inbox/__tests__/upload-signed-route.test.ts
T
Jakob Wennberg 749f90fe62 feat(inbox): direct-to-storage upload for files over the hosted body limit (#1551) (#2030)
Hosted uploads larger than the 4 MB multipart ceiling (Vercel's 4.5 MB request-body cap) now go POST /upload/create (signed PUT URL, rate-limited) -> PUT to the raw Storage URL -> POST /upload/complete (server-side magic-byte and size validation, sha256, WORM move, idempotent), reusing the #1378 pending-upload primitives. uploadAndExtract is split into uploadDocument + processArchivedDocument so both paths share the inbox pipeline. Dokumentinkorgen and the supplier-invoice form use the new path only above the threshold; files that fit keep the multipart route. Cap stays at 10 MB (the issue asks for 20 MB: founder call). Refs #1551
2026-08-30 11:55:42 +02:00

453 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
// The two Storage primitives are the seam: the routes must hand the RAW
// signed URL out, and hand the reservation back with the inbox's own
// options. Everything after archival is processArchivedDocument, mocked so
// this file asserts the hand-off, not the extraction pipeline (covered by
// upload-page-count-gate / sandbox-skip-extraction).
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: vi.fn(),
createPendingDocumentUpload: vi.fn(),
completePendingDocumentUpload: vi.fn(),
linkToJournalEntry: vi.fn(),
}))
vi.mock('@/extensions/general/invoice-inbox/lib/upload-and-extract', async (importOriginal) => {
const actual = await importOriginal<
typeof import('@/extensions/general/invoice-inbox/lib/upload-and-extract')
>()
return { ...actual, processArchivedDocument: vi.fn() }
})
vi.mock('@/lib/rate-limits/inbox', () => ({
checkInboxUploadRateLimit: vi.fn(),
}))
import {
createPendingDocumentUpload,
completePendingDocumentUpload,
} from '@/lib/core/documents/document-service'
import { processArchivedDocument } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
function findRoute(method: string, path: string) {
return invoiceInboxExtension.apiRoutes!.find(
(r) => r.method === method && r.path === path,
)!
}
const createRoute = findRoute('POST', '/upload/create')
const completeRoute = findRoute('POST', '/upload/complete')
const UPLOAD_ID = '33333333-3333-4333-8333-333333333333'
const TX_ID = '44444444-4444-4444-8444-444444444444'
const RAW_SIGNED_URL =
'https://proj.supabase.co/storage/v1/object/upload/sign/documents/documents/company-1/user-1/pending/x.pdf?token=signed'
function buildCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'invoice-inbox',
requestId: 'req_test',
supabase: supabase as ExtensionContext['supabase'],
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn() },
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
services: {},
} as unknown as ExtensionContext
}
function createBody(overrides: Record<string, unknown> = {}) {
return {
file_name: 'faktura.pdf',
mime_type: 'application/pdf',
size_bytes: 6 * 1024 * 1024,
...overrides,
}
}
function completeBody(overrides: Record<string, unknown> = {}) {
return {
upload_id: UPLOAD_ID,
file_name: 'faktura.pdf',
mime_type: 'application/pdf',
...overrides,
}
}
type Envelope = { error: { code: string; message: string; message_en?: string } }
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(checkInboxUploadRateLimit).mockResolvedValue({ ok: true })
vi.mocked(createPendingDocumentUpload).mockResolvedValue({
uploadId: UPLOAD_ID,
signedUrl: RAW_SIGNED_URL,
expiresAt: '2026-08-28T12:00:00.000Z',
})
})
describe('POST /upload/create (signed direct-to-storage upload)', () => {
it('returns 401 without a context', async () => {
const res = await createRoute.handler(createMockRequest('/upload/create', { method: 'POST', body: createBody() }))
expect(res.status).toBe(401)
})
it('returns 400 on an invalid body', async () => {
const mock = createQueuedMockSupabase()
const res = await createRoute.handler(
createMockRequest('/upload/create', { method: 'POST', body: { file_name: 'x.pdf' } }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<{ type: string }>(res)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
})
it('refuses a MIME type the inbox does not accept', async () => {
const mock = createQueuedMockSupabase()
const res = await createRoute.handler(
createMockRequest('/upload/create', { method: 'POST', body: createBody({ mime_type: 'text/html' }) }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('INBOX_UPLOAD_UNSUPPORTED_TYPE')
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
})
it('refuses a file over the inbox ceiling before minting a URL', async () => {
const mock = createQueuedMockSupabase()
const res = await createRoute.handler(
createMockRequest('/upload/create', { method: 'POST', body: createBody({ size_bytes: 10 * 1024 * 1024 + 1 }) }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('INBOX_UPLOAD_TOO_LARGE')
expect(body.error.message).toContain('10 MB')
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
})
it('is rate-limited like /upload and mints nothing when limited', async () => {
vi.mocked(checkInboxUploadRateLimit).mockResolvedValueOnce({
ok: false,
scope: 'minute',
retryAfterSec: 42,
})
const mock = createQueuedMockSupabase()
const res = await createRoute.handler(
createMockRequest('/upload/create', { method: 'POST', body: createBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope & { retry_after: number }>(res)
expect(status).toBe(429)
expect(res.headers.get('Retry-After')).toBe('42')
expect(body.error.code).toBe('RATE_LIMITED')
expect(checkInboxUploadRateLimit).toHaveBeenCalledWith(mock.supabase, 'company-1')
expect(createPendingDocumentUpload).not.toHaveBeenCalled()
})
it('reserves a company-scoped upload with the user client and returns the RAW signed URL', async () => {
const mock = createQueuedMockSupabase()
const res = await createRoute.handler(
createMockRequest('/upload/create', { method: 'POST', body: createBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<{
data: { upload_id: string; upload_url: string; expires_at: string }
}>(res)
expect(status).toBe(200)
expect(createPendingDocumentUpload).toHaveBeenCalledTimes(1)
const [client, companyId, userId, uploadId, fileName] =
vi.mocked(createPendingDocumentUpload).mock.calls[0]
// The user-scoped client: storage RLS decides whether this member may
// write under the company prefix. A fresh UUID per reservation.
expect(client).toBe(mock.supabase)
expect(companyId).toBe('company-1')
expect(userId).toBe('user-1')
expect(uploadId).toMatch(/^[0-9a-f-]{36}$/)
expect(fileName).toBe('faktura.pdf')
// Never the /api/storage proxy: it buffers the body in a function.
expect(body.data.upload_url).toBe(RAW_SIGNED_URL)
expect(body.data.upload_url).not.toContain('/api/storage/')
expect(body.data.upload_id).toBe(UPLOAD_ID)
expect(body.data.expires_at).toBe('2026-08-28T12:00:00.000Z')
})
it('maps a reservation failure to the Swedish registry copy, never the raw message', async () => {
vi.mocked(createPendingDocumentUpload).mockRejectedValueOnce(
new Error('Failed to create document upload URL: bucket exploded'),
)
const mock = createQueuedMockSupabase()
const res = await createRoute.handler(
createMockRequest('/upload/create', { method: 'POST', body: createBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(500)
expect(body.error.code).toBe('INBOX_UPLOAD_FAILED')
expect(body.error.message).toBe('Uppladdningen misslyckades. Försök igen.')
expect(body.error.message_en).not.toContain('bucket exploded')
})
})
describe('POST /upload/complete (signed direct-to-storage upload)', () => {
const archived = { id: UPLOAD_ID, file_name: 'faktura.pdf', mime_type: 'application/pdf' }
const pipelineResult = {
document_id: UPLOAD_ID,
inbox_item_id: 'inbox-1',
status: 'processing',
extracted_data: null,
matched_supplier_id: null,
matched_transaction_id: null,
extraction_skipped: false,
skip_reason: null,
page_count: 3,
}
it('returns 401 without a context', async () => {
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
)
expect(res.status).toBe(401)
})
it('returns 400 when upload_id is not a UUID', async () => {
const mock = createQueuedMockSupabase()
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody({ upload_id: 'not-a-uuid' }) }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<{ type: string }>(res)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
})
it('refuses a MIME type the inbox does not accept', async () => {
const mock = createQueuedMockSupabase()
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody({ mime_type: 'text/html' }) }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('INBOX_UPLOAD_UNSUPPORTED_TYPE')
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
})
it('rejects a matched_transaction_id outside the company before touching Storage', async () => {
const mock = createQueuedMockSupabase()
mock.enqueue({ data: null }) // transactions lookup: not ours
const res = await completeRoute.handler(
createMockRequest('/upload/complete', {
method: 'POST',
body: completeBody({ matched_transaction_id: TX_ID }),
}),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('INBOX_UPLOAD_TX_NOT_IN_COMPANY')
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
})
it('archives through the reservation and joins the multipart pipeline (deferred extraction)', async () => {
const buffer = new TextEncoder().encode('%PDF-1.4\n').buffer as ArrayBuffer
vi.mocked(completePendingDocumentUpload).mockResolvedValueOnce({
document: archived as never,
buffer,
})
vi.mocked(processArchivedDocument).mockResolvedValueOnce(pipelineResult as never)
const mock = createQueuedMockSupabase()
mock.enqueue({ data: { id: TX_ID } }) // transactions lookup: ours
mock.enqueue({ data: null }) // no inbox item yet for this document
const res = await completeRoute.handler(
createMockRequest('/upload/complete', {
method: 'POST',
body: completeBody({ matched_transaction_id: TX_ID, skip_extraction: true }),
}),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<{ data: typeof pipelineResult }>(res)
expect(status).toBe(200)
expect(body.data).toEqual(pipelineResult)
// The complete step never spends rate-limit quota: create already did.
expect(checkInboxUploadRateLimit).not.toHaveBeenCalled()
expect(completePendingDocumentUpload).toHaveBeenCalledWith(
mock.supabase,
'company-1',
'user-1',
UPLOAD_ID,
'faktura.pdf',
'application/pdf',
undefined,
{ extractionOwner: 'invoice-inbox', uploadSource: 'file_upload', dedupeByContent: true },
)
expect(processArchivedDocument).toHaveBeenCalledWith(
mock.supabase,
'user-1',
'company-1',
archived,
{ name: 'faktura.pdf', buffer, type: 'application/pdf' },
'upload',
undefined,
TX_ID,
{ skipExtraction: true, deferExtraction: true },
)
})
it('is idempotent: a second complete returns the inbox item that already exists', async () => {
const mock = createQueuedMockSupabase()
mock.enqueue({
data: {
id: 'inbox-1',
status: 'received',
extracted_data: { totals: { total: 125 } },
matched_supplier_id: 'sup-1',
matched_transaction_id: null,
extraction_skipped: false,
},
})
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({
document_id: UPLOAD_ID,
inbox_item_id: 'inbox-1',
status: 'received',
extracted_data: { totals: { total: 125 } },
matched_supplier_id: 'sup-1',
matched_transaction_id: null,
extraction_skipped: false,
skip_reason: null,
page_count: null,
already_completed: true,
})
expect(completePendingDocumentUpload).not.toHaveBeenCalled()
expect(processArchivedDocument).not.toHaveBeenCalled()
})
it('maps a viewer-role RLS denial on the document insert to 403 in Swedish', async () => {
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
Object.assign(
new Error(
'Failed to create document record: new row violates row-level security policy for table "document_attachments"',
),
{ code: '42501' },
),
)
const mock = createQueuedMockSupabase()
mock.enqueue({ data: null })
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(403)
expect(body.error.code).toBe('INBOX_UPLOAD_NOT_PERMITTED')
expect(body.error.message).toContain('behörighet')
expect(body.error.message).not.toContain('row-level')
expect(processArchivedDocument).not.toHaveBeenCalled()
})
it('maps an expired or never-written reservation to 404 in Swedish', async () => {
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
Object.assign(
new Error('Document upload was not found or has expired. Create a new upload URL and try again.'),
{ code: 'DOCUMENT_UPLOAD_NOT_FOUND' },
),
)
const mock = createQueuedMockSupabase()
mock.enqueue({ data: null })
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(404)
expect(body.error.code).toBe('DOCUMENT_UPLOAD_NOT_FOUND')
expect(body.error.message).toContain('Ladda upp filen igen')
})
it("answers 400 with the document service's authored Swedish sentence on a magic-byte mismatch", async () => {
const verdict =
'Filinnehållet matchar inte den angivna filtypen (förväntade application/pdf, hittade image/png).'
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
Object.assign(new Error(verdict), { code: 'DOC_UPLOAD_INVALID_CONTENT', messageSv: verdict }),
)
const mock = createQueuedMockSupabase()
mock.enqueue({ data: null })
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('DOC_UPLOAD_INVALID_CONTENT')
expect(body.error.message).toBe(verdict)
expect(body.error.message_en).toContain('could not be read')
expect(processArchivedDocument).not.toHaveBeenCalled()
})
it('answers 400 with registry copy when the PUT left an empty object', async () => {
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
Object.assign(new Error('Uploaded file is empty'), { code: 'DOC_UPLOAD_EMPTY' }),
)
const mock = createQueuedMockSupabase()
mock.enqueue({ data: null })
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('DOC_UPLOAD_EMPTY')
expect(body.error.message).toBe('Filen är tom. Ladda upp filen igen.')
})
it('replaces an internal English failure with the registry copy', async () => {
vi.mocked(completePendingDocumentUpload).mockRejectedValueOnce(
new Error('Failed to finalize document upload: move failed'),
)
const mock = createQueuedMockSupabase()
mock.enqueue({ data: null })
const res = await completeRoute.handler(
createMockRequest('/upload/complete', { method: 'POST', body: completeBody() }),
buildCtx(mock.supabase),
)
const { status, body } = await parseJsonResponse<Envelope>(res)
expect(status).toBe(500)
expect(body.error.code).toBe('INBOX_UPLOAD_FAILED')
expect(body.error.message).toBe('Uppladdningen misslyckades. Försök igen.')
expect(body.error.message_en).not.toContain('move failed')
})
})