ab5ec2e2e2
Implement shared row highlighting for executive meetings by adding a `rowColor` field to the database schema and API, and migrating existing per-device colors to the new shared field. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 273accfc-a301-41b9-bd20-c121cb4e79c7 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/g7BgHDL Replit-Helium-Checkpoint-Created: true
353 lines
11 KiB
JavaScript
353 lines
11 KiB
JavaScript
// API contract tests for the shared row-colour overlay added by task #288
|
|
// 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";
|
|
|
|
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: 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",
|
|
);
|
|
}
|
|
});
|