2026-04-28 08:02:41 +00:00
|
|
|
|
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: [],
|
|
|
|
|
|
requestIds: [],
|
|
|
|
|
|
taskIds: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-28 08:14:01 +00:00
|
|
|
|
for (const r of ["user", roleName]) {
|
2026-04-28 08:02:41 +00:00
|
|
|
|
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.taskIds.length > 0) {
|
|
|
|
|
|
await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [
|
|
|
|
|
|
created.taskIds,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (created.requestIds.length > 0) {
|
|
|
|
|
|
await pool.query(`DELETE FROM executive_meeting_requests WHERE id = ANY($1::int[])`, [
|
|
|
|
|
|
created.requestIds,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
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 IN ('meeting','request','task')`, [
|
|
|
|
|
|
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 executive_meeting_tasks WHERE assigned_to = ANY($1::int[])`, [
|
|
|
|
|
|
created.userIds,
|
|
|
|
|
|
]);
|
|
|
|
|
|
await pool.query(`DELETE FROM executive_meeting_requests WHERE requested_by = ANY($1::int[])`, [
|
|
|
|
|
|
created.userIds,
|
|
|
|
|
|
]);
|
2026-04-30 18:56:05 +00:00
|
|
|
|
// 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],
|
|
|
|
|
|
);
|
2026-04-28 08:02:41 +00:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
test("GET /me exposes capability flags including canViewAllTasks", async () => {
|
2026-04-28 08:02:41 +00:00
|
|
|
|
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",
|
|
|
|
|
|
"canApprove",
|
|
|
|
|
|
"canSubmitRequest",
|
|
|
|
|
|
"canViewAudit",
|
|
|
|
|
|
"canViewTasks",
|
|
|
|
|
|
"canViewAllTasks",
|
|
|
|
|
|
]) {
|
|
|
|
|
|
assert.ok(key in body, `missing ${key} in /me response`);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert.equal(body.canViewAllTasks, true);
|
|
|
|
|
|
|
|
|
|
|
|
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
|
|
|
|
|
|
const coordMe = await coordRes.json();
|
|
|
|
|
|
assert.equal(coordMe.canViewTasks, true);
|
2026-04-28 08:14:01 +00:00
|
|
|
|
assert.equal(coordMe.canViewAllTasks, 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);
|
2026-04-28 08:02:41 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
test("Meetings: POST → GET day → DELETE roundtrip", async () => {
|
2026-04-28 08:02:41 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
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"));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 19:16:00 +00:00
|
|
|
|
// --- 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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 20:58:11 +00:00
|
|
|
|
// --- Plain-text sanitization for location / meetingUrl / notes (#189) ----
|
|
|
|
|
|
// Mirrors the existing attendee-title sanitization so a meeting payload
|
|
|
|
|
|
// cannot smuggle <script> / <img onerror=...> markup into fields that
|
|
|
|
|
|
// templates (PDF renderer, audit-log popovers) interpolate as HTML.
|
|
|
|
|
|
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',
|
|
|
|
|
|
// URL with query params + fragment + ampersand — must round-trip
|
|
|
|
|
|
// unchanged after tag removal so `meet?a=1&b=2` doesn't get stored
|
|
|
|
|
|
// as `meet?a=1&b=2`.
|
|
|
|
|
|
meetingUrl:
|
|
|
|
|
|
'<img src=x onerror=alert(1)>https://example.com/meet?a=1&b=2#room',
|
|
|
|
|
|
// Notes with stray `<` (math-style) plus a real script tag — the
|
|
|
|
|
|
// script must be stripped, the `5 < 10` text must survive intact.
|
|
|
|
|
|
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 ?? ""),
|
|
|
|
|
|
`location should not contain <script>, got: ${meeting.location}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
(meeting.location ?? "").includes("Conference Room A"),
|
|
|
|
|
|
`location should preserve plain text, got: ${meeting.location}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!/<img|onerror/i.test(meeting.meetingUrl ?? ""),
|
|
|
|
|
|
`meetingUrl should not contain <img/onerror, got: ${meeting.meetingUrl}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
// Round-trip: `&` must NOT be entity-encoded.
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
meeting.meetingUrl,
|
|
|
|
|
|
"https://example.com/meet?a=1&b=2#room",
|
|
|
|
|
|
`meetingUrl must round-trip ampersands and fragments, got: ${meeting.meetingUrl}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!/<script|<b>/i.test(meeting.notes ?? ""),
|
|
|
|
|
|
`notes should strip both <b> and <script>, got: ${meeting.notes}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
// Round-trip: stray `<` and `&` in plain prose must survive as literals.
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
(meeting.notes ?? "").includes("5 < 10"),
|
|
|
|
|
|
`notes must preserve literal '<', got: ${meeting.notes}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
(meeting.notes ?? "").includes("& ok"),
|
|
|
|
|
|
`notes must preserve literal '&', got: ${meeting.notes}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!/&|<|>/.test(meeting.notes ?? ""),
|
|
|
|
|
|
`notes must NOT be HTML-entity-encoded, got: ${meeting.notes}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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"));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// --- EditableCell column independence (#202) -----------------------------
|
|
|
|
|
|
// The schedule's inline title editor decides which DB column to write
|
|
|
|
|
|
// based on the active i18n locale (titleAr in ar, titleEn in en). The
|
|
|
|
|
|
// server contract that powers this must let either column be patched
|
|
|
|
|
|
// independently — patching titleEn must not clobber titleAr (and vice
|
|
|
|
|
|
// versa). This test locks down that contract so a future server-side
|
|
|
|
|
|
// regression that writes both columns from one payload fails loudly.
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
// Mimic an `ar`-pref admin who switched UI to `en` and edited the
|
|
|
|
|
|
// English title cell — saveTitle sends only titleEn.
|
|
|
|
|
|
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"),
|
|
|
|
|
|
`titleEn should be updated, got: ${body.titleEn}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
body.titleAr.includes("العنوان الأصلي"),
|
|
|
|
|
|
`titleAr must NOT be clobbered by a titleEn-only PATCH, got: ${body.titleAr}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Mirror: editing the Arabic cell must not clobber the (just-edited)
|
|
|
|
|
|
// English title.
|
|
|
|
|
|
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"),
|
|
|
|
|
|
`titleEn must NOT be clobbered by a titleAr-only PATCH, got: ${body.titleEn}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Locks down the two indirect write paths for plain-text fields so a
|
|
|
|
|
|
// future regression in either path (forgetting the sanitizer, or
|
|
|
|
|
|
// swapping it back to one that entity-encodes) fails loudly.
|
|
|
|
|
|
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",
|
|
|
|
|
|
`duplicate must round-trip URL ampersands, got: ${cloned.meetingUrl}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
(cloned.notes ?? "").includes("&"),
|
|
|
|
|
|
`duplicate must preserve literal '&' in notes, got: ${cloned.notes}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!/&/.test(cloned.meetingUrl ?? "") && !/&/.test(cloned.notes ?? ""),
|
|
|
|
|
|
`duplicate must not entity-encode ampersands`,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Sanitization: change_location approved request round-trips URL ampersands and strips tags", async () => {
|
|
|
|
|
|
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
|
|
|
|
|
titleAr: "ج",
|
|
|
|
|
|
titleEn: "J",
|
|
|
|
|
|
meetingDate: today,
|
|
|
|
|
|
location: "Old Room",
|
|
|
|
|
|
meetingUrl: null,
|
|
|
|
|
|
attendees: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(create.status, 201);
|
|
|
|
|
|
const meeting = await create.json();
|
|
|
|
|
|
created.meetingIds.push(meeting.id);
|
|
|
|
|
|
|
|
|
|
|
|
const reqRes = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"POST",
|
|
|
|
|
|
"/api/executive-meetings/requests",
|
|
|
|
|
|
{
|
|
|
|
|
|
requestType: "change_location",
|
|
|
|
|
|
meetingId: meeting.id,
|
|
|
|
|
|
requestDetails: {
|
|
|
|
|
|
location: '<script>x()</script>New Room (Bldg A & B)',
|
|
|
|
|
|
meetingUrl: 'https://x.test/join?id=99&utm=ar',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(reqRes.status, 201);
|
|
|
|
|
|
const reqRow = await reqRes.json();
|
|
|
|
|
|
created.requestIds.push(reqRow.id);
|
|
|
|
|
|
|
|
|
|
|
|
const approve = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ status: "approved", reviewNotes: "ok" },
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(approve.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 ?? ""),
|
|
|
|
|
|
`change_location must strip <script>, got: ${body.location}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
(body.location ?? "").includes("New Room (Bldg A & B)"),
|
|
|
|
|
|
`change_location must preserve literal '&' in location, got: ${body.location}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
body.meetingUrl,
|
|
|
|
|
|
"https://x.test/join?id=99&utm=ar",
|
|
|
|
|
|
`change_location must round-trip URL ampersands, got: ${body.meetingUrl}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
test("Requests: POST → admin can list", async () => {
|
2026-04-28 08:02:41 +00:00
|
|
|
|
const create = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"POST",
|
|
|
|
|
|
"/api/executive-meetings/requests",
|
|
|
|
|
|
{
|
|
|
|
|
|
requestType: "note",
|
|
|
|
|
|
requestDetails: { note: "Phase-2 test request" },
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(create.status, 201);
|
|
|
|
|
|
const reqRow = await create.json();
|
|
|
|
|
|
assert.ok(reqRow.id);
|
|
|
|
|
|
created.requestIds.push(reqRow.id);
|
|
|
|
|
|
|
|
|
|
|
|
const list = await api(adminCookie, "GET", "/api/executive-meetings/requests");
|
|
|
|
|
|
assert.equal(list.status, 200);
|
|
|
|
|
|
const listBody = await list.json();
|
|
|
|
|
|
assert.ok(Array.isArray(listBody.requests));
|
|
|
|
|
|
assert.ok(listBody.requests.some((r) => r.id === reqRow.id));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
test("Requests: full review + apply pipeline (approve → apply highlights meeting)", async () => {
|
|
|
|
|
|
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
|
|
|
|
|
|
titleAr: "ت",
|
|
|
|
|
|
titleEn: "T",
|
|
|
|
|
|
meetingDate: today,
|
|
|
|
|
|
attendees: [],
|
|
|
|
|
|
isHighlighted: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(create.status, 201);
|
|
|
|
|
|
const meeting = await create.json();
|
|
|
|
|
|
created.meetingIds.push(meeting.id);
|
|
|
|
|
|
|
|
|
|
|
|
const reqRes = await api(adminCookie, "POST", "/api/executive-meetings/requests", {
|
|
|
|
|
|
requestType: "highlight",
|
|
|
|
|
|
meetingId: meeting.id,
|
|
|
|
|
|
requestDetails: { note: "highlight-please" },
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(reqRes.status, 201);
|
|
|
|
|
|
const reqRow = await reqRes.json();
|
|
|
|
|
|
created.requestIds.push(reqRow.id);
|
|
|
|
|
|
|
|
|
|
|
|
const approve = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ status: "approved", reviewNotes: "ok" },
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(approve.status, 200);
|
|
|
|
|
|
|
|
|
|
|
|
const fetched = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"GET",
|
|
|
|
|
|
`/api/executive-meetings/${meeting.id}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
const body = await fetched.json();
|
|
|
|
|
|
assert.equal(body.isHighlighted, 1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:02:41 +00:00
|
|
|
|
test("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => {
|
|
|
|
|
|
const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
|
|
|
|
|
taskType: "follow_up",
|
|
|
|
|
|
assignedTo: coordUserId,
|
|
|
|
|
|
notes: "for coord",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(t1.status, 201);
|
|
|
|
|
|
const task1 = await t1.json();
|
|
|
|
|
|
created.taskIds.push(task1.id);
|
|
|
|
|
|
|
|
|
|
|
|
const t2 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
|
|
|
|
|
taskType: "follow_up",
|
|
|
|
|
|
assignedTo: leadUserId,
|
|
|
|
|
|
notes: "for lead",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(t2.status, 201);
|
|
|
|
|
|
const task2 = await t2.json();
|
|
|
|
|
|
created.taskIds.push(task2.id);
|
|
|
|
|
|
|
|
|
|
|
|
const coordList = await api(
|
|
|
|
|
|
coordCookie,
|
|
|
|
|
|
"GET",
|
|
|
|
|
|
`/api/executive-meetings/tasks?mine=0&assigneeId=${leadUserId}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(coordList.status, 200);
|
|
|
|
|
|
const coordBody = await coordList.json();
|
|
|
|
|
|
const coordIds = coordBody.tasks.map((t) => t.id);
|
2026-04-28 08:14:01 +00:00
|
|
|
|
assert.ok(coordIds.includes(task1.id));
|
|
|
|
|
|
assert.ok(!coordIds.includes(task2.id));
|
2026-04-28 08:02:41 +00:00
|
|
|
|
for (const t of coordBody.tasks) {
|
2026-04-28 08:14:01 +00:00
|
|
|
|
assert.equal(t.assignedTo, coordUserId);
|
2026-04-28 08:02:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const leadList = await api(
|
|
|
|
|
|
leadCookie,
|
|
|
|
|
|
"GET",
|
|
|
|
|
|
`/api/executive-meetings/tasks?assigneeId=${leadUserId}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(leadList.status, 200);
|
|
|
|
|
|
const leadBody = await leadList.json();
|
|
|
|
|
|
assert.ok(leadBody.tasks.some((t) => t.id === task2.id));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
test("Tasks: PATCH supports reassign + notes update", async () => {
|
|
|
|
|
|
const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
|
|
|
|
|
|
taskType: "follow_up",
|
|
|
|
|
|
assignedTo: coordUserId,
|
|
|
|
|
|
notes: "v1",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(create.status, 201);
|
|
|
|
|
|
const task = await create.json();
|
|
|
|
|
|
created.taskIds.push(task.id);
|
|
|
|
|
|
|
|
|
|
|
|
const patch = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"PATCH",
|
|
|
|
|
|
`/api/executive-meetings/tasks/${task.id}`,
|
|
|
|
|
|
{ assignedTo: leadUserId, notes: "v2" },
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(patch.status, 200);
|
|
|
|
|
|
const updated = await patch.json();
|
|
|
|
|
|
assert.equal(updated.assignedTo, leadUserId);
|
|
|
|
|
|
assert.equal(updated.notes, "v2");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:02:41 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:02:41 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
|
2026-04-28 08:02:41 +00:00
|
|
|
|
const ok = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"PATCH",
|
|
|
|
|
|
"/api/executive-meetings/font-settings",
|
|
|
|
|
|
{
|
|
|
|
|
|
scope: "user",
|
|
|
|
|
|
fontFamily: "Cairo",
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 08:14:01 +00:00
|
|
|
|
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(
|
2026-04-28 08:02:41 +00:00
|
|
|
|
adminCookie,
|
|
|
|
|
|
"GET",
|
|
|
|
|
|
`/api/executive-meetings/pdf-archives?date=${today}`,
|
|
|
|
|
|
);
|
2026-04-28 08:14:01 +00:00
|
|
|
|
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));
|
2026-04-28 08:02:41 +00:00
|
|
|
|
});
|
2026-04-29 07:39:24 +00:00
|
|
|
|
|
2026-04-29 18:01:19 +00:00
|
|
|
|
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 ("Cairo") embeds NotoSansArabic, while the Naskh
|
|
|
|
|
|
// default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's
|
|
|
|
|
|
// PostScript name appears verbatim in the PDF font dictionary.
|
|
|
|
|
|
const setSans = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"PUT",
|
|
|
|
|
|
"/api/executive-meetings/font-settings",
|
|
|
|
|
|
{
|
|
|
|
|
|
scope: "user",
|
|
|
|
|
|
fontFamily: "Cairo",
|
|
|
|
|
|
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),
|
|
|
|
|
|
"Cairo family must embed NotoSansArabic in the PDF",
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!/NotoNaskhArabic/i.test(sansAscii),
|
|
|
|
|
|
"Cairo family must NOT embed NotoNaskhArabic",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const setNaskh = await api(
|
|
|
|
|
|
adminCookie,
|
|
|
|
|
|
"PUT",
|
|
|
|
|
|
"/api/executive-meetings/font-settings",
|
|
|
|
|
|
{
|
|
|
|
|
|
scope: "user",
|
|
|
|
|
|
fontFamily: "Noto Naskh Arabic",
|
|
|
|
|
|
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),
|
|
|
|
|
|
"Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF",
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
!/NotoSansArabic/i.test(naskhAscii),
|
|
|
|
|
|
"Noto Naskh Arabic family must NOT embed NotoSansArabic",
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-29 07:39:24 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 05:56:23 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-29 08:10:13 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-29 08:03:25 +00:00
|
|
|
|
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
|
|
|
|
|
|
const reorderDate = "2050-01-15";
|
2026-04-29 07:39:24 +00:00
|
|
|
|
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
2026-04-29 08:03:25 +00:00
|
|
|
|
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
|
2026-04-29 07:39:24 +00:00
|
|
|
|
startTime: "09:00", endTime: "09:30",
|
|
|
|
|
|
});
|
|
|
|
|
|
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
2026-04-29 08:03:25 +00:00
|
|
|
|
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
|
2026-04-29 07:39:24 +00:00
|
|
|
|
startTime: "10:00", endTime: "10:30",
|
|
|
|
|
|
});
|
|
|
|
|
|
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
|
2026-04-29 08:03:25 +00:00
|
|
|
|
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
|
2026-04-29 07:39:24 +00:00
|
|
|
|
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",
|
2026-04-29 08:03:25 +00:00
|
|
|
|
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
|
2026-04-29 07:39:24 +00:00
|
|
|
|
assert.equal(reorder.status, 200);
|
|
|
|
|
|
|
|
|
|
|
|
const day = await api(adminCookie, "GET",
|
2026-04-29 08:03:25 +00:00
|
|
|
|
`/api/executive-meetings?date=${reorderDate}`);
|
2026-04-29 07:39:24 +00:00
|
|
|
|
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);
|
2026-04-29 08:03:25 +00:00
|
|
|
|
assert.equal(cAfter.dailyNumber, 1);
|
|
|
|
|
|
assert.equal(bAfter.dailyNumber, 2);
|
|
|
|
|
|
assert.equal(aAfter.dailyNumber, 3);
|
2026-04-29 07:53:51 +00:00
|
|
|
|
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");
|
2026-04-29 08:12:51 +00: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");
|
2026-04-29 07:53:51 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-29 08:23:16 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-29 08:03:25 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-29 07:53:51 +00:00
|
|
|
|
test("Apply request (add_attendee) sanitizes attendee.name like direct writes", async () => {
|
|
|
|
|
|
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
|
|
|
|
|
titleAr: "اجتماع طلب", titleEn: "Request meeting", meetingDate: today,
|
|
|
|
|
|
});
|
|
|
|
|
|
const M = await m.json(); created.meetingIds.push(M.id);
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/executive-meetings/:id/requests creates a request bound to
|
|
|
|
|
|
// the meeting. The route validates payload via requestPayloadSchemas.
|
|
|
|
|
|
const reqRes = await api(adminCookie, "POST",
|
|
|
|
|
|
`/api/executive-meetings/${M.id}/requests`, {
|
|
|
|
|
|
requestType: "add_attendee",
|
|
|
|
|
|
requestDetails: {
|
|
|
|
|
|
name: '<b>Real</b><script>alert(1)</script><img src=x onerror=alert(1)>',
|
|
|
|
|
|
attendanceType: "internal",
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(reqRes.status, 201);
|
|
|
|
|
|
const reqRow = await reqRes.json();
|
|
|
|
|
|
|
|
|
|
|
|
// PATCH /api/executive-meetings/requests/:id with {status:"approved"}
|
|
|
|
|
|
// routes through applyApprovedRequest -> add_attendee.
|
|
|
|
|
|
const approve = await api(adminCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ status: "approved" });
|
|
|
|
|
|
assert.equal(approve.status, 200);
|
|
|
|
|
|
|
|
|
|
|
|
const det = await api(adminCookie, "GET", `/api/executive-meetings/${M.id}`);
|
|
|
|
|
|
const detail = await det.json();
|
|
|
|
|
|
const att = detail.attendees.at(-1);
|
|
|
|
|
|
assert.ok(/<b[^>]*>Real<\/b>/i.test(att.name), "<b> must survive");
|
|
|
|
|
|
assert.ok(!/<script/i.test(att.name), "<script> must be stripped");
|
|
|
|
|
|
assert.ok(!/onerror/i.test(att.name), "onerror must be stripped");
|
2026-04-29 07:39:24 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-04-29 18:27:15 +00:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 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("Requests: coordinator can submit + withdraw their own request", async () => {
|
|
|
|
|
|
const reqRes = await api(coordCookie, "POST",
|
|
|
|
|
|
"/api/executive-meetings/requests", {
|
|
|
|
|
|
requestType: "note",
|
|
|
|
|
|
requestDetails: { note: "coord submitting" },
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(reqRes.status, 201,
|
|
|
|
|
|
"coordinator (REQUEST_ROLES) must be able to POST a request");
|
|
|
|
|
|
const reqRow = await reqRes.json();
|
|
|
|
|
|
assert.equal(reqRow.status, "new");
|
|
|
|
|
|
assert.equal(reqRow.requestedBy, coordUserId);
|
|
|
|
|
|
created.requestIds.push(reqRow.id);
|
|
|
|
|
|
|
|
|
|
|
|
// A different coordinator cannot withdraw someone else's request.
|
|
|
|
|
|
const otherCoord = await createUser("em_coord_other", "executive_coordinator");
|
|
|
|
|
|
const otherCookie = await login(otherCoord.username, TEST_PASSWORD);
|
|
|
|
|
|
const otherWithdraw = await api(otherCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ action: "withdraw" });
|
|
|
|
|
|
assert.equal(otherWithdraw.status, 403,
|
|
|
|
|
|
"non-requester must not be able to withdraw someone else's request");
|
|
|
|
|
|
|
|
|
|
|
|
// The original requester can withdraw while still 'new'.
|
|
|
|
|
|
const withdraw = await api(coordCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ action: "withdraw" });
|
|
|
|
|
|
assert.equal(withdraw.status, 200);
|
|
|
|
|
|
const withdrawn = await withdraw.json();
|
|
|
|
|
|
assert.equal(withdrawn.status, "withdrawn");
|
|
|
|
|
|
|
|
|
|
|
|
// Once withdrawn, repeated withdraw must 409 (bad_state), not crash.
|
|
|
|
|
|
const again = await api(coordCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ action: "withdraw" });
|
|
|
|
|
|
assert.equal(again.status, 409);
|
|
|
|
|
|
const againBody = await again.json();
|
|
|
|
|
|
assert.equal(againBody.code, "bad_state");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Requests: admin can reject; rejected requests cannot be re-reviewed", async () => {
|
|
|
|
|
|
const reqRes = await api(coordCookie, "POST",
|
|
|
|
|
|
"/api/executive-meetings/requests", {
|
|
|
|
|
|
requestType: "note",
|
|
|
|
|
|
requestDetails: { note: "to be rejected" },
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(reqRes.status, 201);
|
|
|
|
|
|
const reqRow = await reqRes.json();
|
|
|
|
|
|
created.requestIds.push(reqRow.id);
|
|
|
|
|
|
|
|
|
|
|
|
// Coordinator (no APPROVE_ROLES) cannot reject.
|
|
|
|
|
|
const coordReject = await api(coordCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ status: "rejected", reviewNotes: "not allowed" });
|
|
|
|
|
|
assert.equal(coordReject.status, 403,
|
|
|
|
|
|
"non-approver must not be able to reject a request");
|
|
|
|
|
|
|
|
|
|
|
|
// Admin can reject. The response must reflect the new status and reviewer.
|
|
|
|
|
|
const reject = await api(adminCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ status: "rejected", reviewNotes: "no" });
|
|
|
|
|
|
assert.equal(reject.status, 200);
|
|
|
|
|
|
const rejected = await reject.json();
|
|
|
|
|
|
assert.equal(rejected.status, "rejected");
|
|
|
|
|
|
assert.equal(rejected.reviewedBy, adminUserId);
|
|
|
|
|
|
assert.equal(rejected.reviewNotes, "no");
|
|
|
|
|
|
|
|
|
|
|
|
// After review the original requester can no longer withdraw.
|
|
|
|
|
|
const lateWithdraw = await api(coordCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ action: "withdraw" });
|
|
|
|
|
|
assert.equal(lateWithdraw.status, 409,
|
|
|
|
|
|
"withdrawing an already-reviewed request must 409, not crash");
|
|
|
|
|
|
|
|
|
|
|
|
// Trying to re-review must also 409 with code:bad_state.
|
|
|
|
|
|
const reReview = await api(adminCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/requests/${reqRow.id}`,
|
|
|
|
|
|
{ status: "approved", reviewNotes: "flip" });
|
|
|
|
|
|
assert.equal(reReview.status, 409);
|
|
|
|
|
|
const reBody = await reReview.json();
|
|
|
|
|
|
assert.equal(reBody.code, "bad_state");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Tasks: assignee can update status; non-assignee non-mutator gets 403", async () => {
|
|
|
|
|
|
// Admin creates a task assigned to coord.
|
|
|
|
|
|
const create = await api(adminCookie, "POST",
|
|
|
|
|
|
"/api/executive-meetings/tasks", {
|
|
|
|
|
|
taskType: "follow_up",
|
|
|
|
|
|
assignedTo: coordUserId,
|
|
|
|
|
|
notes: "assignee-only test",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(create.status, 201);
|
|
|
|
|
|
const task = await create.json();
|
|
|
|
|
|
created.taskIds.push(task.id);
|
|
|
|
|
|
|
|
|
|
|
|
// The assignee (coord) can flip the status even though they are NOT in
|
|
|
|
|
|
// MUTATE_ROLES. This is the assignedTo carve-out in the PATCH handler.
|
|
|
|
|
|
const assigneeUpdate = await api(coordCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/tasks/${task.id}`, { status: "in_progress" });
|
|
|
|
|
|
assert.equal(assigneeUpdate.status, 200,
|
|
|
|
|
|
"assignee must be able to update their own task status");
|
|
|
|
|
|
const updated = await assigneeUpdate.json();
|
|
|
|
|
|
assert.equal(updated.status, "in_progress");
|
|
|
|
|
|
|
|
|
|
|
|
// The assignee CANNOT change non-status fields (mutator-only).
|
|
|
|
|
|
const assigneeReassign = await api(coordCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/tasks/${task.id}`,
|
|
|
|
|
|
{ assignedTo: leadUserId });
|
|
|
|
|
|
assert.equal(assigneeReassign.status, 200,
|
|
|
|
|
|
"assignee patching a mutator-only field returns the row but ignores it");
|
|
|
|
|
|
const stillCoord = await assigneeReassign.json();
|
|
|
|
|
|
assert.equal(stillCoord.assignedTo, coordUserId,
|
|
|
|
|
|
"assignee may not reassign a task to someone else");
|
|
|
|
|
|
|
|
|
|
|
|
// A different coordinator (not the assignee, not a mutator) is rejected.
|
|
|
|
|
|
const otherCoord = await createUser("em_other_coord", "executive_coordinator");
|
|
|
|
|
|
const otherCookie = await login(otherCoord.username, TEST_PASSWORD);
|
|
|
|
|
|
const nonAssignee = await api(otherCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/tasks/${task.id}`, { status: "completed" });
|
|
|
|
|
|
assert.equal(nonAssignee.status, 403,
|
|
|
|
|
|
"non-assignee non-mutator must be denied by the PATCH handler");
|
|
|
|
|
|
|
|
|
|
|
|
// Mutator (lead) is always allowed.
|
|
|
|
|
|
const mutator = await api(leadCookie, "PATCH",
|
|
|
|
|
|
`/api/executive-meetings/tasks/${task.id}`, { status: "completed" });
|
|
|
|
|
|
assert.equal(mutator.status, 200);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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: "Cairo",
|
|
|
|
|
|
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, "Cairo");
|
|
|
|
|
|
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"],
|
|
|
|
|
|
["POST", "/api/executive-meetings/abc/requests"],
|
|
|
|
|
|
["PATCH", "/api/executive-meetings/requests/abc"],
|
|
|
|
|
|
["PATCH", "/api/executive-meetings/tasks/abc"],
|
|
|
|
|
|
["DELETE", "/api/executive-meetings/tasks/abc"],
|
|
|
|
|
|
];
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-04-30 18:56:05 +00:00
|
|
|
|
|
|
|
|
|
|
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: in-app fan-out skips users who muted that channel", async () => {
|
|
|
|
|
|
// request_submitted notifications fan out to APPROVE_ROLES (admin,
|
|
|
|
|
|
// executive_office_manager). Create two approver users — mute one,
|
|
|
|
|
|
// leave the other on — then submit a request and verify only the
|
|
|
|
|
|
// non-muted user receives a new in-app notification row.
|
|
|
|
|
|
const muted = await createUser("em_pref_muted_appr", "executive_office_manager");
|
|
|
|
|
|
const control = await createUser("em_pref_control_appr", "executive_office_manager");
|
|
|
|
|
|
const mutedCookie = await login(muted.username, TEST_PASSWORD);
|
|
|
|
|
|
|
|
|
|
|
|
const muteRes = await api(mutedCookie, "PUT",
|
|
|
|
|
|
"/api/executive-meetings/notification-prefs",
|
|
|
|
|
|
{ prefs: [{ notificationType: "request_submitted", inApp: false, email: false }] });
|
|
|
|
|
|
assert.equal(muteRes.status, 200);
|
|
|
|
|
|
|
|
|
|
|
|
async function countFor(userId) {
|
|
|
|
|
|
const r = await pool.query(
|
|
|
|
|
|
`SELECT COUNT(*)::int AS n
|
|
|
|
|
|
FROM executive_meeting_notifications
|
|
|
|
|
|
WHERE user_id = $1
|
|
|
|
|
|
AND notification_type = 'request_submitted'`,
|
|
|
|
|
|
[userId],
|
|
|
|
|
|
);
|
|
|
|
|
|
return r.rows[0].n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const beforeMuted = await countFor(muted.id);
|
|
|
|
|
|
const beforeControl = await countFor(control.id);
|
|
|
|
|
|
|
|
|
|
|
|
const submit = await api(coordCookie, "POST",
|
|
|
|
|
|
"/api/executive-meetings/requests",
|
|
|
|
|
|
{ requestType: "note", requestDetails: { note: "fanout-mute-test" } });
|
|
|
|
|
|
assert.equal(submit.status, 201);
|
|
|
|
|
|
const submitted = await submit.json();
|
|
|
|
|
|
created.requestIds.push(submitted.id);
|
|
|
|
|
|
|
|
|
|
|
|
const afterMuted = await countFor(muted.id);
|
|
|
|
|
|
const afterControl = await countFor(control.id);
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(afterMuted, beforeMuted,
|
|
|
|
|
|
"muted approver must not receive an in-app notification row");
|
|
|
|
|
|
assert.ok(afterControl > beforeControl,
|
|
|
|
|
|
"non-muted approver must still receive an in-app notification row " +
|
|
|
|
|
|
`(before=${beforeControl}, after=${afterControl})`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("Notification prefs: channels are independent (email-only mute leaves in-app delivery intact)", async () => {
|
|
|
|
|
|
// Muting one channel must not affect the other. This complements the
|
|
|
|
|
|
// both-channels-off test above by exercising the email-channel filter
|
|
|
|
|
|
// path (sendExecutiveMeetingEmail uses filterRecipientsByNotificationPref
|
|
|
|
|
|
// with channel="email") while in-app stays on.
|
|
|
|
|
|
const approver = await createUser("em_pref_email_only", "executive_office_manager");
|
|
|
|
|
|
const approverCookie = await login(approver.username, TEST_PASSWORD);
|
|
|
|
|
|
|
|
|
|
|
|
// Email off, in-app on.
|
|
|
|
|
|
const muteEmail = await api(approverCookie, "PUT",
|
|
|
|
|
|
"/api/executive-meetings/notification-prefs",
|
|
|
|
|
|
{ prefs: [{ notificationType: "request_submitted", inApp: true, email: false }] });
|
|
|
|
|
|
assert.equal(muteEmail.status, 200);
|
|
|
|
|
|
|
|
|
|
|
|
async function inAppCount(userId) {
|
|
|
|
|
|
const r = await pool.query(
|
|
|
|
|
|
`SELECT COUNT(*)::int AS n
|
|
|
|
|
|
FROM executive_meeting_notifications
|
|
|
|
|
|
WHERE user_id = $1
|
|
|
|
|
|
AND notification_type = 'request_submitted'`,
|
|
|
|
|
|
[userId],
|
|
|
|
|
|
);
|
|
|
|
|
|
return r.rows[0].n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const before = await inAppCount(approver.id);
|
|
|
|
|
|
const submit = await api(coordCookie, "POST",
|
|
|
|
|
|
"/api/executive-meetings/requests",
|
|
|
|
|
|
{ requestType: "note", requestDetails: { note: "email-mute-only-test" } });
|
|
|
|
|
|
assert.equal(submit.status, 201);
|
|
|
|
|
|
const submitted = await submit.json();
|
|
|
|
|
|
created.requestIds.push(submitted.id);
|
|
|
|
|
|
|
|
|
|
|
|
const after = await inAppCount(approver.id);
|
|
|
|
|
|
assert.ok(after > before,
|
|
|
|
|
|
"user who muted only the email channel must still receive in-app rows " +
|
|
|
|
|
|
`(before=${before}, after=${after})`);
|
|
|
|
|
|
|
|
|
|
|
|
// Confirm the saved row really is email=false / in-app=true so that
|
|
|
|
|
|
// sendExecutiveMeetingEmail (which calls the same helper with
|
|
|
|
|
|
// channel="email") will see the explicit opt-out.
|
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
|
`SELECT in_app, email
|
|
|
|
|
|
FROM executive_meeting_notification_prefs
|
|
|
|
|
|
WHERE user_id = $1
|
|
|
|
|
|
AND notification_type = 'request_submitted'`,
|
|
|
|
|
|
[approver.id],
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(rows.length, 1);
|
|
|
|
|
|
assert.equal(rows[0].in_app, true);
|
|
|
|
|
|
assert.equal(rows[0].email, false);
|
|
|
|
|
|
});
|