fix(executive-meetings): lock down attendee save payload (#221)
Why: Task #220 added a client-only `_sid` field on attendee rows so React DnD can identify rows. The two client save sites already enumerate wire fields explicitly and never serialize `_sid`, but nothing prevents a future refactor from accidentally leaking it. The server was zod-default lenient (silently strips unknowns), so a regression would either be silently absorbed (bad — silent contract drift) or land in a future JSONB metadata column without anyone noticing. What changed: - `attendeeSchema` in artifacts/api-server/src/routes/executive-meetings.ts is now `.strict()`. Any unknown attendee key (including `_sid` or any future client-only field) is rejected with HTTP 400 instead of being silently stripped. The schema is reused by all three attendee-bearing endpoints (POST /executive-meetings, PATCH /executive-meetings/:id, PUT /executive-meetings/:id/attendees), so all three are covered by one change. Tests: - Added three API tests in artifacts/api-server/tests/executive-meetings.test.mjs: 1. POST /executive-meetings with attendee carrying `_sid` returns 400 and the error mentions the rejected key. 2. PATCH /executive-meetings/:id with attendees carrying `_sid` returns 400 AND the meeting's existing attendee list is preserved (no partial mutation). 3. PUT /executive-meetings/:id/attendees with attendee carrying `_sid` returns 400 AND the seeded attendee is unchanged. Verification: - Full API test suite: 207/207 green (was 204/204 before; +3 new). - No client-side change needed: existing `saveAttendeeName` (~L943 in artifacts/tx-os/src/pages/executive-meetings.tsx) and the manage-dialog save (~L4004) already project to the documented wire shape (`name, title, attendanceType, sortOrder, kind`). - Architect review: addressed the one gap (PATCH coverage) by adding test #2 above; verdict resolved. Out of scope cleanup: - Marked the descriptions of stale tasks #172 and #179 as STALE (both PDF tests pass and PDF export works in current main). Final cancellation left to the user.
This commit is contained in:
@@ -264,6 +264,129 @@ test("Meetings: PUT /attendees replaces the attendee list", async () => {
|
||||
assert.ok(!body.attendees.find((a) => a.name === "Old One"));
|
||||
});
|
||||
|
||||
// --- Attendee payload contract (Task #221) ---------------------------------
|
||||
// Server is `.strict()` on the attendee schema so client-only fields like
|
||||
// `_sid` (the React row id used by the drag-and-drop editor) are rejected
|
||||
// loudly with 400 instead of being silently stripped. This locks down the
|
||||
// wire contract so a future client refactor that accidentally serializes
|
||||
// `_sid` (or any other internal field) breaks tests immediately rather
|
||||
// than being absorbed by lenient parsing.
|
||||
test("Attendee payload contract: POST rejects unknown fields like `_sid`", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "س",
|
||||
titleEn: "S",
|
||||
meetingDate: today,
|
||||
attendees: [
|
||||
{
|
||||
name: "Leaky One",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
_sid: "client-only-row-id",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(
|
||||
create.status,
|
||||
400,
|
||||
`expected 400 when attendee carries _sid, got ${create.status}`,
|
||||
);
|
||||
const body = await create.json();
|
||||
// Zod surfaces the rejected key in the error path.
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.ok(
|
||||
serialized.includes("_sid") || serialized.toLowerCase().includes("unrecognized"),
|
||||
`expected error to mention the rejected key, got: ${serialized}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("Attendee payload contract: PATCH /:id rejects unknown attendee fields like `_sid`", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ت",
|
||||
titleEn: "T",
|
||||
meetingDate: today,
|
||||
attendees: [{ name: "Patch Seed", attendanceType: "internal", sortOrder: 0 }],
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const meeting = await create.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
const patch = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{
|
||||
attendees: [
|
||||
{
|
||||
name: "Leaky Three",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
_sid: "client-only-row-id",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
patch.status,
|
||||
400,
|
||||
`expected 400 when PATCH attendee carries _sid, got ${patch.status}`,
|
||||
);
|
||||
|
||||
// The seed attendee must still be the only one stored — the rejected
|
||||
// PATCH must not have replaced the attendee list.
|
||||
const fetched = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
);
|
||||
const body = await fetched.json();
|
||||
assert.equal(body.attendees.length, 1);
|
||||
assert.equal(body.attendees[0].name, "Patch Seed");
|
||||
});
|
||||
|
||||
test("Attendee payload contract: PUT /attendees rejects unknown fields like `_sid`", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ش",
|
||||
titleEn: "Sh",
|
||||
meetingDate: today,
|
||||
attendees: [{ name: "Seed", attendanceType: "internal", sortOrder: 0 }],
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const meeting = await create.json();
|
||||
created.meetingIds.push(meeting.id);
|
||||
|
||||
const replace = await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
`/api/executive-meetings/${meeting.id}/attendees`,
|
||||
{
|
||||
attendees: [
|
||||
{
|
||||
name: "Leaky Two",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
_sid: "client-only-row-id",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
replace.status,
|
||||
400,
|
||||
`expected 400 when PUT attendee carries _sid, got ${replace.status}`,
|
||||
);
|
||||
|
||||
// And the meeting's attendee list must not have changed (the seed row
|
||||
// still wins, the leaky row never persists).
|
||||
const fetched = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
);
|
||||
const body = await fetched.json();
|
||||
assert.equal(body.attendees.length, 1);
|
||||
assert.equal(body.attendees[0].name, "Seed");
|
||||
});
|
||||
|
||||
test("Meetings: POST /duplicate clones a meeting onto another date", async () => {
|
||||
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ب",
|
||||
|
||||
Reference in New Issue
Block a user