Task #293: DB CHECK constraint for executive_meetings.row_color (defense-in-depth for #288)

Mirrors the API-side row-colour whitelist at the database layer so any
out-of-band write path (manual psql, future bulk-import jobs, restored
backups) cannot smuggle an unrenderable colour past the Zod guard
introduced in #288.

Changes:

- lib/db/src/schema/executive-meetings.ts: export new shared constant
  EXECUTIVE_MEETING_ROW_COLOR_KEYS (red/amber/green/blue/violet/gray)
  + ExecutiveMeetingRowColor union type. Add a Drizzle check()
  constraint named `executive_meetings_row_color_palette_check` that
  allows NULL or any of the six keys. The CHECK uses sql.raw to inline
  the palette as quoted SQL literals (PG CHECK definitions are DDL and
  reject parameter placeholders); safe because the keys come from a
  hardcoded compile-time constant of single-word identifiers, never
  user input. Generating the literal list from the same constant
  guarantees the API guard and the DB constraint stay in sync.

- artifacts/api-server/src/routes/executive-meetings.ts: import
  EXECUTIVE_MEETING_ROW_COLOR_KEYS from @workspace/db and rewire
  rowColorSchema to z.enum(EXECUTIVE_MEETING_ROW_COLOR_KEYS).nullable().
  Deletes the local ROW_COLOR_KEYS const so the palette has exactly
  one source of truth. No behaviour change at runtime.

- artifacts/api-server/tests/executive-meetings-row-color.test.mjs:
  append a focused defense-in-depth test that bypasses the API and
  asserts (a) NULL is allowed, (b) each of the six palette keys
  round-trips on raw UPDATE, (c) off-palette UPDATEs reject with PG
  SQLSTATE 23514 referencing the constraint by name, (d) the row
  keeps its previous colour after the rejected updates, and (e) raw
  INSERT with an off-palette value is rejected the same way.

Migration applied via pnpm --filter @workspace/db push (no destructive
prompts; existing rows are NULL or valid keys from #288 so the
constraint installs cleanly). All 7 tests in the row-color file pass.
Architect review: APPROVED, no critical findings.

The single pre-existing Reorder test failure in the wider suite is
unrelated to this change (Reorder does not touch row_color).
This commit is contained in:
riyadhafraa
2026-05-01 15:41:21 +00:00
parent 4d497606d4
commit de7973f35b
3 changed files with 142 additions and 12 deletions
@@ -369,6 +369,104 @@ test("PATCH rowColor: the realtime executive_meetings_changed event fires for th
}
});
test("DB CHECK constraint rejects off-palette rowColor on raw UPDATE that bypasses the API (#293)", async () => {
// The API's Zod whitelist is the primary guard, but #293 adds a
// matching CHECK constraint on `executive_meetings.row_color` so
// any out-of-band write path (manual psql, future bulk jobs,
// restored backups) cannot smuggle an unrenderable colour into the
// table either. This test bypasses the route entirely and writes
// straight to the DB to prove the constraint is doing its job.
const meeting = await createMeeting(
"RowColor DB check",
"قيد التحقق على مستوى قاعدة البيانات",
);
// Sanity: a NULL (default / no tint) update must still succeed —
// the constraint allows NULL to preserve the "no colour" case.
await pool.query(
`UPDATE executive_meetings SET row_color = NULL WHERE id = $1`,
[meeting.id],
);
// Each of the six palette keys must round-trip cleanly so we know
// the literal-list inside the CHECK matches the API whitelist.
for (const good of ["red", "amber", "green", "blue", "violet", "gray"]) {
await pool.query(
`UPDATE executive_meetings SET row_color = $1 WHERE id = $2`,
[good, meeting.id],
);
}
// Anything outside the palette must be rejected with PG's
// check_violation (SQLSTATE 23514) and must NOT change the stored
// colour. Cover a few realistic mistakes a future bulk-import
// script might introduce: an unsupported key, a casing slip, an
// empty string, and the magic string "default" the UI uses for
// the "no tint" radio option (which on the wire is supposed to be
// null, not the literal "default").
for (const bad of ["fuchsia", "RED", "", "default", "red ", "blue;--"]) {
await assert.rejects(
pool.query(
`UPDATE executive_meetings SET row_color = $1 WHERE id = $2`,
[bad, meeting.id],
),
(err) => {
assert.equal(
err.code,
"23514",
`expected PG check_violation for "${bad}", got ${err.code}: ${err.message}`,
);
assert.match(
err.constraint ?? "",
/executive_meetings_row_color_palette_check/,
`error should reference the row-colour palette CHECK constraint, got "${err.constraint}"`,
);
return true;
},
);
}
// Last good colour was "gray" — confirm the rejected updates did
// not silently mutate the row.
const { rows } = await pool.query(
`SELECT row_color FROM executive_meetings WHERE id = $1`,
[meeting.id],
);
assert.equal(
rows[0]?.row_color,
"gray",
"rejected updates must leave the previous colour intact",
);
// Belt-and-braces: also exercise INSERT, since a future bulk-import
// job is more likely to create rows than to UPDATE in place. The
// constraint must reject the off-palette value at row creation too,
// not only on subsequent updates.
await assert.rejects(
pool.query(
`INSERT INTO executive_meetings (title_ar, title_en, meeting_date, daily_number, row_color)
VALUES ($1, $2, $3, $4, $5)`,
[
"INSERT-check probe",
"INSERT-check probe",
today,
// High daily_number to dodge the unique (date, daily_number)
// index against meetings created earlier in this file.
99000 + Math.floor(Math.random() * 999),
"fuchsia",
],
),
(err) => {
assert.equal(err.code, "23514");
assert.match(
err.constraint ?? "",
/executive_meetings_row_color_palette_check/,
);
return true;
},
);
});
test("PATCH rowColor: each successful change writes an audit row attributed to the acting user", async () => {
const meeting = await createMeeting("RowColor audit", "سجل لون الصف");