fix(import): attach underlag by the basename of a folder-picked upload (#2288)
A folder-picked Fortnox export failed 50 of 50 attaches with UNDERLAG_REF_MISMATCH although the preview had matched every file. The preview is built from File.name, a bare filename by spec, while the attach route read the multipart filename, which Chrome fills with the folder-relative path for folder selections (2026/06/Leverantorsfakturor/A166_x.pdf). The guard that requires a file to land where the preview said compared the previewed basename with a path the parser cannot read, and refused. The route now reduces the multipart filename to its basename once, at the boundary, before the resolver check and before archiving, so the archived file_name is the name the user reviewed rather than a path. The parser keeps its no-directory-stripping rule: the manual-reference box shares it, and a typed 2024/01/31 there is a date, not voucher 31. Both separators are stripped; nothing else is normalized. Claude-Session: https://claude.ai/code/session_014uwXchJvF5YMgz8vRfuxLe Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
9618bab273
commit
a870c7f03e
@@ -198,6 +198,59 @@ describe('POST /api/import/documents/attach', () => {
|
||||
expect(uploadDocumentMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the basename when the browser sends a folder-relative multipart filename', async () => {
|
||||
// Chrome fills the multipart `filename` with webkitRelativePath for files
|
||||
// picked through a folder selection, so the server sees the Fortnox export
|
||||
// tree (<year>/<month>/<type>/<file>) while the preview, built from
|
||||
// File.name, saw only the basename. The check must run on the name the
|
||||
// user reviewed, and the archived document must carry that name, not the
|
||||
// path. Long exporter suffixes after the ref are part of the same shape.
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
fileName:
|
||||
'2026/06/Leverantörsfakturor/A31_90493_62864442_Hetzner_2026-05-13_089000921156.pdf',
|
||||
}),
|
||||
emptyParams,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const [, , , file] = uploadDocumentMock.mock.calls[0]
|
||||
expect(file).toMatchObject({
|
||||
name: 'A31_90493_62864442_Hetzner_2026-05-13_089000921156.pdf',
|
||||
})
|
||||
})
|
||||
|
||||
it('still refuses a folder-relative filename whose basename points elsewhere', async () => {
|
||||
// Normalizing the path must not loosen the guard: the basename is checked
|
||||
// exactly as a bare filename would be.
|
||||
vouchers = [{ ...VOUCHER, id: OTHER_ID }]
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ fileName: '2026/06/Leverantörsfakturor/A31_kvitto.pdf' }),
|
||||
emptyParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('UNDERLAG_REF_MISMATCH')
|
||||
expect(uploadDocumentMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches several underlag to the same verifikat, one request each', async () => {
|
||||
// Fortnox exports one file per attachment, so a verifikat with six
|
||||
// receipts is six files sharing a prefix. Each lands on the same target
|
||||
// under the same entry-scoped idempotency key; the content hash keeps
|
||||
// them apart as separate documents.
|
||||
for (const fileName of ['A31_90470_1_faktura.pdf', 'A31_90470_2_kvitto.pdf']) {
|
||||
expect((await POST(makeRequest({ fileName }), emptyParams)).status).toBe(200)
|
||||
}
|
||||
|
||||
expect(uploadDocumentMock).toHaveBeenCalledTimes(2)
|
||||
for (const call of uploadDocumentMock.mock.calls) {
|
||||
expect(call[4]).toMatchObject({ journal_entry_id: TARGET_ID, idempotency_key: TARGET_ID })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 400 when the declared fiscal year is missing or not a uuid', async () => {
|
||||
expect((await POST(makeRequest({ declaredPeriodId: null }), emptyParams)).status).toBe(400)
|
||||
expect((await POST(makeRequest({ declaredPeriodId: '2024' }), emptyParams)).status).toBe(400)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service'
|
||||
import { planPermitsAttach } from '@/lib/documents/underlag-import'
|
||||
import { uploadedFileBaseName } from '@/lib/documents/upload-file-name'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
@@ -65,6 +66,14 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('DOC_UPLOAD_NO_FILE', log, { requestId })
|
||||
}
|
||||
|
||||
// The multipart `filename` is not `File.name`. For a folder selection
|
||||
// Chrome writes the relative path (`2026/06/Leverantörsfakturor/A166_x.pdf`)
|
||||
// while the preview the user approved was built from `File.name`
|
||||
// (`A166_x.pdf`). Every use below, the resolver check and the archived
|
||||
// name alike, must see the name the user reviewed, so it is normalized
|
||||
// once, here, at the boundary.
|
||||
const fileName = uploadedFileBaseName(file.name)
|
||||
|
||||
const fields = AttachFieldsSchema.safeParse({
|
||||
journal_entry_id: formData.get('journal_entry_id'),
|
||||
fiscal_period_id: formData.get('fiscal_period_id'),
|
||||
@@ -98,7 +107,13 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
const opLog = log.child({ filename: file.name, journalEntryId })
|
||||
const opLog = log.child({
|
||||
filename: fileName,
|
||||
journalEntryId,
|
||||
// Kept only when the browser sent something else, so a refusal can be
|
||||
// read against exactly what arrived on the wire.
|
||||
...(file.name !== fileName ? { uploadedAs: file.name } : {}),
|
||||
})
|
||||
|
||||
// Tenant check first and explicitly: RLS covers the cookie session, but the
|
||||
// link is irreversible, so the route never takes the client's word for which
|
||||
@@ -156,7 +171,7 @@ export const POST = withRouteContext(
|
||||
const permitted = await planPermitsAttach(
|
||||
supabase,
|
||||
companyId!,
|
||||
file.name,
|
||||
fileName,
|
||||
journalEntryId,
|
||||
fiscalPeriodId,
|
||||
override,
|
||||
@@ -173,7 +188,7 @@ export const POST = withRouteContext(
|
||||
supabase,
|
||||
user.id,
|
||||
companyId!,
|
||||
{ name: file.name, buffer, type: file.type },
|
||||
{ name: fileName, buffer, type: file.type },
|
||||
{
|
||||
upload_source: 'file_upload',
|
||||
journal_entry_id: journalEntryId,
|
||||
|
||||
Reference in New Issue
Block a user