Files
TX/artifacts/api-server/tests/executive-meetings.test.mjs
T

1877 lines
67 KiB
JavaScript
Raw Normal View History

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_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const TEST_PASSWORD = "TestPass123!";
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 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 coordCookie = null;
let coordUserId = null;
let leadCookie = null;
let leadUserId = null;
before(async () => {
adminCookie = await login("admin", "admin123");
const meRes = await api(adminCookie, "GET", "/api/auth/me");
const me = await meRes.json();
adminUserId = me.id ?? me.userId;
const coord = await createUser("em_coord", "executive_coordinator");
coordUserId = coord.id;
coordCookie = await login(coord.username, TEST_PASSWORD);
const lead = await createUser("em_lead", "executive_coord_lead");
leadUserId = lead.id;
leadCookie = await login(lead.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_id = ANY($1::int[]) AND entity_type = 'meeting'`, [
created.meetingIds,
]);
await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
created.meetingIds,
]);
}
if (created.userIds.length > 0) {
// Notification prefs are owned by users with ON DELETE CASCADE,
// but be explicit so we don't rely on the cascade if the FK is ever
// changed.
await pool.query(
`DELETE FROM executive_meeting_notification_prefs WHERE user_id = ANY($1::int[])`,
[created.userIds],
);
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();
});
const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
test("GET /me exposes the surviving capability flags", async () => {
const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
assert.equal(res.status, 200);
const body = await res.json();
for (const key of [
"userId",
"roles",
"canRead",
"canMutate",
"canEditGlobalFontSettings",
"canViewAudit",
]) {
assert.ok(key in body, `missing ${key} in /me response`);
}
for (const removed of [
"canApprove",
"canSubmitRequest",
"canViewTasks",
"canViewAllTasks",
]) {
assert.ok(!(removed in body), `unexpected ${removed} in /me response`);
}
assert.equal(body.canEditGlobalFontSettings, true);
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
const coordMe = await coordRes.json();
assert.equal(coordMe.canRead, true);
assert.equal(coordMe.canEditGlobalFontSettings, false);
});
test("Meetings: bilingual title required (titleEn cannot be empty)", async () => {
const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع",
meetingDate: today,
});
assert.equal(missingEn.status, 400);
const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع",
titleEn: "",
meetingDate: today,
});
assert.equal(emptyEn.status, 400);
});
test("Meetings: POST → GET day → DELETE roundtrip", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع اختبار",
titleEn: "Test meeting",
meetingDate: today,
startTime: "09:00",
endTime: "10:00",
platform: "none",
status: "scheduled",
isHighlighted: 0,
attendees: [
{ name: "Tester One", attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
assert.ok(meeting.id);
created.meetingIds.push(meeting.id);
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${today}`,
);
assert.equal(day.status, 200);
const dayBody = await day.json();
assert.ok(Array.isArray(dayBody.meetings));
assert.ok(dayBody.meetings.some((m) => m.id === meeting.id));
const del = await api(
adminCookie,
"DELETE",
`/api/executive-meetings/${meeting.id}`,
);
assert.ok(del.status === 200 || del.status === 204);
});
test("Meetings: PUT /attendees replaces the attendee list", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا",
titleEn: "A",
meetingDate: today,
attendees: [{ name: "Old One", 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: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 },
{ name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 },
],
},
);
assert.equal(replace.status, 200);
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.equal(body.attendees.length, 2);
assert.ok(body.attendees.find((a) => a.name === "New One"));
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: "ب",
titleEn: "B",
meetingDate: today,
attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(create.status, 201);
const original = await create.json();
created.meetingIds.push(original.id);
const dup = await api(
adminCookie,
"POST",
`/api/executive-meetings/${original.id}/duplicate`,
{ targetDate: tomorrow },
);
assert.equal(dup.status, 201);
const newRow = await dup.json();
assert.notEqual(newRow.id, original.id);
created.meetingIds.push(newRow.id);
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${tomorrow}`,
);
const dayBody = await day.json();
assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
});
// #189: plain-text sanitization for location / meetingUrl / notes.
test("Sanitization: POST strips HTML from location, meetingUrl, and notes", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ل",
titleEn: "L",
meetingDate: today,
location: '<script>alert("xss")</script>Conference Room A',
meetingUrl:
'<img src=x onerror=alert(1)>https://example.com/meet?a=1&b=2#room',
notes: '<b>bold</b> note: 5 < 10 & ok <script>bad()</script>',
attendees: [],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
assert.ok(!/<script/i.test(meeting.location ?? ""));
assert.ok((meeting.location ?? "").includes("Conference Room A"));
assert.ok(!/<img|onerror/i.test(meeting.meetingUrl ?? ""));
assert.equal(meeting.meetingUrl, "https://example.com/meet?a=1&b=2#room");
assert.ok(!/<script|<b>/i.test(meeting.notes ?? ""));
assert.ok((meeting.notes ?? "").includes("5 < 10"));
assert.ok((meeting.notes ?? "").includes("& ok"));
assert.ok(!/&amp;|&lt;|&gt;/.test(meeting.notes ?? ""));
});
// Regression: encoded payloads must NOT be decoded back into live tags.
test("Sanitization: encoded HTML payloads (&lt;script&gt;…) are NOT decoded into live tags", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ه",
titleEn: "H",
meetingDate: today,
location: "&lt;script&gt;alert('loc')&lt;/script&gt;Boardroom",
meetingUrl:
"&#x3C;script&#x3E;alert('url')&#x3C;/script&#x3E;https://x.test/?a=1&b=2",
notes: "ok &amp; safe — &lt;img onerror=alert(1) src=x&gt;",
attendees: [],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
for (const field of ["location", "meetingUrl", "notes"]) {
const v = meeting[field] ?? "";
assert.ok(!/<script|<img|<iframe|<\/script/i.test(v));
}
assert.ok((meeting.location ?? "").includes("&lt;script&gt;"));
assert.ok((meeting.meetingUrl ?? "").includes("&#x3C;script&#x3E;"));
assert.ok(
(meeting.notes ?? "").includes("&amp;") &&
(meeting.notes ?? "").includes("&lt;img"),
);
const mixed = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "خ",
titleEn: "X",
meetingDate: today,
location: "<ScRiPt>alert(1)</ScRiPt>Boardroom B",
notes: "<IFRAME src=evil>x</IFRAME>visible note",
attendees: [],
});
assert.equal(mixed.status, 201);
const mm = await mixed.json();
created.meetingIds.push(mm.id);
assert.ok(!/<script|<iframe/i.test((mm.location ?? "") + (mm.notes ?? "")));
assert.ok((mm.location ?? "").includes("Boardroom B"));
assert.ok((mm.notes ?? "").includes("visible note"));
});
test("Sanitization: PATCH strips HTML from location, meetingUrl, and notes", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "م",
titleEn: "M",
meetingDate: today,
location: "Original",
notes: "Original note",
attendees: [],
});
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}`,
{
location: '<script>steal()</script>Updated Room',
meetingUrl: '<a href="javascript:bad()">https://meet.example.com</a>',
notes: '<iframe src="x"></iframe>Updated note',
},
);
assert.equal(patch.status, 200);
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.ok(!/<script/i.test(body.location ?? ""));
assert.ok((body.location ?? "").includes("Updated Room"));
assert.ok(!/<a |<iframe|javascript:/i.test(body.meetingUrl ?? ""));
assert.ok((body.meetingUrl ?? "").includes("https://meet.example.com"));
assert.ok(!/<iframe/i.test(body.notes ?? ""));
assert.ok((body.notes ?? "").includes("Updated note"));
});
// #202: titleEn / titleAr are independent columns; saveTitle in
// executive-meetings.tsx routes by visible i18n.language, so a PATCH
// for one column must never clobber the other.
test("EditableCell contract: PATCH titleEn alone leaves titleAr untouched (and vice versa)", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "العنوان الأصلي",
titleEn: "Original Title",
meetingDate: today,
attendees: [],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const patchEn = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{ titleEn: "<p>Edited English</p>" },
);
assert.equal(patchEn.status, 200);
let fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
let body = await fetched.json();
assert.ok(body.titleEn.includes("Edited English"));
assert.ok(body.titleAr.includes("العنوان الأصلي"));
const patchAr = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{ titleAr: "<p>عنوان معدل</p>" },
);
assert.equal(patchAr.status, 200);
fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
body = await fetched.json();
assert.ok(body.titleAr.includes("عنوان معدل"));
assert.ok(body.titleEn.includes("Edited English"));
});
test("Sanitization: duplicate path re-sanitizes location/meetingUrl/notes and preserves URL ampersands", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "د",
titleEn: "D",
meetingDate: today,
location: "Boardroom 7",
meetingUrl: "https://meet.example.com/x?room=42&token=abc",
notes: "Bring slides & notes",
attendees: [],
});
assert.equal(create.status, 201);
const original = await create.json();
created.meetingIds.push(original.id);
const dup = await api(
adminCookie,
"POST",
`/api/executive-meetings/${original.id}/duplicate`,
{ targetDate: tomorrow },
);
assert.equal(dup.status, 201);
const cloned = await dup.json();
created.meetingIds.push(cloned.id);
assert.equal(cloned.meetingUrl, "https://meet.example.com/x?room=42&token=abc");
assert.ok((cloned.notes ?? "").includes("&"));
assert.ok(
!/&amp;/.test(cloned.meetingUrl ?? "") && !/&amp;/.test(cloned.notes ?? ""),
);
});
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
const ok = await api(
adminCookie,
"GET",
"/api/executive-meetings/audit-logs",
);
assert.equal(ok.status, 200);
const body = await ok.json();
assert.ok(Array.isArray(body.logs ?? body.auditLogs ?? body.entries ?? []));
const denied = await api(
coordCookie,
"GET",
"/api/executive-meetings/audit-logs",
);
assert.equal(denied.status, 403);
});
test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "س",
titleEn: "S",
meetingDate: today,
});
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const filtered = await api(
adminCookie,
"GET",
`/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`,
);
assert.equal(filtered.status, 200);
const body = await filtered.json();
const entries = body.entries ?? body.logs ?? [];
assert.ok(Array.isArray(entries));
for (const e of entries) {
assert.equal(e.action, "create");
assert.equal(e.entityType, "meeting");
assert.equal(e.performedBy, adminUserId);
}
assert.ok(entries.some((e) => e.entityId === meeting.id));
});
test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
const empty = await api(
adminCookie,
"GET",
`/api/executive-meetings/notifications?date=1990-01-01`,
);
assert.equal(empty.status, 200);
const emptyBody = await empty.json();
assert.ok(Array.isArray(emptyBody.notifications));
const todayList = await api(
adminCookie,
"GET",
`/api/executive-meetings/notifications?date=${today}`,
);
assert.equal(todayList.status, 200);
});
test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
const ok = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "DIN Next LT Arabic",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
},
);
assert.equal(ok.status, 200);
const badWeight = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{ scope: "user", fontWeight: "medium" },
);
assert.equal(badWeight.status, 400);
const badSize = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{ scope: "user", fontSize: 30 },
);
assert.equal(badSize.status, 400);
const badFamily = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{ scope: "user", fontFamily: "Comic Sans" },
);
assert.equal(badFamily.status, 400);
});
test("PDF archives: POST creates a snapshot and GET returns it", async () => {
const post = await api(
adminCookie,
"POST",
"/api/executive-meetings/pdf-archives",
{ archiveDate: today },
);
assert.equal(post.status, 201);
const archive = await post.json();
assert.ok(archive.id);
assert.ok(archive.version >= 1);
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const body = await list.json();
const archives = body.archives ?? body.items ?? body.pdfArchives ?? [];
assert.ok(Array.isArray(archives));
assert.ok(archives.some((a) => a.id === archive.id));
});
test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => {
// Create a meeting with mixed Arabic+Latin attendees + a time range so
// the renderer's bidi/font-segmentation path is exercised.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع الميزانية ‪Q2 2026",
titleEn: "Q2 2026 Budget Review",
meetingDate: today,
startTime: "10:00",
endTime: "11:30",
location: "قاعة كبرى",
attendees: [
{ name: "أحمد العلي", title: "المدير التنفيذي",
attendanceType: "internal", sortOrder: 0 },
{ name: "John Smith", title: "CFO",
attendanceType: "external", sortOrder: 1 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
// Bad date → 400
const bad = await api(adminCookie, "GET",
"/api/executive-meetings/pdf?date=not-a-date");
assert.equal(bad.status, 400);
// Coordinator without executive role is denied — auth gate matches the
// rest of the executive-meetings module.
const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`);
assert.equal(noAuth.status, 401);
// Happy path: real PDF response with non-trivial body.
const res = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") || "",
/^application\/pdf/,
"must serve application/pdf",
);
assert.match(
res.headers.get("content-disposition") || "",
new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`),
"filename should include the requested date",
);
const buf = Buffer.from(await res.arrayBuffer());
assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`);
assert.equal(
buf.subarray(0, 4).toString("ascii"),
"%PDF",
"body must start with PDF magic bytes",
);
// The download must have created an archive row recorded against the
// generating user with byteSize matching the served body.
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const archives = (await list.json()).archives ?? [];
const generated = archives.find(
(a) => typeof a.byteSize === "number" && a.byteSize === buf.length,
);
assert.ok(
generated,
"PDF download must produce an archive row with the served byte_size",
);
assert.ok(generated.generatedBy, "generatedBy must be recorded");
assert.ok(
typeof generated.filePath === "string" && generated.filePath.length > 0,
"filePath/storage_url must be non-empty",
);
// Empty-day handling: another date with no meetings still returns a
// valid PDF (with a "no meetings" message).
const empty = await api(adminCookie, "GET",
`/api/executive-meetings/pdf?date=2099-01-01&lang=en`);
assert.equal(empty.status, 200);
const emptyBuf = Buffer.from(await empty.arrayBuffer());
assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF");
// Font-family must affect which font is embedded in the PDF. We render
// the same day twice with different families and assert that:
// 1) Both downloads succeed (200 + %PDF magic).
// 2) The Sans family ("DIN Next LT Arabic") embeds NotoSansArabic,
// while the Naskh-style family ("Majalla") embeds NotoNaskhArabic.
// The PostScript name appears verbatim in the PDF font dictionary.
const setSans = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "DIN Next LT Arabic",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
},
);
assert.equal(setSans.status, 200);
const sansRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(sansRes.status, 200);
const sansBuf = Buffer.from(await sansRes.arrayBuffer());
assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF");
const sansAscii = sansBuf.toString("latin1");
assert.ok(
/NotoSansArabic/i.test(sansAscii),
"DIN Next LT Arabic family must embed NotoSansArabic in the PDF",
);
assert.ok(
!/NotoNaskhArabic/i.test(sansAscii),
"DIN Next LT Arabic family must NOT embed NotoNaskhArabic",
);
const setNaskh = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Majalla",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
},
);
assert.equal(setNaskh.status, 200);
const naskhRes = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf?date=${today}&lang=ar`,
);
assert.equal(naskhRes.status, 200);
const naskhBuf = Buffer.from(await naskhRes.arrayBuffer());
const naskhAscii = naskhBuf.toString("latin1");
assert.ok(
/NotoNaskhArabic/i.test(naskhAscii),
"Majalla family must embed NotoNaskhArabic in the PDF",
);
assert.ok(
!/NotoSansArabic/i.test(naskhAscii),
"Majalla family must NOT embed NotoSansArabic",
);
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: dirty,
titleEn: dirty,
meetingDate: today,
attendees: [
{ name: 'Tester <em style="color:blue">One</em><script>x</script>',
attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const m = body.meetings.find((x) => x.id === meeting.id);
assert.ok(m, "meeting must come back");
// disallowed tags must be stripped
assert.ok(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");
// allowed inline formatting must be preserved
assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");
// attendee names get the same treatment
const att = m.attendees.find((a) => /Tester/.test(a.name));
assert.ok(att, "attendee must come back");
assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});
test("Sanitization: attendee.title is stripped of HTML on every write path", async () => {
const malicious = '<script>alert(1)</script><b onclick="x">CFO</b>';
// POST creates the meeting with a malicious title on one attendee.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "تنظيف العنوان",
titleEn: "Title sanitization",
meetingDate: today,
attendees: [
{
name: "Attendee POST",
title: malicious,
attendanceType: "internal",
sortOrder: 0,
},
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const postedTitle = meeting.attendees[0].title;
assert.ok(postedTitle, "title must round-trip");
assert.ok(!/<script/i.test(postedTitle), "POST: <script> stripped from title");
assert.ok(!/<b/i.test(postedTitle), "POST: <b> stripped from title");
assert.ok(!/onclick/i.test(postedTitle), "POST: onclick stripped from title");
assert.ok(/CFO/.test(postedTitle), "POST: visible text preserved in title");
// PATCH replaces attendees with a different malicious title.
const patched = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/${meeting.id}`,
{
attendees: [
{
name: "Attendee PATCH",
title: '<img src=x onerror=alert(1)>Director',
attendanceType: "internal",
sortOrder: 0,
},
],
},
);
assert.equal(patched.status, 200);
const patchedBody = await patched.json();
const patchedTitle = patchedBody.attendees[0].title;
assert.ok(!/<img/i.test(patchedTitle), "PATCH: <img> stripped from title");
assert.ok(!/onerror/i.test(patchedTitle), "PATCH: onerror stripped from title");
assert.ok(/Director/.test(patchedTitle), "PATCH: visible text preserved");
// PUT /attendees replaces the list and must sanitize too.
const put = await api(
adminCookie,
"PUT",
`/api/executive-meetings/${meeting.id}/attendees`,
{
attendees: [
{
name: "Attendee PUT",
title: '<a href="javascript:bad()">Manager</a>',
attendanceType: "virtual",
sortOrder: 0,
},
],
},
);
assert.equal(put.status, 200);
const putBody = await put.json();
const putTitle = putBody.attendees[0].title;
assert.ok(!/<a/i.test(putTitle), "PUT: <a> stripped from title");
assert.ok(!/javascript:/i.test(putTitle), "PUT: javascript: scheme gone");
assert.ok(/Manager/.test(putTitle), "PUT: visible text preserved");
// Duplicate path: copy of a meeting with a sanitized title must stay sanitized.
const dup = await api(
adminCookie,
"POST",
`/api/executive-meetings/${meeting.id}/duplicate`,
{ targetDate: today },
);
assert.equal(dup.status, 201);
const dupBody = await dup.json();
created.meetingIds.push(dupBody.id);
const dupTitle = dupBody.attendees[0].title;
assert.ok(!/<a/i.test(dupTitle), "duplicate: title remains free of HTML tags");
assert.ok(!/<script/i.test(dupTitle), "duplicate: no <script> in copied title");
assert.ok(/Manager/.test(dupTitle), "duplicate: visible text preserved");
});
test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${M.id}`, { titleAr: html });
assert.equal(patch.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const got = body.meetings.find((x) => x.id === M.id);
assert.ok(got, "meeting must come back");
assert.match(got.titleAr, /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i,
"right-aligned <p> survives sanitization");
assert.match(got.titleAr, /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i,
"center-aligned <p> survives sanitization");
});
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
const reorderDate = "2050-01-15";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
assert.equal(a.status, 201);
assert.equal(b.status, 201);
assert.equal(c.status, 201);
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
// reverse the order: C, B, A
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
assert.equal(reorder.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${reorderDate}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
assert.equal(cAfter.dailyNumber, 1);
assert.equal(bAfter.dailyNumber, 2);
assert.equal(aAfter.dailyNumber, 3);
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
const auditRows = await pool.query(
`SELECT action, entity_type, entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'executive_meeting.reorder'
AND entity_id = ANY($1::int[])
ORDER BY id DESC LIMIT 1`,
[[A.id, B.id, C.id]],
);
assert.equal(auditRows.rowCount, 1, "expected one reorder audit row");
assert.equal(auditRows.rows[0].entity_type, "meeting");
const newOrder = auditRows.rows[0].new_value?.order;
assert.deepEqual(newOrder, [C.id, B.id, A.id],
"audit new_value.order must echo the new ordering");
});
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
const reorderDate = "2050-04-04";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
});
const A = await a.json(); created.meetingIds.push(A.id);
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
});
const B = await b.json(); created.meetingIds.push(B.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [A.id, A.id, B.id] });
assert.equal(r.status, 400);
const body = await r.json();
assert.equal(body.code, "duplicate_ids",
"duplicate ids must be reported with the duplicate_ids code");
});
// Task #311: dragging a visible meeting from one row to another must not
// disturb cancelled rows on the same day. The schedule view hides
// cancelled meetings (#273), so dnd-kit can only ever drag visible
// rows; the reorder endpoint must accept an orderedIds payload that
// covers exactly the visible (non-cancelled) meetings, slot-swap among
// them, and leave cancelled rows' time slots and daily numbers
// completely untouched. Before the fix, cancelled meetings were
// included in the slot pool and silently stole chronologically-sorted
// slots from the visible list, which made dragging a meeting from top
// to bottom land it in a non-chronological position on screen.
test("Reorder: leaves cancelled rows untouched and only slot-swaps visible meetings", async () => {
const reorderDate = "2050-05-21";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ج", titleEn: "C", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
const d = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "د", titleEn: "D", meetingDate: reorderDate,
startTime: "12:00", endTime: "12:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
const D = await d.json(); created.meetingIds.push(D.id);
// Cancel B in-place so the visible list is [A, C, D] but the day
// still holds 4 rows. Capture B's pre-reorder slot to assert it
// stays put after the reorder.
await pool.query(
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
[B.id],
);
const { rows: bBeforeRows } = await pool.query(
`SELECT daily_number, start_time, end_time, status
FROM executive_meetings WHERE id = $1`,
[B.id],
);
const bBefore = bBeforeRows[0];
// User drags D (bottom of the visible list) up to the top, so the
// visible order becomes [D, A, C]. The orderedIds payload covers
// ONLY visible meetings — that is what the patched client sends.
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [D.id, A.id, C.id] });
assert.equal(reorder.status, 200,
"subset reorder (cancelled excluded) must be accepted");
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${reorderDate}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
// Visible meetings inherit each other's slots, sorted chronologically:
// D ← (09:00, daily_number from A's slot)
// A ← (11:00, daily_number from C's slot)
// C ← (12:00, daily_number from D's slot)
// After the swap the visible rows must read top-to-bottom in
// chronological start-time order.
const visible = body.meetings
.filter((m) => m.status !== "cancelled")
.sort((a, b) => a.dailyNumber - b.dailyNumber);
assert.deepEqual(visible.map((m) => m.id), [D.id, A.id, C.id],
"visible rows must be in the dropped order [D, A, C]");
const starts = visible.map((m) => m.startTime.slice(0, 5));
for (let i = 1; i < starts.length; i++) {
assert.ok(starts[i - 1] <= starts[i],
`visible row ${i - 1} (${starts[i - 1]}) must be <= row ${i} (${starts[i]}) chronologically`);
}
// Concretely the slot-swap should produce 09:00, 11:00, 12:00 for
// [D, A, C] (B's 10:00 slot stays with B, untouched).
assert.equal(visible[0].startTime.slice(0, 5), "09:00");
assert.equal(visible[1].startTime.slice(0, 5), "11:00");
assert.equal(visible[2].startTime.slice(0, 5), "12:00");
// Cancelled B: time slot AND daily_number must be unchanged.
const bAfter = byId.get(B.id);
assert.equal(bAfter.status, "cancelled");
assert.equal(bAfter.startTime.slice(0, 5),
String(bBefore.start_time).slice(0, 5),
"cancelled meeting startTime must not change");
assert.equal(bAfter.endTime.slice(0, 5),
String(bBefore.end_time).slice(0, 5),
"cancelled meeting endTime must not change");
assert.equal(bAfter.dailyNumber, bBefore.daily_number,
"cancelled meeting dailyNumber must not change");
});
test("Reorder: rejects orderedIds containing a cancelled meeting with code cancelled_in_reorder", async () => {
const reorderDate = "2050-05-22";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
await pool.query(
`UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
[B.id],
);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [B.id, A.id] });
assert.equal(r.status, 400,
"orderedIds containing a cancelled meeting must be rejected");
const body = await r.json();
assert.equal(body.code, "cancelled_in_reorder",
"rejection must be reported with the cancelled_in_reorder code");
});
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
const reorderDate = "2050-02-20";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "08:00", endTime: "08:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
// Send only one of the two — server must refuse so 1..N renumber stays safe.
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [A.id] });
assert.equal(r.status, 400);
const body = await r.json();
assert.equal(body.code, "incomplete_day");
});
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اليوم", titleEn: "Today", meetingDate: today,
});
const other = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "غدًا", titleEn: "Tomorrow",
meetingDate: new Date(Date.now() + 86400000)
.toISOString().slice(0, 10),
});
const S = await sameDay.json(); created.meetingIds.push(S.id);
const O = await other.json(); created.meetingIds.push(O.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [S.id, O.id] });
assert.equal(r.status, 400);
});
test("Reorder: user without executive role is denied (403)", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا", titleEn: "A", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
// a brand-new "user" has only the base 'user' role (no executive perms)
const plain = await createUser("em_plain", "user");
const plainCookie = await login(plain.username, TEST_PASSWORD);
const r = await api(plainCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [M.id] });
assert.equal(r.status, 403);
});
// ---------------------------------------------------------------------
// Phase-2 task #112 additions:
// - per-role meeting CRUD permissions
// - request reject / withdraw flows
// - task assignee-only status updates
// - font-settings PATCH/GET roundtrip
// - non-numeric :id returns 404 (router.param guard) instead of crashing
// - audit-insert failure rolls back the parent mutation (transactional)
// ---------------------------------------------------------------------
test("Meeting CRUD permissions: coordinator forbidden, lead allowed, admin allowed", async () => {
// executive_coordinator is in REQUEST_ROLES but NOT in MUTATE_ROLES, so
// a direct POST/PATCH/DELETE on /executive-meetings must come back 403.
const coordCreate = await api(coordCookie, "POST", "/api/executive-meetings", {
titleAr: "م", titleEn: "M", meetingDate: today,
});
assert.equal(coordCreate.status, 403,
"executive_coordinator must not be able to POST a meeting");
// executive_coord_lead is in MUTATE_ROLES, so the same call must succeed.
const leadCreate = await api(leadCookie, "POST", "/api/executive-meetings", {
titleAr: "ل", titleEn: "L", meetingDate: today,
});
assert.equal(leadCreate.status, 201,
"executive_coord_lead must be able to POST a meeting");
const leadMeeting = await leadCreate.json();
created.meetingIds.push(leadMeeting.id);
// Coordinator can still GET (read role) but cannot PATCH or DELETE.
const coordRead = await api(coordCookie, "GET",
`/api/executive-meetings/${leadMeeting.id}`);
assert.equal(coordRead.status, 200,
"coordinator should still be allowed to read meeting details");
const coordPatch = await api(coordCookie, "PATCH",
`/api/executive-meetings/${leadMeeting.id}`, { notes: "no" });
assert.equal(coordPatch.status, 403,
"coordinator must not PATCH meetings directly");
const coordDelete = await api(coordCookie, "DELETE",
`/api/executive-meetings/${leadMeeting.id}`);
assert.equal(coordDelete.status, 403,
"coordinator must not DELETE meetings directly");
// Lead can mutate.
const leadPatch = await api(leadCookie, "PATCH",
`/api/executive-meetings/${leadMeeting.id}`, { notes: "ok" });
assert.equal(leadPatch.status, 200,
"executive_coord_lead must be able to PATCH meetings");
});
test("Font settings: PUT then GET returns the user-scoped row roundtrip", async () => {
const put = await api(adminCookie, "PUT",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "Tajawal",
fontSize: 18,
fontWeight: "bold",
alignment: "center",
});
assert.equal(put.status, 200);
const get = await api(adminCookie, "GET",
"/api/executive-meetings/font-settings");
assert.equal(get.status, 200);
const body = await get.json();
assert.ok(body.user, "GET must include the user-scoped row");
assert.equal(body.user.scope, "user");
assert.equal(body.user.fontFamily, "Tajawal");
assert.equal(body.user.fontSize, 18);
assert.equal(body.user.fontWeight, "bold");
assert.equal(body.user.alignment, "center");
// PATCH alias must hit the same upsert handler.
const patch = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "DIN Next LT Arabic",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
});
assert.equal(patch.status, 200);
const get2 = await api(adminCookie, "GET",
"/api/executive-meetings/font-settings");
const body2 = await get2.json();
assert.equal(body2.user.fontFamily, "DIN Next LT Arabic");
assert.equal(body2.user.fontSize, 14);
assert.equal(body2.user.fontWeight, "regular");
assert.equal(body2.user.alignment, "start");
});
test("router.param: non-numeric :id returns 404 across endpoints (no crash)", async () => {
// The router.param("id", ...) guard in executive-meetings.ts must reject
// non-digit ids by calling next("route"), so Express falls through to 404
// instead of attempting Number("abc") and crashing further down.
const cases = [
["GET", "/api/executive-meetings/abc"],
["PATCH", "/api/executive-meetings/abc"],
["DELETE", "/api/executive-meetings/abc"],
["GET", "/api/executive-meetings/123abc"],
["GET", "/api/executive-meetings/-1"],
["POST", "/api/executive-meetings/abc/duplicate"],
["PUT", "/api/executive-meetings/abc/attendees"],
];
for (const [method, path] of cases) {
const res = await api(adminCookie, method, path,
method === "GET" || method === "DELETE" ? undefined : {});
assert.equal(res.status, 404,
`${method} ${path} should 404 (got ${res.status})`);
// Express's default 404 fallthrough returns an HTML "Cannot METHOD
// /path" page (text/html), so we assert that the body is *not* the
// pretty-printed JSON of an unhandled exception. Whichever Express
// produces, it must not be a 5xx HTML error stack trace.
const body = await res.text();
assert.ok(!/<pre>Error:/i.test(body),
`${method} ${path} body must not contain a thrown Error stack`);
assert.ok(!/TypeError|ReferenceError/i.test(body),
`${method} ${path} body must not contain a JS exception name`);
}
});
test("Transactional safety: a failing audit insert rolls back the parent DELETE", async () => {
// Create a meeting we can target.
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اختبار-صفقة", titleEn: "Tx Rollback Target", meetingDate: today,
});
assert.equal(create.status, 201);
const meeting = await create.json();
// Do NOT push to created.meetingIds yet — we want to verify the row is
// still there after the deliberately-failing DELETE, then clean it up
// ourselves.
const meetingId = meeting.id;
// Install a BEFORE INSERT trigger on the audit log table that raises an
// exception only when our specific (entity_type='meeting', action='delete',
// entity_id=<this meeting>) row is being inserted. Other audit inserts —
// including the ones that other tests run in parallel/sequence — are
// unaffected.
const triggerName = `tx_rollback_test_${meetingId}`;
const fnName = `tx_rollback_test_fn_${meetingId}`;
await pool.query(`
CREATE OR REPLACE FUNCTION ${fnName}() RETURNS trigger AS $$
BEGIN
IF NEW.entity_type = 'meeting'
AND NEW.action = 'delete'
AND NEW.entity_id = ${meetingId} THEN
RAISE EXCEPTION 'forced audit failure for tx rollback test';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await pool.query(`
CREATE TRIGGER ${triggerName}
BEFORE INSERT ON executive_meeting_audit_logs
FOR EACH ROW EXECUTE FUNCTION ${fnName}();
`);
let deleteStatus = null;
try {
const del = await api(adminCookie, "DELETE",
`/api/executive-meetings/${meetingId}`);
deleteStatus = del.status;
} finally {
// Always tear the trigger + function down so the rest of the test file
// (and any other test runs) are not poisoned.
await pool.query(`DROP TRIGGER IF EXISTS ${triggerName} ON executive_meeting_audit_logs`);
await pool.query(`DROP FUNCTION IF EXISTS ${fnName}()`);
}
assert.equal(deleteStatus, 500,
"DELETE must surface a 500 when the audit insert raises");
// The meeting row must still exist — the surrounding db.transaction()
// should have rolled the DELETE back when the audit insert failed.
const stillThere = await pool.query(
`SELECT id FROM executive_meetings WHERE id = $1`,
[meetingId],
);
assert.equal(stillThere.rowCount, 1,
"parent DELETE must be rolled back when audit insert fails");
// Now clean up for real — register the id so afterAll deletes it.
created.meetingIds.push(meetingId);
});
test("Notification prefs: GET defaults every event type to in-app + email on", async () => {
// Use a brand-new coordinator so we know there are no pre-existing rows.
const fresh = await createUser("em_pref_a", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
const res = await api(cookie, "GET", "/api/executive-meetings/notification-prefs");
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(Array.isArray(body.types) && body.types.length > 0,
"response must include the canonical types list");
assert.ok(Array.isArray(body.prefs) && body.prefs.length === body.types.length,
"response must return one merged pref per canonical type");
for (const p of body.prefs) {
assert.equal(p.inApp, true, `default inApp must be true for ${p.notificationType}`);
assert.equal(p.email, true, `default email must be true for ${p.notificationType}`);
}
});
test("Notification prefs: PUT upserts and is reflected in subsequent GET", async () => {
const fresh = await createUser("em_pref_b", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
// Pick a known type from the canonical list returned by GET.
const initial = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
const targetType = initial.types[0];
const put = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{ prefs: [{ notificationType: targetType, inApp: false, email: true }] });
assert.equal(put.status, 200);
const putBody = await put.json();
assert.equal(putBody.ok, true);
assert.equal(putBody.count, 1);
const after = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
const row = after.prefs.find((p) => p.notificationType === targetType);
assert.ok(row, "updated pref must come back in GET");
assert.equal(row.inApp, false);
assert.equal(row.email, true);
// Other types must remain at default (true / true).
for (const p of after.prefs) {
if (p.notificationType === targetType) continue;
assert.equal(p.inApp, true, `${p.notificationType} should remain default-on`);
assert.equal(p.email, true, `${p.notificationType} should remain default-on`);
}
// Re-PUT the same type should overwrite (upsert), not duplicate.
const put2 = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{ prefs: [{ notificationType: targetType, inApp: true, email: false }] });
assert.equal(put2.status, 200);
const after2 = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
const row2 = after2.prefs.find((p) => p.notificationType === targetType);
assert.equal(row2.inApp, true);
assert.equal(row2.email, false);
});
test("Notification prefs: PUT rejects unknown notificationType with 400", async () => {
const fresh = await createUser("em_pref_c", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
const bad = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{ prefs: [{ notificationType: "totally_made_up", inApp: false, email: false }] });
assert.equal(bad.status, 400);
});
test("Notification prefs: DELETE wipes the user's pref rows so GET reverts to defaults", async () => {
// #236: clicking "Restore defaults" in the UI hits DELETE. After it
// returns, every event type must read default-on regardless of what
// had been muted before, and a follow-up fan-out must reach the user
// again. Use a fresh approver so we don't disturb other prefs tests.
const fresh = await createUser("em_pref_restore", "executive_office_manager");
const cookie = await login(fresh.username, TEST_PASSWORD);
const put = await api(cookie, "PUT",
"/api/executive-meetings/notification-prefs",
{
prefs: [
{ notificationType: "meeting_created", inApp: false, email: false },
],
});
assert.equal(put.status, 200);
const { rows: pre } = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notification_prefs
WHERE user_id = $1`,
[fresh.id],
);
assert.equal(pre[0].n, 1, "PUT must have left 1 pref row");
const del = await api(cookie, "DELETE",
"/api/executive-meetings/notification-prefs");
assert.equal(del.status, 200);
const delBody = await del.json();
assert.equal(delBody.ok, true);
assert.equal(delBody.count, 1, "DELETE must report wiping the row");
const { rows: post } = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notification_prefs
WHERE user_id = $1`,
[fresh.id],
);
assert.equal(post[0].n, 0, "no pref rows must remain after DELETE");
// GET must now synthesize default-on for every type.
const after = await (await api(cookie, "GET",
"/api/executive-meetings/notification-prefs")).json();
for (const p of after.prefs) {
assert.equal(p.inApp, true, `${p.notificationType} must be default-on after DELETE`);
assert.equal(p.email, true, `${p.notificationType} must be default-on after DELETE`);
}
// And actual fan-out must reach the user again — a regression in the
// DELETE handler that left rows behind would silently re-mute them.
const beforeCount = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notifications
WHERE user_id = $1
AND notification_type = 'meeting_created'`,
[fresh.id],
);
const submit = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "إخطار-إعادة-الافتراضات",
titleEn: "Restore-defaults fan-out",
meetingDate: today,
attendees: [],
});
assert.equal(submit.status, 201);
const submittedMeeting = await submit.json();
created.meetingIds.push(submittedMeeting.id);
// Give the fan-out a moment to land before counting.
await new Promise((r) => setTimeout(r, 250));
const afterCount = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_notifications
WHERE user_id = $1
AND notification_type = 'meeting_created'`,
[fresh.id],
);
assert.ok(
afterCount.rows[0].n > beforeCount.rows[0].n,
"user with no pref rows (post-DELETE) must receive new meeting_created rows",
);
});
// ---------------------------------------------------------------------------
// #302 — Cascade-shift on postpone / reschedule.
// Each test uses its own far-future date so the day's meeting set is
// isolated from every other test (and from any seeded data on `today`).
// ---------------------------------------------------------------------------
async function createMeeting(date, titleEn, startTime, endTime, opts = {}) {
const res = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: titleEn,
titleEn,
meetingDate: date,
startTime,
endTime,
platform: "none",
status: opts.status ?? "scheduled",
isHighlighted: 0,
});
assert.equal(res.status, 201, `create ${titleEn} should succeed`);
const body = await res.json();
created.meetingIds.push(body.id);
return body;
}
async function getMeeting(id) {
const res = await api(adminCookie, "GET", `/api/executive-meetings/${id}`);
assert.equal(res.status, 200, `GET meeting ${id} should succeed`);
return res.json();
}
test("#302 cascade-preview: returns later active meetings with shifted times", async () => {
const d = "2050-05-10";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const c = await createMeeting(d, "C follower", "11:30", "12:00");
// Earlier meeting must NOT be in the preview.
const z = await createMeeting(d, "Z earlier", "08:00", "08:30");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/cascade-preview`,
{ deltaMinutes: 30 },
);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.blockedBy, null);
const ids = body.followers.map((f) => f.id);
assert.deepEqual(ids, [b.id, c.id], "only later active meetings, in start order");
assert.ok(!ids.includes(z.id), "earlier meetings must not be cascaded");
assert.equal(body.followers[0].newStart.slice(0, 5), "11:00");
assert.equal(body.followers[0].newEnd.slice(0, 5), "11:30");
assert.equal(body.followers[1].newStart.slice(0, 5), "12:00");
});
test("#302 cascade-preview: skips cancelled and completed followers", async () => {
const d = "2050-05-11";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B cancelled", "10:30", "11:00", {
status: "cancelled",
});
const c = await createMeeting(d, "C completed", "11:30", "12:00", {
status: "completed",
});
const dActive = await createMeeting(d, "D active", "13:00", "14:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/cascade-preview`,
{ deltaMinutes: 15 },
);
assert.equal(res.status, 200);
const body = await res.json();
const ids = body.followers.map((f) => f.id);
assert.deepEqual(ids, [dActive.id]);
assert.ok(!ids.includes(b.id));
assert.ok(!ids.includes(c.id));
});
test("#302 postpone-minutes cascadeFollowing=true atomically shifts all later meetings + writes audit", async () => {
const d = "2050-05-12";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const c = await createMeeting(d, "C follower", "11:30", "12:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/postpone-minutes`,
{
minutes: 30,
expectedUpdatedAt: a.updatedAt,
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.deepEqual(body.cascadedIds.sort((x, y) => x - y), [b.id, c.id].sort((x, y) => x - y));
const aFresh = await getMeeting(a.id);
const bFresh = await getMeeting(b.id);
const cFresh = await getMeeting(c.id);
assert.equal(aFresh.startTime.slice(0, 5), "09:30");
assert.equal(aFresh.endTime.slice(0, 5), "10:30");
assert.equal(bFresh.startTime.slice(0, 5), "11:00");
assert.equal(bFresh.endTime.slice(0, 5), "11:30");
assert.equal(cFresh.startTime.slice(0, 5), "12:00");
assert.equal(cFresh.endTime.slice(0, 5), "12:30");
// Audit rows for the cascade — one per follower, naming the trigger.
const audits = await pool.query(
`SELECT entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'meeting_cascade_shift'
AND entity_id = ANY($1::int[])
ORDER BY entity_id`,
[[b.id, c.id]],
);
assert.equal(audits.rowCount, 2, "one cascade audit per follower");
for (const row of audits.rows) {
assert.equal(row.new_value.triggerMeetingId, a.id);
assert.equal(row.new_value.deltaMinutes, 30);
}
});
test("#302 postpone-minutes cascadeFollowing=false leaves followers untouched (no audit)", async () => {
const d = "2050-05-13";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/postpone-minutes`,
{
minutes: 15,
expectedUpdatedAt: a.updatedAt,
cascadeFollowing: false,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, []);
const aFresh = await getMeeting(a.id);
const bFresh = await getMeeting(b.id);
assert.equal(aFresh.startTime.slice(0, 5), "09:15", "primary moved");
assert.equal(bFresh.startTime.slice(0, 5), "10:30", "follower unchanged");
const audits = await pool.query(
`SELECT COUNT(*)::int AS n
FROM executive_meeting_audit_logs
WHERE action = 'meeting_cascade_shift'
AND entity_id = $1`,
[b.id],
);
assert.equal(audits.rows[0].n, 0, "no cascade audit row when flag is false");
});
test("#302 postpone-minutes cascade midnight rejection rolls back primary too", async () => {
const d = "2050-05-14";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
// 23:30 follower would be pushed to 24:30 by a 60-minute delta.
const b = await createMeeting(d, "B late follower", "23:30", "23:45");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/postpone-minutes`,
{
minutes: 60,
expectedUpdatedAt: a.updatedAt,
cascadeFollowing: true,
},
);
assert.equal(res.status, 400);
const body = await res.json();
assert.equal(body.code, "cascade_crosses_midnight");
assert.equal(body.blockedBy.id, b.id);
// Primary must be unchanged because the whole transaction rolled back.
const aFresh = await getMeeting(a.id);
assert.equal(aFresh.startTime.slice(0, 5), "09:00");
assert.equal(aFresh.endTime.slice(0, 5), "10:00");
const bFresh = await getMeeting(b.id);
assert.equal(bFresh.startTime.slice(0, 5), "23:30");
});
test("#302 reschedule cascadeFollowing=true uses (newStart - oldStart) as delta on same day", async () => {
const d = "2050-05-15";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/reschedule`,
{
meetingDate: d,
startTime: "09:45",
endTime: "10:45",
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, [b.id]);
const bFresh = await getMeeting(b.id);
// delta = 09:45 - 09:00 = 45m → 10:30 → 11:15
assert.equal(bFresh.startTime.slice(0, 5), "11:15");
assert.equal(bFresh.endTime.slice(0, 5), "11:45");
});
test("#302 reschedule cascadeFollowing=true is a no-op when date moves", async () => {
const d1 = "2050-05-16";
const d2 = "2050-05-17";
const a = await createMeeting(d1, "A primary", "09:00", "10:00");
const b = await createMeeting(d1, "B follower", "10:30", "11:00");
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/reschedule`,
{
meetingDate: d2,
startTime: "09:30",
endTime: "10:30",
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, [], "cross-day reschedule must not cascade");
const bFresh = await getMeeting(b.id);
assert.equal(bFresh.startTime.slice(0, 5), "10:30", "follower untouched");
});
test("#302 reschedule cascadeFollowing=true is a no-op when newStart <= oldStart", async () => {
const d = "2050-05-18";
const a = await createMeeting(d, "A primary", "09:00", "10:00");
const b = await createMeeting(d, "B follower", "10:30", "11:00");
// Reschedule earlier: same day, newStart < oldStart → ignore flag.
const res = await api(
adminCookie,
"POST",
`/api/executive-meetings/${a.id}/reschedule`,
{
meetingDate: d,
startTime: "08:30",
endTime: "09:30",
cascadeFollowing: true,
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.cascadedIds, []);
const bFresh = await getMeeting(b.id);
assert.equal(bFresh.startTime.slice(0, 5), "10:30");
});
test("Notification prefs: DELETE on a user with no rows is a 200 no-op", async () => {
// Cheap idempotence check — clicking Restore defaults twice in a row
// must not 500 or surface a misleading error.
const fresh = await createUser("em_pref_restore_noop", "executive_coordinator");
const cookie = await login(fresh.username, TEST_PASSWORD);
const del = await api(cookie, "DELETE",
"/api/executive-meetings/notification-prefs");
assert.equal(del.status, 200);
const body = await del.json();
assert.equal(body.ok, true);
assert.equal(body.count, 0);
});