Files
TX/artifacts/api-server/tests/executive-meetings.test.mjs
T
riyadhafraa ee1e2a9f42 Executive Meetings: inline editing, drag reorder, and current-meeting highlight
Implements Task #122 — four features on the Executive Meetings schedule:
1. Word-like inline editing of meeting title and attendee names with bold,
   italic, underline, color, font-family, font-size, and text-align controls
   (Tiptap-backed EditableCell + FormattingToolbar).
2. Drag-to-reorder column headers (dnd-kit), persisted in the existing
   em-schedule-cols-v1 storage key. Old up/down buttons removed.
3. Drag-to-reorder rows within a day. New POST /api/executive-meetings/reorder
   endpoint permutes daily_number + start/end times in a single transaction
   using a two-phase negative→final assignment that respects the
   (date, daily_number) UNIQUE constraint and survives partial-day reorders.
   Audit-logged and role-guarded.
4. Highlight the meeting whose start/end window is "now", refreshed every
   60 s. Toggle and color picker live in the customize popover and persist
   to localStorage (em-current-meeting-highlight-v1).

Backend changes
- Widened titleAr / titleEn / attendees.name to text.
- New artifacts/api-server/src/lib/sanitize.ts with an allowlist for
  inline tags + safe inline styles. Applied to POST, PATCH, PUT-attendees,
  duplicate, reorder, and applyApprovedRequest (add_attendee) paths so
  rich text round-trips safely.
- Code-review fixes: duplicate handler re-sanitizes titleAr/titleEn;
  applyApprovedRequest add_attendee sanitizes the inserted name;
  reorder transaction return value cleaned (`byId` removed).
