7a2ae8434d
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true
405 lines
12 KiB
JavaScript
405 lines
12 KiB
JavaScript
// 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: "<p>Combined block</p>",
|
|
},
|
|
},
|
|
);
|
|
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 =
|
|
'<p style="color: rgb(220, 38, 38)">Visible <strong>bold</strong></p>' +
|
|
'<script>alert(1)</script>' +
|
|
'<a href="javascript:hack()" onerror="hack()">link</a>' +
|
|
'<img src=x onerror="hack()" />';
|
|
|
|
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, "<strong> bold preserved");
|
|
// The allowed inline color style on <p> 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(!/<script/i.test(text), "<script> stripped");
|
|
assert.ok(!/onerror/i.test(text), "onerror handler stripped");
|
|
assert.ok(!/javascript:/i.test(text), "javascript: scheme stripped");
|
|
assert.ok(!/<img/i.test(text), "<img> stripped");
|
|
});
|
|
|
|
test("PATCH merge: a non-editor (executive_viewer) gets 403 and the row is not mutated", async () => {
|
|
const meeting = await createMeeting(
|
|
"Merge forbidden",
|
|
"ممنوع",
|
|
);
|
|
|
|
const forbidden = await api(
|
|
viewerCookie,
|
|
"PATCH",
|
|
`/api/executive-meetings/${meeting.id}`,
|
|
{
|
|
merge: {
|
|
mergeStartColumn: "meeting",
|
|
mergeEndColumn: "time",
|
|
mergeText: "viewer attempt",
|
|
},
|
|
},
|
|
);
|
|
assert.equal(
|
|
forbidden.status,
|
|
403,
|
|
"viewer without mutate role must be rejected",
|
|
);
|
|
|
|
// Confirm via DB that nothing changed — defense in depth against
|
|
// a future regression where the handler updates the row before the
|
|
// 403 short-circuits.
|
|
const { rows } = await pool.query(
|
|
`SELECT merge_start_column, merge_end_column, merge_text
|
|
FROM executive_meetings WHERE id = $1`,
|
|
[meeting.id],
|
|
);
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].merge_start_column, null);
|
|
assert.equal(rows[0].merge_end_column, null);
|
|
assert.equal(rows[0].merge_text, null);
|
|
});
|