Files
TX/artifacts/api-server/tests/executive-meetings.test.mjs
T
riyadhafraa 82ae63f88e Task #108 — Executive Meetings Phase 2 (RBAC + audit + Zod + i18n)
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
  attendees column widest, tighter padding, navy/white/gray + red highlight
  only. Header label "م" → "#" (ar.json).

Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
  requests work without an existing meeting. db push applied.

Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full
rewrite:
- ALL routes (including /me) gated by requireExecutiveAccess plus fine-grained
  requireMutate / requireApprove / requireRequest / requireAdminAudit on the
  appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
  {status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
  {action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired DIRECTLY to a
  single shared upsertFontSettingsHandler (no Express method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- GET /notifications now accepts ?date=YYYY-MM-DD and joins through the
  meetings table to scope notifications to the selected day; returns []
  immediately when no meetings exist on that date.
- Removed all `as never` casts via $inferInsert/Partial typing.

Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES (admin +
  executive_*).

Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Tab-level RBAC: SECTIONS.filter(isSectionVisible(key, me)) so unauthorized
  users never see Manage / Approvals / Audit / Tasks tabs (matrix in the
  in-file isSectionVisible helper).
- Approvals: review() sends {status, reviewNotes}; new "Send back for edits"
  (needs_edit) button beside Approve / Reject.
- Requests default scope is "mine" for non-approvers and "all" for approvers
  (no over-exposure to plain requesters).
- Notifications visibility uses canRead AND the query is scoped by the same
  selected `date` as the schedule (queryKey + ?date=… included).
- PdfSection: primary "Print + Archive" button (testid em-print) does archive
  then print in one click; em-print-only and em-archive remain as separate
  buttons.
- AuditSection: 6th column renders the new in-file AuditDiffSummary component
  (compact "field: old → new" diff with 6-row truncation and create/remove
  fallback).
- New tWithFallback(t, key, fallback) helper used in audit + notifications
  cells (avoids strict TFunction overload errors); apiJson rewritten to
  extract body/headers separately and stringify rawBody, eliminating the
  "Record<string, unknown> not assignable to BodyInit" TS errors. tsc
  --noEmit on executive-meetings.tsx is now clean.
- Print page (executive-meetings-print.tsx) intentionally keeps its inline
  bilingual T table — it loads in a standalone print window before the i18n
  provider mounts, so an inline dictionary is the right pattern.

i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
  .audit.col.changes, .audit.{created, removed, moreChanges},
  .audit.entity.pdf_archive,
  .audit.action.{approved, rejected, needs_edit, withdraw, apply, duplicate,
  replace_attendees, done},
  .pdf.{print = "Print + archive" / "طباعة وأرشفة", printOnly,
  archivedAndPrinted}.

Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
  creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
  expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201, nested
  POST /:id/requests 201, PATCH /requests/:id status approval 200,
  audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
  200 with persisted data, /notifications?date=YYYY-MM-DD filters
  correctly.
- tsc --noEmit on artifacts/tx-os/src/pages/executive-meetings.tsx is
  clean (pre-existing admin.tsx errors are unrelated to this task).
- Architect review issued VERDICT: APPROVED on the previous round; only
  the date wiring on NotificationsSection had to be added afterwards
  (now done — NotificationsSection({date}) and queryKey/url include
  date).

Validation-round-5 fixes (this commit):
- Coordinator task scoping (server-side, blocking RBAC fix). Added
  TASK_BROAD_VIEW_ROLES (admin + office_manager + coord_lead). Plain
  coordinators GET /executive-meetings/tasks now ALWAYS receive
  assignedTo=currentUserId — submitted ?mine=0 / ?assigneeId=other are
  ignored. /me also exposes a new canViewAllTasks flag for the UI.
- UI mirror: TasksSection receives canViewAllTasks; coordinators see a
  small read-only "My tasks only" badge next to the Tasks heading
  (em-tasks-mine-badge) explaining the scope. New i18n keys
  executiveMeetings.tasks.{myTasksOnly,myTasksOnlyHelp} (AR + EN).
- Seed: scripts/src/seed.ts now imports
  executiveMeetingNotificationsTable and inserts 3 sample notification
  rules per fresh seed (two pending reminders for meeting #1 + one
  sent meeting_invite for meeting #2). Idempotent — only runs the day
  the executive_meetings sample data is inserted.
- Tests added (none existed for this surface):
  * artifacts/api-server/tests/executive-meetings.test.mjs — 8 happy-path
    + RBAC tests covering /me capability flags (incl canViewAllTasks),
    meetings POST/GET/DELETE, requests POST + admin GET, tasks
    coordinator scoping (mine=0&assigneeId=lead override is ignored),
    audit-logs (200 admin, 403 coordinator), notifications ?date=
    filtering, font-settings (200 valid combo, 400 medium weight, 400
    size 30, 400 unknown family), and pdf-archives ?date=. All 8 pass.
  * artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs —
    Playwright UI test that logs in as admin, opens the Manage tab via
    em-nav-manage, fills the create dialog (titleAr + titleEn + date),
    saves, asserts POST /api/executive-meetings returns 201, and
    verifies the row landed in the DB with the expected title and date.

Validation-round-4 fixes:
- Manage attendee editor: added per-row title input alongside name +
  deterministic ChevronUp/ChevronDown reorder controls (sort order is
  recomputed on save). New i18n keys
  executiveMeetings.manage.attendees.{moveUp,moveDown}.
- Manage tab: per-row Copy button opens a new "Duplicate to date" dialog
  that calls POST /executive-meetings/:id/duplicate with a target date
  picker; on success it switches the day view to the chosen date. New
  i18n keys executiveMeetings.manage.{duplicate, duplicateToDate,
  duplicated, duplicateFailed}.
- Print page (executive-meetings-print.tsx) now uses react-i18next with
  new locale block executiveMeetings.print.* (AR + EN parity), forces
  i18n.changeLanguage(lang) so ?lang=en always prints English. Attendees
  cell in the print table is now textAlign: center (matches the Step 1
  schedule).
- Font settings tightened to spec: families = system/Cairo/Tajawal/
  Noto Naskh Arabic/Amiri (dropped Inter, monospace), weights =
  regular/bold (dropped medium, semibold), alignment = start/center
  (dropped end), size range = 12–22 (was 10–32). Enforced server-side
  in fontSettingsSchema (Zod) and mirrored in the UI selects/range.
  Verified: PATCH font-settings returns 200 for valid combo, 400 for
  fontWeight="medium", 400 for fontSize=30.

Validation-round-3 fixes:
- Tasks RBAC tightened end-to-end: new TASK_VIEW_ROLES = admin +
  office_manager + coord_lead + coordinator. GET /tasks and
  PATCH /tasks/:id now require requireTaskView. /me exposes new
  canViewTasks flag. Frontend isSectionVisible("tasks") gated on
  canViewTasks (so CEO/viewer never sees the Tasks tab or hits the
  endpoint). Verified: admin /me returns canViewTasks=true.
- GET /requests gained dateFrom + dateTo (createdAt range) and a
  requester filter that is honored only for approver/audit roles
  (non-approvers stay locked to their own requests). Verified curl
  with ?dateFrom=...&dateTo=... returns 200.
- Task dueAt schema accepts BOTH YYYY-MM-DD (from <input type="date">)
  and full ISO datetime; date-only is normalized to start-of-day UTC,
  empty string clears the field. Verified curl POST /tasks with
  {"dueAt":"2026-12-31"} returns 201 with stored
  dueAt = 2026-12-31T00:00:00.000Z.

Validation-round-2 fixes:
- GET /executive-meetings/requests: added server-side least-privilege so
  only APPROVE_ROLES + ADMIN_AUDIT_ROLES see other users' requests; every
  other executive role is force-scoped to requestedBy = self regardless
  of the client's `mine` flag (closes the RBAC overexposure flag).
- isSectionVisible("tasks") now also returns true for canSubmitRequest,
  so executive_coordinator sees the Tasks tab (coordinators execute the
  follow-ups generated from approved requests).
- Requests "new request" dropdown now exposes ALL twelve backend request
  types: create, edit, delete, reschedule, add_attendee, remove_attendee,
  change_location, cancel_meeting, note, highlight, unhighlight, other.
- i18n parity: added en.json + ar.json keys for the five missing request
  types (add_attendee, remove_attendee, change_location, cancel_meeting,
  note) and the two missing statuses (needs_edit, done) under
  executiveMeetings.requests.{type,status}.
- Fixed wrong key reference: reschedule form's date row was using
  manage.field.date (does not exist) — switched to manage.field.meetingDate.

Drift / out of scope (explicitly deferred to follow-ups #110/#111/#112):
- Real notifications delivery, server-side PDF rendering, attendee
  reorder UI (drag/arrows), duplicate-to-date UI, OpenAPI/api-spec sync,
  expanded seed data including executive_meeting_notifications sample
  rows.
2026-04-28 08:02:41 +00:00

388 lines
12 KiB
JavaScript

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);
const roles = ["user", roleName];
for (const r of roles) {
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 () => {
// Reuse the seeded admin (admin/admin123) for the broad-view path.
adminCookie = await login("admin", "admin123");
const meRes = await api(adminCookie, "GET", "/api/auth/me");
const me = await meRes.json();
adminUserId = me.id ?? me.userId;
// Create a fresh plain coordinator and a coord_lead so we can check the
// server-side scoping behavior of GET /tasks (coordinator must only ever
// see their own tasks even with mine=0/assigneeId=other).
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 () => {
// Best-effort cleanup; per-test rows may have already been deleted.
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 /executive-meetings/me exposes the full capability flag set 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,
"plain coordinators must not get cross-user task visibility",
);
});
test("Meetings: POST creates → GET day returns it → DELETE removes it", 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("Requests: POST creates → admin GET sees it; coord scope filter respects requester", 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("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => {
// Admin creates a task assigned to the coordinator and another assigned to
// the lead. The coordinator must only see the first one even when they pass
// mine=0 / assigneeId=lead.
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);
// Plain coordinator: mine=0 + assigneeId=leadUserId attempts override; the
// server must ignore both and force assignedTo=coordUserId.
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),
"coordinator should see their own task",
);
assert.ok(
!coordIds.includes(task2.id),
"coordinator must NOT see another user's task even with assigneeId override",
);
for (const t of coordBody.tasks) {
assert.equal(
t.assignedTo,
coordUserId,
"every task returned to a coordinator must be assigned to them",
);
}
// Lead: broad view honors assigneeId.
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("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("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 returns 200; invalid weight or out-of-range size returns 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: GET returns an array and supports ?date=", async () => {
const res = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(Array.isArray(body.archives ?? body.items ?? body.pdfArchives ?? []));
});