import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import zlib from "node:zlib"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; 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 --------------------------------- // 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: 'Conference Room A', meetingUrl: 'https://example.com/meet?a=1&b=2#room', notes: 'bold note: 5 < 10 & ok ', attendees: [], }); assert.equal(create.status, 201); const meeting = await create.json(); created.meetingIds.push(meeting.id); assert.ok(!/Boardroom B", notes: "visible note", attendees: [], }); assert.equal(mixed.status, 201); const mm = await mixed.json(); created.meetingIds.push(mm.id); assert.ok(!/ { 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: 'Updated Room', meetingUrl: 'https://meet.example.com', notes: '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(!/World' + 'x'; const create = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr: dirty, titleEn: dirty, meetingDate: today, attendees: [ { name: 'Tester One', 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(!/CFO'; // 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(!/