Skip to content

Commit e2b40a2

Browse files
fix(rest)!: an anonymous public-form submission carries no file (#25682)
Fixes #25622 Fixes #25572 Clause-②: no (narrowing) Metadata-protocol: none ## What this changes Ruling C on #25572 (the maintainer, verbatim: 「25619 C 25495 B」; the ruling record is on PR #25619): **an anonymous public-form submission carries no file.** - **The submit door** (`@objectstack/rest`). `POST /forms/:slug/submit` refuses a value in a field whose declared type is a file type (`FILE_REFERENCE_TYPES`: `file`, `image`, `avatar`, `video`, `audio`). It names the field and refuses before anything is written. The answer is the door's existing envelope, `400 VALIDATION_FAILED`, with one `fields[]` entry per file field: the ADR-0114 field code `invalid_value` and the field's label. If the same submission also leaves a `required` field empty, both are reported in one envelope. An empty or absent file field submits as before. Only the fields the form declares are judged, so the refusal names nothing that `GET /forms/:slug` does not already publish. No error code is added. - **The file-reference claim path** (`@objectstack/service-storage`) now looks a referenced file up within the organization the write acts in. It uses the channel that already stamps a copied file for that organization, so the driver's tenant scope for the posture in force decides what the lookup sees. A referenced file id outside that scope is kept as written and is neither claimed nor copied, exactly as an id the platform does not manage. The module writes no organization comparison of its own. A write that carries no organization, and the `single` posture, look files up as before. Not in this PR: the publish-time refusal of a guest-facing form that declares a file field (#25650, `domain:spec`), and any guest upload door (ruled out by C). ## Declarations - `Clause-②: no (narrowing)`. The door refuses a request shape it accepted, and the claim path reaches fewer files. Nothing is widened. Both claims carry `Clause-②: no` with no arm; the dispatch asked for the arm to be judged, and AGENTS.md's Post-Task Checklist step 3 names `(narrowing)` for this case, so the arm is added. - `Metadata-protocol: none`. Nothing a metadata author writes changes. - One `patch` changeset on both packages, `fix(rest)!:`, with the BREAKING section, the migration line and the ADR-0087 disposition `not-required (no-migration-prescription)`. `check-adr-0087-registration` reads it as `[BREAKING+bang+clause-②-narrowing]` and passes. ## Composition with #25641 #25641 (`domain:spec`, ADR-0138 D5) also edits `packages/rest/src/form-routes.ts`, in `resolveFormBySlug`, the submit door's context literal and the imports. This PR's edit sits in the submit door's `required` judgement, below both hunks, with unchanged lines between them. A local `git merge-tree --write-tree` of this branch with #25641's head `08b020c9d3` exits 0 with no conflicted path (`form-routes.ts` is not a merge-driver path, so the local answer is GitHub's). Whichever PR lands second merges `main`. ## One existing pin changed, with its subject kept `public-form-intake-availability.test.ts`, "single posture, walled object — accepted, and the object is not even read for the predicate", asserted that the whole submit read only `view` metadata. The submit now reads the object's declarations once whenever a declared field carries a value, as the `required` judgement already did for forms with required entries. The pin now asserts `['view', 'object']`: exactly one object read, by the field judgement. A predicate that read the object on this posture would be a second read and would still turn it red. ## Tests All on head `80aab79ea2`, each package's exit code captured separately: - `pnpm --filter @objectstack/rest test`: Test Files 321 passed (321), Tests 6417 passed, 326 skipped. `pnpm --filter @objectstack/rest typecheck`: exit 0. The test-layer program (`tsconfig.test.json`, counted with `--listFiles`) compiles both test files this PR touches. - `pnpm --filter @objectstack/service-storage test`: Test Files 63 passed (63), Tests 1137 passed. `typecheck`: exit 0, and the new test file is in its test-layer program. - New suites: `packages/rest/src/public-form-file-field.test.ts` has 11 tests: the refusal by value shape (a file id, an external URL, an inline file value), a multi-valued field, two fields at once, one envelope with `required`, four empty/absent controls and the undeclared-field boundary. `packages/services/service-storage/src/file-reference-organization-scope.test.ts` has 6 tests on a real `ObjectQL` and `SqlDriver` under the `isolated` posture with the hooks installed as the plugin installs them: insert and update in one organization, two controls in the writing organization (claim, and a copy stamped for it), and the `single` posture. The refusal pins assert `code` and `status` (`VALIDATION_FAILED`, `400`) plus each entry's field, code and label. Message wording is not pinned; each message is only required to name its label. - `eslint --no-inline-config` with the root config over `packages/rest/src` and `packages/services/service-storage/src`: exit 0. - Full dogfood battery (owed for the `(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` under the shared lock with no shard and no path: it was running when this PR opened, and its counts are in the `os-dev-report` comment on #25622. ## Proof Each leg mutated through `node scripts/ablation-replace.mjs`, which verifies on disk that the anchor hit, the blob changed and the restore matched the HEAD blob. Both subjects resolve through relative `src` imports in their suites, so no `dist/` is involved. - **The door.** With the file-type judgement replaced by `continue`, the 6 refusal pins of `public-form-file-field.test.ts` redden (the door answers `201`), and its 5 control and boundary pins hold. Restored: the blob `e6855e87be17` equals HEAD's and `git diff HEAD` is empty. - **The claim path, the lookup before the write.** With this change's context for that lookup undone, exactly 1 of the 6 organization-scope pins reddens (the copy pin). Restored: the blob equals HEAD's. - **The claim path, the claim's own lookup.** With this change's context undone there, exactly 2 of the 6 redden (the insert and update claim pins). Restored: the blob equals HEAD's. - The two controls and the `single`-posture pin held in every leg. ## Gates - Derived on `80aab79ea2` by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; the change set comes from the merge base): 73 commands, each exit code captured before any pipe. 72 exited 0 in sequence. `pnpm check:type-check-debt` first exited 3 (its own closure build never got a turn on the shared verify lock, so nothing was measured), and its rerun under the lock exited 0 with no entry above its recorded number. `--ran`: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. `check-adr-0087-registration` reads the changeset as `[BREAKING+bang+clause-②-narrowing]` with an accepted disposition, and `check-changeset-no-major` reports no major bump. - `pnpm lint` (the root `eslint . --no-inline-config`) is CI's run. Locally it was run over the two touched packages only (above). - The tree note the derivation prints: `80aab79ea2` merged `origin/main` at `9dac9eb8d6`. Since then `main` has gained refactor moves in `objectql`, `driver-sql` and `metadata-protocol` and the template pins, and one derivation input (`scripts/sync-template-versions.mjs`) changed upstream. The derived list did not change between the two merges. CI reads the PR's merge ref. - CI check-runs had not concluded when this PR opened; convergence is read by the seat. ## Acceptance notes - `GET /forms/:slug` still serves a declared file field in the form's sections, so a renderer may show an upload control that a visitor cannot use. That belongs to the publish half (#25650), which refuses such a form at publish. It is not changed here. - The refusal message is inline English beside the `required` message, which the door renders from the built-in catalog without a locale. No catalog key is added. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e44a1ff commit e2b40a2

6 files changed

Lines changed: 529 additions & 16 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/service-storage': patch
4+
---
5+
6+
fix(rest)!: an anonymous public-form submission carries no file
7+
8+
`POST /api/v1/forms/:slug/submit` (and its environment-scoped twin) now refuses a submission that carries a value in a field whose declared type is a file type (`file`, `image`, `avatar`, `video` or `audio`, the spec's `FILE_REFERENCE_TYPES`). The refusal comes before anything is written. It is the door's existing envelope, `400 VALIDATION_FAILED`, with one `fields[]` entry per file field, coded `invalid_value` (the ADR-0114 field catalog) and naming the field by its label. A missing `required` field in the same submission is reported in the same envelope. An empty or absent file field submits exactly as before. Only the fields the form declares are judged, so the refusal names nothing the form does not already publish.
9+
10+
This is the server half of the rule "an anonymous public-form submission carries no file". A visitor who needs to provide a file registers and signs in, then submits through a signed-in surface that uploads through the authenticated upload doors. The anonymous door has no upload door of its own, and none is added. The protocol half, refusing at publish a guest-facing form that declares a file field, is separate. This door also judges forms published before that refusal exists.
11+
12+
`@objectstack/service-storage`: the file-reference claim path now looks a referenced file up within the organization the write acts in. It uses the same channel that already stamps a copied file for that organization, so the driver's tenant scope for the posture in force decides what the lookup sees. A referenced file id outside that scope is kept as written and is neither claimed nor copied, exactly as an id the platform does not manage. A write that carries no organization, and the `single` posture, look files up as before.
13+
14+
Clause-②: no (narrowing) — the anonymous public-form submit door refuses a request shape it used to accept (a value in a declared file field), and the file-reference claim path reaches only files within the writing organization's tenant scope. Nothing is widened.
15+
16+
Metadata-protocol: none (a request's handling at an anonymous door and a write path's file bookkeeping; nothing a metadata author writes changes. The publish-time refusal of a guest-facing form with a file field is the protocol half, and ships separately)
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is how the anonymous public-form submit door handles a request body (a value in a declared file field is refused) and which files the file-reference claim path can reach for a write (those within the writing organization's tenant scope). The remedy is a change of how a visitor submits (register and sign in to provide a file), never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
19+
20+
**BREAKING** for a public form that collected files anonymously, shipped as `patch` because the metadata protocol does not move.
21+
22+
- **Before.** A value in a file field the form declares was written with the anonymous submission.
23+
- **Now.** That submission is refused with `400 VALIDATION_FAILED`, naming each file field that carries a value. A submission that leaves every file field empty is accepted as before.
24+
- **Fix.** A public form that collected files: have the submitter register and sign in, then provide the file from their account. Remove the file field from the guest-facing form, or leave it for the visitor to leave empty.

‎packages/rest/src/form-routes.ts‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import type { CreateDataRequest, GetMetaItemsRequest } from '@objectstack/spec/a
7777
import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security';
7878
import { renderValidationMessage } from '@objectstack/spec/system';
7979
import { validationFailure } from '@objectstack/types';
80+
import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data';
8081
import type { ServerScopedDataRequest } from './data-action-routes.js';
8182
import { isExpectedRouteError, mapDataError } from './error-response.js';
8283
import { logError } from './log.js';
@@ -595,10 +596,40 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
595596
const judged = requiredFields.filter((name) =>
596597
!PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)
597598
&& name !== '__proto__' && name !== 'constructor' && name !== 'prototype');
598-
if (judged.length > 0) {
599+
// [#25572, ruling C (record 6384525042)] An anonymous public-form
600+
// submission carries no file. A value in a field whose declared
601+
// type is a file type (`FILE_REFERENCE_TYPES`) is refused, naming
602+
// the field, before anything is written; an empty or absent file
603+
// field submits as before. A file is provided by a signed-in
604+
// caller, through the authenticated upload doors; this door has
605+
// none, by that ruling, and a guest-facing form that declares a
606+
// file field is refused at publish (the protocol half, #25650).
607+
// Forms published before that refusal existed are why the door
608+
// judges it too.
609+
//
610+
// - Judged on what this door WRITES (`filteredData`): only the
611+
// fields the form declares, which `GET /forms/:slug` already
612+
// publishes, so the refusal names nothing the form does not.
613+
// - The same emptiness rule as `required` (`isEmptyForRequired`).
614+
// - ONE envelope with the `required` judgement above: both are
615+
// collected into one `validationFailure`, the flat
616+
// `400 VALIDATION_FAILED` with `fields[]`, so a submission with
617+
// both problems is told both at once. The field code is the
618+
// closed ADR-0114 catalog's `invalid_value`.
619+
// The declared fields this submission carries a value in: the
620+
// only ones the file judgement can refuse, so a submission that
621+
// carries none reads no object declarations for it.
622+
const fileValued = [...allowedFields].filter((name) =>
623+
Object.prototype.hasOwnProperty.call(filteredData, name)
624+
&& !isMissing(filteredData[name]));
625+
if (judged.length > 0 || fileValued.length > 0) {
599626
const objectDef = (await this.readFormObjectDefinitions(p, environmentId))
600627
.find((o: any) => o?.name === match.object);
601-
const missing: Array<{ field: string; code: 'required'; message: string; label: string }> = [];
628+
const labelOf = (def: any, name: string): string => {
629+
const declared = typeof def?.label === 'string' ? def.label.trim() : '';
630+
return declared.length > 0 ? declared : name;
631+
};
632+
const refused: Array<{ field: string; code: 'required' | 'invalid_value'; message: string; label: string }> = [];
602633
for (const name of judged) {
603634
const def = objectDef?.fields?.[name];
604635
const value = Object.prototype.hasOwnProperty.call(filteredData, name)
@@ -608,17 +639,29 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
608639
? isEmptyForRequired(def, value)
609640
: isMissing(value);
610641
if (!empty) continue;
611-
const declared = typeof def?.label === 'string' ? def.label.trim() : '';
612-
const label = declared.length > 0 ? declared : name;
613-
missing.push({
642+
const label = labelOf(def, name);
643+
refused.push({
614644
field: name,
615645
code: 'required',
616646
message: renderValidationMessage({ messageKey: 'required', label, field: name, params: {} }),
617647
label,
618648
});
619649
}
620-
if (missing.length > 0) {
621-
throw validationFailure(missing.map((f) => f.message).join('; '), missing);
650+
for (const name of fileValued) {
651+
const def = objectDef?.fields?.[name];
652+
if (!def || typeof def.type !== 'string' || !FILE_REFERENCE_TYPES.has(def.type)) continue;
653+
if (isEmptyForRequired(def, filteredData[name])) continue;
654+
const label = labelOf(def, name);
655+
refused.push({
656+
field: name,
657+
code: 'invalid_value',
658+
message: `${label} cannot take a file here: an anonymous public-form submission carries no file. `
659+
+ 'Leave it empty, or sign in and provide the file from your account.',
660+
label,
661+
});
662+
}
663+
if (refused.length > 0) {
664+
throw validationFailure(refused.map((f) => f.message).join('; '), refused);
622665
}
623666
}
624667

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25572, ruling C] An anonymous public-form submission carries no file.
5+
*
6+
* `POST /forms/:slug/submit` refuses a value in a field whose declared type is
7+
* a file type (`FILE_REFERENCE_TYPES`), naming the field, before anything is
8+
* written. An empty or absent file field submits as before. A file is provided
9+
* by a signed-in caller through the authenticated upload doors; a guest-facing
10+
* form that declares a file field is refused at publish (the protocol half),
11+
* and this door judges the forms published before that refusal existed.
12+
*
13+
* Through the real route — `RestServer` → `ObjectStackProtocolImplementation`
14+
* → `ObjectQL` → `SqlDriver` (sqlite in memory). Only the metadata read is
15+
* stubbed, to serve the form and the object the route resolves.
16+
*
17+
* Pinned, by class:
18+
* - a value in a declared file field, in any shape, is refused: `400`, the
19+
* flat `VALIDATION_FAILED` envelope naming the field with the ADR-0114
20+
* field code `invalid_value`, the write never attempted and no row stored;
21+
* - a multi-valued file field, and two file fields at once (one refusal, the
22+
* form's order);
23+
* - one envelope with the form's `required` judgement: both are told at once;
24+
* - control: an empty or absent file field submits, `201`, the row stored;
25+
* - the boundary: a file field the form does not declare is judged by the
26+
* door's allow-list as before (dropped, never refused), so the refusal
27+
* names nothing the form does not publish.
28+
*/
29+
30+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
31+
import { ObjectQL } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
34+
import { RestServer } from './rest-server';
35+
import { installDefaultOrganization } from './default-organization.testkit.js';
36+
37+
const OBJECT = 'pf_file_supplier_25572';
38+
const SLUG = 'pf-file-25572';
39+
const SYS = { context: { isSystem: true } };
40+
41+
const SUPPLIER = {
42+
name: OBJECT,
43+
label: 'Supplier',
44+
fields: {
45+
name: { name: 'name', type: 'text' as const, label: 'Supplier Name', required: true },
46+
email: { name: 'email', type: 'email' as const, label: 'Email' },
47+
licence: { name: 'licence', type: 'file' as const, label: 'Business Licence' },
48+
photos: { name: 'photos', type: 'image' as const, label: 'Site Photos', multiple: true },
49+
note: { name: 'note', type: 'text' as const, label: 'Note' },
50+
// Declared on the object, NOT on the form.
51+
internal_scan: { name: 'internal_scan', type: 'file' as const, label: 'Internal Scan' },
52+
},
53+
};
54+
55+
const FORM = {
56+
name: 'pf_file_supplier_form',
57+
object: OBJECT,
58+
viewKind: 'form',
59+
config: {
60+
type: 'simple',
61+
data: { provider: 'object', object: OBJECT },
62+
sections: [
63+
{
64+
name: 'supplier',
65+
fields: [
66+
{ field: 'name' },
67+
{ field: 'email', required: true },
68+
{ field: 'licence' },
69+
{ field: 'photos' },
70+
{ field: 'note' },
71+
],
72+
},
73+
],
74+
sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` },
75+
},
76+
};
77+
78+
const COMPLETE = { name: 'Acme Parts', email: 'ops@acme.test', note: 'first contact' };
79+
80+
function mockServer() {
81+
const noop = () => {};
82+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
83+
}
84+
85+
function makeRes() {
86+
const res: any = {
87+
header: () => res,
88+
end: () => res,
89+
status: (code: number) => { res._status = code; return res; },
90+
json: (body: unknown) => { res._json = body; return res; },
91+
};
92+
return res;
93+
}
94+
95+
interface Answer { status: number; body: any }
96+
97+
describe('[#25572] POST /forms/:slug/submit: an anonymous submission carries no file', () => {
98+
let engine: ObjectQL;
99+
let createData: ReturnType<typeof vi.fn>;
100+
let post: (body: Record<string, unknown>) => Promise<Answer>;
101+
const rows = async (): Promise<Array<Record<string, any>>> => (await engine.find(OBJECT, {}, SYS)) as any;
102+
103+
beforeAll(async () => {
104+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any);
105+
engine = new ObjectQL();
106+
engine.registerDriver(driver, true);
107+
await engine.init();
108+
engine.registry.registerObject(SUPPLIER as any);
109+
await engine.syncSchemas();
110+
await installDefaultOrganization(engine);
111+
112+
const protocol: any = new ObjectStackProtocolImplementation(engine as any);
113+
const realGetMetaItems = protocol.getMetaItems.bind(protocol);
114+
protocol.getMetaItems = async (request: any) => {
115+
if (request?.type === 'view') return [FORM];
116+
if (request?.type === 'object') return [SUPPLIER];
117+
return realGetMetaItems(request);
118+
};
119+
const realCreateData = protocol.createData.bind(protocol);
120+
createData = vi.fn(realCreateData);
121+
protocol.createData = createData;
122+
const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any);
123+
rest.registerRoutes();
124+
const submit = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/forms/:slug/submit');
125+
expect(submit, 'the submit route is registered').toBeDefined();
126+
post = async (body) => {
127+
const res = makeRes();
128+
await submit!.handler({ params: { slug: SLUG }, body: JSON.parse(JSON.stringify(body)) } as any, res);
129+
return { status: res._status ?? 200, body: res._json };
130+
};
131+
}, 60_000);
132+
133+
afterAll(async () => {
134+
await engine?.destroy().catch(() => {});
135+
});
136+
137+
/** A refusal naming exactly `expected` (field, code) in order, the write never attempted, no row stored. */
138+
const expectRefused = async (body: Record<string, unknown>, expected: Array<[string, string, string]>) => {
139+
const before = (await rows()).length;
140+
const calls = createData.mock.calls.length;
141+
const answer = await post(body);
142+
expect(answer.status, JSON.stringify(answer.body)).toBe(400);
143+
expect(answer.body.code).toBe('VALIDATION_FAILED');
144+
expect(answer.body.fields.map((f: any) => [f.field, f.code, f.label])).toEqual(expected);
145+
for (const f of answer.body.fields) expect(f.message).toContain(f.label);
146+
expect(createData.mock.calls.length, 'the write is never attempted').toBe(calls);
147+
expect((await rows()).length, 'a refused submit stores no row').toBe(before);
148+
return answer;
149+
};
150+
151+
it.each([
152+
['a file id', '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c'],
153+
['an external URL', 'https://files.example.test/licence.pdf'],
154+
['an inline file value', { url: 'https://files.example.test/licence.pdf', name: 'licence.pdf' }],
155+
])('an anonymous submission refuses a value in a file field (%s), naming the field', async (_shape, value) => {
156+
await expectRefused({ ...COMPLETE, licence: value }, [['licence', 'invalid_value', 'Business Licence']]);
157+
}, 60_000);
158+
159+
it('a multi-valued file field carrying a value is refused, naming it', async () => {
160+
await expectRefused({ ...COMPLETE, photos: ['8d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d'] }, [['photos', 'invalid_value', 'Site Photos']]);
161+
}, 60_000);
162+
163+
it('two file fields carrying values → one refusal naming both, in the form\'s order', async () => {
164+
await expectRefused(
165+
{ ...COMPLETE, photos: ['8d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d'], licence: '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c' },
166+
[['licence', 'invalid_value', 'Business Licence'], ['photos', 'invalid_value', 'Site Photos']],
167+
);
168+
}, 60_000);
169+
170+
it('one envelope with the form\'s `required`: a missing required field and a file value are told at once', async () => {
171+
const { email: _omitted, ...withoutEmail } = COMPLETE;
172+
await expectRefused(
173+
{ ...withoutEmail, licence: '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c' },
174+
[['email', 'required', 'Email'], ['licence', 'invalid_value', 'Business Licence']],
175+
);
176+
}, 60_000);
177+
178+
it.each([
179+
['absent', {}],
180+
['null', { licence: null, photos: null }],
181+
["''", { licence: '', photos: '' }],
182+
['[] on the multi-valued field', { photos: [] }],
183+
])('CONTROL — an empty or absent file field submits (%s) → 201, the row stored', async (_cell, extra) => {
184+
const before = (await rows()).length;
185+
const answer = await post({ ...COMPLETE, ...extra });
186+
expect(answer.status, JSON.stringify(answer.body)).toBe(201);
187+
expect(typeof answer.body.id).toBe('string');
188+
const all = await rows();
189+
expect(all.length).toBe(before + 1);
190+
const stored = all.find((r) => r.id === answer.body.id);
191+
expect(stored?.name).toBe(COMPLETE.name);
192+
expect([null, undefined, '']).toContain(stored?.licence);
193+
}, 60_000);
194+
195+
it('BOUNDARY — a file field the form does not declare is dropped by the allow-list as before, never refused', async () => {
196+
const answer = await post({ ...COMPLETE, internal_scan: '3f2a9c1e-7b4d-4e1a-9c2f-0d1e2f3a4b5c' });
197+
expect(answer.status, JSON.stringify(answer.body)).toBe(201);
198+
const stored = (await rows()).find((r) => r.id === answer.body.id);
199+
expect(stored?.internal_scan ?? null).toBeNull();
200+
}, 60_000);
201+
});

0 commit comments

Comments
 (0)