// API contract tests for the shared row-colour overlay added by // to the executive-meetings PATCH endpoint. Covers: // // 1. Setting a colour persists it on the row and round-trips back // through GET (server is the source of truth, not the browser). // 2. Setting `rowColor: null` clears the colour back to default // without disturbing other fields on the row. // 3. Unknown / off-palette colour keys are rejected with 400 and the // stored value does not change. // 4. A non-mutate user (executive_viewer role) gets 403 — they can // see the colour but cannot change it. // 5. Each successful change writes an audit-log row attributed to the // acting user, with the old + new colour captured so the audit // trail mirrors other meeting field edits. // // Cleanup runs in `after` regardless of which assertions failed so // repeated runs of this file against the same DB don't leak rows. import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; import { io as ioClient } from "socket.io-client"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run these tests"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const created = { userIds: [], meetingIds: [], }; function uniqueName(prefix) { return `${prefix}_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; } async function createUser(prefix, roleName) { const username = uniqueName(prefix); const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'EM RowColor Test', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const id = rows[0].id; created.userIds.push(id); for (const r of ["user", roleName]) { await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2 ON CONFLICT DO NOTHING`, [id, r], ); } return { id, username }; } function extractCookie(res) { const setCookie = res.headers.get("set-cookie"); if (!setCookie) return null; return ( setCookie .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")) ?? null ); } async function login(username, password) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200, `login should succeed for ${username}`); const cookie = extractCookie(res); assert.ok(cookie, "login response should set a session cookie"); return cookie; } async function api(cookie, method, path, body) { const init = { method, headers: { Cookie: cookie, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, }; if (body !== undefined) init.body = JSON.stringify(body); return fetch(`${API_BASE}${path}`, init); } let adminCookie = null; let adminUserId = null; let viewerCookie = null; const today = new Date().toISOString().slice(0, 10); async function createMeeting(titleEn, titleAr) { const res = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr, titleEn, meetingDate: today, startTime: "09:00", endTime: "10:00", platform: "none", status: "scheduled", isHighlighted: 0, attendees: [ { name: "Tester", attendanceType: "internal", sortOrder: 0 }, ], }); assert.equal(res.status, 201, "create meeting should succeed"); const meeting = await res.json(); created.meetingIds.push(meeting.id); return meeting; } before(async () => { adminCookie = await login("admin", "admin123"); // Capture the admin's user id so we can assert audit attribution. const meRes = await api(adminCookie, "GET", "/api/auth/me"); if (meRes.status === 200) { const me = await meRes.json(); adminUserId = me?.id ?? me?.user?.id ?? null; } // executive_viewer is in READ_ROLES but NOT in MUTATE_ROLES, so // requireExecutiveAccess passes and we hit requireMutate's 403. // This isolates the "non-editor" case from the bare "no executive // access at all" case (which would 403 earlier in the chain). const viewer = await createUser("em_rowcolor_viewer", "executive_viewer"); viewerCookie = await login(viewer.username, TEST_PASSWORD); }); after(async () => { if (created.meetingIds.length > 0) { await pool.query( `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [created.meetingIds], ); await pool.query( `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, [created.meetingIds], ); await pool.query( `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [created.meetingIds], ); } if (created.userIds.length > 0) { await pool.query( `DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [created.userIds], ); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ created.userIds, ]); } await pool.end(); }); test("PATCH rowColor: setting a colour persists and round-trips via GET", async () => { const meeting = await createMeeting("RowColor set", "تعيين لون الصف"); // Sanity: a fresh row has no colour set. assert.equal( meeting.rowColor ?? null, null, "fresh meeting should default to no colour", ); const patch = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: "red" }, ); assert.equal(patch.status, 200, "PATCH rowColor should succeed"); const patched = await patch.json(); assert.equal(patched.rowColor, "red", "PATCH response includes new colour"); // The day GET is what every other client uses to refresh after the // socket fires, so it MUST surface the new colour. This is the // assertion that proves the colour is shared with other viewers. const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${today}`, ); assert.equal(day.status, 200); const body = await day.json(); const row = body.meetings.find((m) => m.id === meeting.id); assert.ok(row, "patched meeting should appear in GET ?date="); assert.equal(row.rowColor, "red", "GET reflects the persisted colour"); }); test("PATCH rowColor: { rowColor: null } clears the colour without touching other fields", async () => { const meeting = await createMeeting("RowColor clear", "مسح لون الصف"); // Seed with a colour so we have something to clear. const seed = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: "blue" }, ); assert.equal(seed.status, 200); const clear = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: null }, ); assert.equal(clear.status, 200, "clearing rowColor should succeed"); const cleared = await clear.json(); assert.equal(cleared.rowColor, null, "rowColor is null after clear"); // Other fields stay intact through the clear — guards against a // future regression where the PATCH handler accidentally overwrites // unrelated columns when only rowColor is sent. assert.equal(cleared.titleEn, "RowColor clear"); assert.equal(cleared.startTime, "09:00:00"); assert.equal(cleared.endTime, "10:00:00"); }); test("PATCH rowColor: unknown / off-palette colour key is rejected with 400 and does not change the row", async () => { const meeting = await createMeeting("RowColor invalid", "لون غير صالح"); // Seed with a known good colour so we can prove the bad request // didn't silently overwrite it. const seed = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: "green" }, ); assert.equal(seed.status, 200); for (const bad of ["fuchsia", "RED", "", "default", " red "]) { const res = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: bad }, ); assert.equal( res.status, 400, `unknown rowColor key "${bad}" should be rejected with 400`, ); const body = await res.json(); assert.equal(body.code, "validation"); } // Confirm the row still has the seeded colour, not anything else. const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${today}`, ); const row = (await day.json()).meetings.find((m) => m.id === meeting.id); assert.equal(row.rowColor, "green", "row colour unchanged after 400s"); }); test("PATCH rowColor: non-mutate viewer role gets 403 and the row colour is unchanged", async () => { const meeting = await createMeeting( "RowColor viewer guard", "حماية المشاهد من تغيير اللون", ); // Admin sets a colour first so the viewer's failed PATCH would be // visible if the guard were missing. const seed = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: "amber" }, ); assert.equal(seed.status, 200); const blocked = await api( viewerCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: "violet" }, ); assert.equal(blocked.status, 403, "viewer role should be blocked"); // Viewer can still GET — they need to *see* the colour, just not change it. const day = await api( viewerCookie, "GET", `/api/executive-meetings?date=${today}`, ); assert.equal(day.status, 200); const row = (await day.json()).meetings.find((m) => m.id === meeting.id); assert.equal( row.rowColor, "amber", "viewer's blocked PATCH did not change the persisted colour", ); }); test("PATCH rowColor: the realtime executive_meetings_changed event fires for the affected date so other tabs / devices know to re-fetch", async () => { // This is the contract that makes "shared" actually feel realtime — // without it, a second viewer would only see the colour change after // they manually refreshed. The frontend listens for this exact event // (see use-notifications-socket.ts) and invalidates the day query. const meeting = await createMeeting( "RowColor socket", "بث اللون عبر السوكيت", ); const events = []; const socket = ioClient(API_BASE, { path: "/api/socket.io", transports: ["websocket"], forceNew: true, reconnection: false, extraHeaders: { Cookie: adminCookie }, }); await new Promise((resolve, reject) => { socket.on("executive_meetings_changed", (payload) => { events.push(payload); }); socket.on("connect", resolve); socket.on("connect_error", reject); }); try { const res = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: "gray" }, ); assert.equal(res.status, 200); // Give the broadcast a moment to land. 400ms is plenty for a // localhost socket and well under the test timeout. const start = Date.now(); while (events.length === 0 && Date.now() - start < 400) { await new Promise((r) => setTimeout(r, 25)); } assert.ok( events.length > 0, "executive_meetings_changed should fire after a rowColor PATCH", ); // Some payloads also include an array of dates; we just need the // current day to be addressed in any of them. const sawToday = events.some((p) => { if (!p) return false; if (typeof p.date === "string" && p.date === today) return true; if (Array.isArray(p.dates) && p.dates.includes(today)) return true; return false; }); assert.ok( sawToday, `event should reference today's date (${today}); got ${JSON.stringify(events)}`, ); } finally { socket.disconnect(); } }); 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", "سجل لون الصف"); const set = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { rowColor: "violet" }, ); assert.equal(set.status, 200); // The audit log is read by the admin section. Pull all entries for // this meeting and assert that the most recent one captures the // colour change. We don't strictly enforce ordering of unrelated // create / patch entries — we just need a row whose newValue.rowColor // is "violet" and whose performedBy is the admin. const { rows: auditRows } = await pool.query( `SELECT action, entity_type, entity_id, new_value, performed_by FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = $1 ORDER BY id DESC`, [meeting.id], ); const colorEntry = auditRows.find((r) => { const nv = r.new_value; return ( nv && typeof nv === "object" && "rowColor" in nv && nv.rowColor === "violet" ); }); assert.ok( colorEntry, "audit log should contain an entry whose newValue.rowColor is the colour we just set", ); if (adminUserId != null) { assert.equal( colorEntry.performed_by, adminUserId, "audit row attributed to the admin who made the change", ); } });