- 5 new tests cover sanitization stripping, reorder happy path, cross-day
  rejection, permission denial, and applyApprovedRequest sanitization.
  Pre-existing app_permissions failures are unrelated (tracked by #126/#127).

Frontend changes
- artifacts/tx-os/src/components/editable-cell.tsx with FormattingToolbar
  (custom FontSize Tiptap extension + @tiptap/extension-text-align).
- ScheduleSection wires saveTitle, saveAttendeeName, reorder mutation
  (optimistic with revert on error), highlight tick, and HighlightPrefs.
- New SortableHeader component for column drag.
- AttendeesCell passes the original attendee index to PUT writes so edits
  reach the right row even after grouping into virtual/internal/external.
- Print page renders sanitized rich text via dangerouslySetInnerHTML on
  names and titles; attendee.title is rendered as a plain text node (XSS).
- Translation keys added in ar.json and en.json.
2026-04-29 08:23:16 +00:00

777 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
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.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,
]);
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 capability flags including canViewAllTasks", 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",
"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);
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);
});
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"));
});
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));
});
test("Requests: POST → admin can list", async () => {
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));
});
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);
});
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);
assert.ok(coordIds.includes(task1.id));
assert.ok(!coordIds.includes(task2.id));
for (const t of coordBody.tasks) {
assert.equal(t.assignedTo, coordUserId);
}
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));
});
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");
});
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
const ok = await api(
adminCookie,
"GET",
"/api/executive-meetings/audit-logs",
);
assert.equal(ok.status, 200);
const body = await ok.json();
assert.ok(Array.isArray(body.logs ?? body.auditLogs ?? body.entries ?? []));
const denied = await api(
coordCookie,
"GET",
"/api/executive-meetings/audit-logs",
);
assert.equal(denied.status, 403);
});
test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "س",
titleEn: "S",
meetingDate: today,
});
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const filtered = await api(
adminCookie,
"GET",
`/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`,
);
assert.equal(filtered.status, 200);
const body = await filtered.json();
const entries = body.entries ?? body.logs ?? [];
assert.ok(Array.isArray(entries));
for (const e of entries) {
assert.equal(e.action, "create");
assert.equal(e.entityType, "meeting");
assert.equal(e.performedBy, adminUserId);
}
assert.ok(entries.some((e) => e.entityId === meeting.id));
});
test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
const empty = await api(
adminCookie,
"GET",
`/api/executive-meetings/notifications?date=1990-01-01`,
);
assert.equal(empty.status, 200);
const emptyBody = await empty.json();
assert.ok(Array.isArray(emptyBody.notifications));
const todayList = await api(
adminCookie,
"GET",
`/api/executive-meetings/notifications?date=${today}`,
);
assert.equal(todayList.status, 200);
});
test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
const ok = await api(
adminCookie,
"PATCH",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "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);
});
test("PDF archives: POST creates a snapshot and GET returns it", async () => {
const post = await api(
adminCookie,
"POST",
"/api/executive-meetings/pdf-archives",
{ archiveDate: today },
);
assert.equal(post.status, 201);
const archive = await post.json();
assert.ok(archive.id);
assert.ok(archive.version >= 1);
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(list.status, 200);
const body = await list.json();
const archives = body.archives ?? body.items ?? body.pdfArchives ?? [];
assert.ok(Array.isArray(archives));
assert.ok(archives.some((a) => a.id === archive.id));
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: dirty,
titleEn: dirty,
meetingDate: today,
attendees: [
{ name: 'Tester <em style="color:blue">One</em><script>x</script>',
attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const m = body.meetings.find((x) => x.id === meeting.id);
assert.ok(m, "meeting must come back");
// disallowed tags must be stripped
assert.ok(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");
// allowed inline formatting must be preserved
assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");
// attendee names get the same treatment
const att = m.attendees.find((a) => /Tester/.test(a.name));
assert.ok(att, "attendee must come back");
assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});
test("Sanitization: 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);
// Tiptap's TextAlign extension emits paragraphs like
// <p style="text-align: right">…</p>. The sanitizer must allow that
// structure so Word-like alignment persists.
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
const patch = await api(adminCookie, "PATCH",
`/api/executive-meetings/${M.id}`, { titleAr: html });
assert.equal(patch.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const got = body.meetings.find((x) => x.id === M.id);
assert.ok(got, "meeting must come back");
assert.match(got.titleAr, /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i,
"right-aligned <p> survives sanitization");
assert.match(got.titleAr, /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i,
"center-aligned <p> survives sanitization");
});
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
// Use a fresh future date to guarantee the reorder is full-day.
const reorderDate = "2050-01-15";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
startTime: "11:00", endTime: "11:30",
});
assert.equal(a.status, 201);
assert.equal(b.status, 201);
assert.equal(c.status, 201);
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
// reverse the order: C, B, A
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
assert.equal(reorder.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${reorderDate}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
// Full-day reorder => dailyNumber recomputed as 1..N in the new order;
// each row inherits the corresponding slot's start/end time.
assert.equal(cAfter.dailyNumber, 1);
assert.equal(bAfter.dailyNumber, 2);
assert.equal(aAfter.dailyNumber, 3);
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
// The reorder route must write an audit row with the namespaced action
// `executive_meeting.reorder` so downstream filters can target it.
const auditRows = await pool.query(
`SELECT action, entity_type, entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'executive_meeting.reorder'
AND entity_id = ANY($1::int[])
ORDER BY id DESC LIMIT 1`,
[[A.id, B.id, C.id]],
);
assert.equal(auditRows.rowCount, 1, "expected one reorder audit row");
assert.equal(auditRows.rows[0].entity_type, "meeting");
const newOrder = auditRows.rows[0].new_value?.order;
assert.deepEqual(newOrder, [C.id, B.id, A.id],
"audit new_value.order must echo the new ordering");
});
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
// Even though full-day membership would otherwise be satisfied, sending the
// same id twice must be refused so we never assign the same dailyNumber to
// two distinct rows during the two-phase renumber.
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");
});
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
const reorderDate = "2050-02-20";
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
startTime: "08:00", endTime: "08:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
startTime: "09:00", endTime: "09:30",
});
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
// Send only one of the two — server must refuse so 1..N renumber stays safe.
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: reorderDate, orderedIds: [A.id] });
assert.equal(r.status, 400);
const body = await r.json();
assert.equal(body.code, "incomplete_day");
});
test("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");
});
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);
});