Repository navigation
Description
POST /v1/instrument-records/:id/files/upload-complete returns a 500 when the payload leaves out an optional file group (one whose count.min is 0). validateFiles accepts the missing group in its count check (it counts it as 0 files), then iterates it through a non-null assertion and throws TypeError: uploadedFiles is not iterable. The payload is valid under $UploadCompleteData (uploads: z.record(z.string(), z.array($FileMetadata))), so the API should complete the upload. Instead the record stays pending even though the files are already in storage. The web client avoids this only because apps/web/src/routes/_app/instruments/render/$id.tsx (lines 74-76) pre-fills every basename with []. Any other API client that omits an empty optional group hits the crash.
Where
apps/api/src/instrument-records/files/files.service.ts:174-185
const uploadedFiles = uploads[fileGroup.basename];
const actual = uploadedFiles?.length ?? 0;
// count check passes when min === 0 and the key is absent
for (const file of uploadedFiles!) {
validatedFiles.push(file);
}Reproduce
- Create a pending record for a file instrument with a group whose
countis{ min: 0, max: 2 }(for example, basenamenotes). POST /v1/instrument-records/<id>/files/upload-completewith{ "uploads": {} }.
Actual: 500 (TypeError: uploadedFiles is not iterable), and the record stays pending.
Expected: 201, and the record is marked complete with no files for notes.
Tests
apps/api/src/instrument-records/files/__tests__/files.service.spec.ts, test should crash with a type error when an optional group is omitted, documenting a bug in validateFiles (line 322). It asserts .rejects.toThrow(TypeError). When fixing, rename it to describe the correct behaviour (for example, "should complete the upload when an optional group is omitted"). Then assert that instrumentRecordModel.update is called with files: { create: [] } and pending: false.
Suggested fix
Default the lookup to an empty array (const uploadedFiles = uploads[fileGroup.basename] ?? [];) and drop the !.
Found while raising unit test coverage in #1632.
Activity
joshunrau commentedon Oct 26, 2026
Still a bug on
main(d86740e), but the steps in the issue cannot run, and the suggested fix would not cover the input that does reproduce it.$$FileGrouphas declaredmin: z.int().positive()(packages/schemas/src/instrument/instrument.file.ts:17) since 21695b3 added the FILE kind. So a{ min: 0, max: 2 }group gets a 422 "Instrument validation failed" fromPOST /v1/instruments(apps/api/src/instruments/instruments.service.ts:102-107), and the test atapps/api/src/instrument-records/files/__tests__/files.service.test.ts:322mocks an instrument that cannot be stored.The 500 is still reachable with a valid instrument.
basenameis a barez.string()(instrument.file.ts:14), anduploadsis a plain object, so a missing key falls through toObject.prototype:{ basename: 'constructor', count: { min: 1, max: 1 } }.{ "uploads": {} }toupload-complete.uploads.constructorisObject. Itslengthof 1 passes the count check atfiles.service.ts:177, and the loop at:183throwsTypeError: uploadedFiles is not iterable. Any other function member ofObject.prototypewhoselengthfalls in the group's count range does the same, such ashasOwnProperty,isPrototypeOfor__defineGetter__.Fix
?? []does not catch this, because the inherited function is not nullish. Read own keys only, and drop the!:noUncheckedIndexedAccessis on, so anObject.hasOwnternary would still need the!. Both sides of the??are already covered: a present key by:244, a missing one by:295.:322test with aconstructorgroup{ min: 1, max: 1 }and{ uploads: {} }. Expect the 400Invalid file count for file group 'constructor': expected 1 file(s), but got '0'and noinstrumentRecordModel.updatecall.testing/has no bundler. Read theARBITRARY_SINGLE_FILEbundle the wayreadSeededBundledoes (testing/src/specs/authorization.spec.ts:278-284). Replacebasename:"file"withbasename:"constructor", andname:"ARBITRARY_SINGLE_FILE"with a name built fromuniqueId, because the id is a hash of name and edition.POST /v1/instrumentsit, then followrecord-file-deletion.spec.tswith{ uploads: {} }and assert a 400.The renderer has the same flaw.
packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx:52readsstore.errors[basename]fromerrors: {}. For a group named after anyObject.prototypemember,toStringincluded,issuesis the inherited value andissues.mapthrows on first render.store.ts:31-32(errors[basename] ??= []) would also throw on a submit with the wrong count. #1706 is already editing$$FileGroup, and rejecting those basenames there would cover the renderer too. The API lookup should not depend on that.Order with #1706
Land this first. If #1706 allows
min: 0, the original steps start to reproduce. That PR should then also rejectuploadskeys that name no group, because with an optional group a misspelled key completes the record with no files. If #1706 keepspositive(), themin: 0fixtures atfiles.service.test.ts:146,:210and:307describe instruments the schema rejects, and should be changed or removed.Related: the client-supplied
locationsetUploadCompletestores each file'slocation.basenameandlocation.indexexactly as sent (files.service.ts:110-112). Nothing checks that the basename matches the group the file is listed under. Nothing checks that the index is one of therange(count.max)slots presigned at:88, or that indexes are unique.$FileLocation.indexaccepts anyz.int()(packages/schemas/src/storage/storage.ts:6), andInstrumentRecordFileindexes onlyrecordId(apps/api/prisma/schema.prisma:206). OnARBITRARY_SINGLE_FILE,"file": [{ "location": { "basename": "file", "index": 7 }, … }]returns 201 and stores a row whose download keyfile_7was never presigned. A location basename that names no group stores a row thatfindnever returns (:57). This should be its own issue, and it should land before #1742. The fix suggested there writesstorageKeyinsetUploadComplete, and the only location available there is this client-supplied one.Triage note written with Claude Code. Code references point at
main@d86740e.