// API contract tests for the cell-merge overlay added by // to the executive-meetings PATCH endpoint. Covers: // // 1. Setting a merge writes mergeStartColumn / mergeEndColumn / // mergeText to the row and round-trips back through GET. // 2. Clearing the merge with `merge: null` nulls all three columns // while leaving the rest of the row (title, attendees, etc.) // intact. // 3. Invalid ranges (start column comes after end column) are // rejected with 400 and don't mutate the row. // 4. mergeText is sanitized just like titleAr/titleEn — script tags, // event handlers, and javascript: hrefs are stripped while the // visible text and an allowed inline color survive. // 5. A non-mutate user (executive_viewer role) receives 403 when // attempting to PATCH the merge fields, and the row is unchanged. // // Cleanup runs in `after` regardless of which assertions failed so // repeated runs of this file against the same DB don't leak rows. import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run these tests"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const created = { userIds: [], meetingIds: [], }; function uniqueName(prefix) { return `${prefix}_${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 8)}`; } async function createUser(prefix, roleName) { const username = uniqueName(prefix); const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'EM Merge 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 viewerCookie = null; let viewerUserId = null; const today = new Date().toISOString().slice(0, 10); // Create a single meeting we can mutate across the tests below. Each // test scopes its assertions to a fresh row id so test ordering doesn't // leak state between assertions. async function createMeeting(titleEn, titleAr) { const res = await api(adminCookie, "POST", "/api/executive-meetings", { titleAr, titleEn, meetingDate: today, startTime: "09:00", endTime: "10:00", platform: "none", status: "scheduled", isHighlighted: 0, attendees: [ { name: "Tester", attendanceType: "internal", sortOrder: 0 }, ], }); assert.equal(res.status, 201, "create meeting should succeed"); const meeting = await res.json(); created.meetingIds.push(meeting.id); return meeting; } before(async () => { adminCookie = await login("admin", "admin123"); // executive_viewer is in READ_ROLES but NOT in MUTATE_ROLES, so // requireExecutiveAccess passes and we hit requireMutate's 403. // This isolates the "non-editor" case from the bare "no executive // access at all" case (which would 403 earlier in the chain). const viewer = await createUser("em_merge_viewer", "executive_viewer"); viewerUserId = viewer.id; viewerCookie = await login(viewer.username, TEST_PASSWORD); }); after(async () => { if (created.meetingIds.length > 0) { await pool.query( `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, [created.meetingIds], ); await pool.query( `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, [created.meetingIds], ); await pool.query( `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [created.meetingIds], ); } if (created.userIds.length > 0) { await pool.query( `DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [created.userIds], ); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ created.userIds, ]); } await pool.end(); }); test("PATCH merge: setting a merge persists all three columns and round-trips via GET", async () => { const meeting = await createMeeting( "Merge set", "تعيين الدمج", ); // Sanity: a fresh row has no merge. assert.equal(meeting.mergeStartColumn, null); assert.equal(meeting.mergeEndColumn, null); assert.equal(meeting.mergeText, null); const patch = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { merge: { mergeStartColumn: "meeting", mergeEndColumn: "time", mergeText: "

Combined block

", }, }, ); assert.equal(patch.status, 200, "PATCH merge should succeed"); const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${today}`, ); assert.equal(day.status, 200); const body = await day.json(); const row = body.meetings.find((m) => m.id === meeting.id); assert.ok(row, "patched meeting should appear in GET ?date="); assert.equal(row.mergeStartColumn, "meeting"); assert.equal(row.mergeEndColumn, "time"); assert.match( row.mergeText ?? "", /Combined block/, "visible text from the merge label survives sanitization", ); }); test("PATCH merge: { merge: null } clears all three merge columns without touching the rest of the row", async () => { const meeting = await createMeeting("Merge clear", "مسح الدمج"); // Seed with a merge so we have something to clear. const setRes = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { merge: { mergeStartColumn: "number", mergeEndColumn: "attendees", mergeText: "to be cleared", }, }, ); assert.equal(setRes.status, 200); // Now clear via merge: null in the same patch as a benign title // change. The title change must persist and the merge must vanish. const clearRes = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { titleEn: "Merge cleared", merge: null, }, ); assert.equal(clearRes.status, 200, "clearing merge should succeed"); const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${today}`, ); const body = await day.json(); const row = body.meetings.find((m) => m.id === meeting.id); assert.ok(row); assert.equal(row.mergeStartColumn, null, "start column cleared"); assert.equal(row.mergeEndColumn, null, "end column cleared"); assert.equal(row.mergeText, null, "merge text cleared"); // Title change in the same PATCH was applied. assert.match(row.titleEn ?? "", /Merge cleared/); // Untouched fields stay intact. assert.equal(row.startTime, "09:00:00"); assert.equal(row.endTime, "10:00:00"); }); test("PATCH merge: invalid range (start > end) is rejected with 400 and the row is unchanged", async () => { const meeting = await createMeeting( "Merge invalid", "مدى غير صالح", ); // The schedule columns are ordered: number, meeting, attendees, time. // start=time, end=meeting reverses the order — must fail validation. const bad = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { merge: { mergeStartColumn: "time", mergeEndColumn: "meeting", mergeText: "should not be saved", }, }, ); assert.equal(bad.status, 400, "reversed range must be rejected"); // The row must not have been mutated by the rejected request. const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${today}`, ); const body = await day.json(); const row = body.meetings.find((m) => m.id === meeting.id); assert.ok(row); assert.equal(row.mergeStartColumn, null); assert.equal(row.mergeEndColumn, null); assert.equal(row.mergeText, null); // A column id outside the enum is also rejected. const badEnum = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { merge: { mergeStartColumn: "meeting", mergeEndColumn: "notes", mergeText: "x", }, }, ); assert.equal(badEnum.status, 400, "unknown column id must be rejected"); }); test("PATCH merge: mergeText is sanitized — script tags and JS handlers are stripped, visible text survives", async () => { const meeting = await createMeeting( "Merge sanitize", "تنقية الدمج", ); const dirty = '

Visible bold

' + '' + 'link' + ''; const patch = await api( adminCookie, "PATCH", `/api/executive-meetings/${meeting.id}`, { merge: { mergeStartColumn: "meeting", mergeEndColumn: "attendees", mergeText: dirty, }, }, ); assert.equal(patch.status, 200); const day = await api( adminCookie, "GET", `/api/executive-meetings?date=${today}`, ); const body = await day.json(); const row = body.meetings.find((m) => m.id === meeting.id); const text = row.mergeText ?? ""; // Visible content survives. assert.match(text, /Visible/, "visible text preserved"); assert.match(text, /<(strong|b)[\s>]/i, " bold preserved"); // The allowed inline color style on

must survive the sanitizer // — Tiptap-style formatting (color, bold, etc.) is intentionally // preserved so the merged label can carry the editor's formatting. // Different sanitizers normalize whitespace/quotes inside the style // attribute slightly differently, so the assertion just checks for // the rgb triple in any spacing. assert.match( text.toLowerCase(), /rgb\(\s*220\s*,\s*38\s*,\s*38\s*\)/, "allowed inline color style survives sanitization", ); // Dangerous markup gone. assert.ok(!/