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, and font controls (Tiptap-backed EditableCell). 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 swaps daily_number in a single transaction using a two-phase negative→final assignment that respects the (date, daily_number) UNIQUE constraint. 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. Backend changes - Widened titleAr / titleEn / attendees.name to text (applied via direct ALTER TABLE because db:push --force is currently blocked by Task #126). - New artifacts/api-server/src/lib/sanitize.ts with an allowlist for inline tags + safe inline styles. Applied to POST, PATCH, PUT-attendees, duplicate, and reorder paths so rich text round-trips safely. - Code-review fix: duplicate handler now re-sanitizes titleAr/titleEn. - Code-review fix: print page no longer interpolates the unsanitized attendee.title into dangerouslySetInnerHTML. - 4 new tests cover sanitization stripping, reorder happy path, cross-day rejection, and permission denial. Pre-existing app_permissions failures are unrelated and tracked by Task #126. Frontend changes - artifacts/tx-os/src/components/editable-cell.tsx with FormattingToolbar. - ScheduleSection wires saveTitle, saveAttendeeName, reorder mutation (optimistic with revert on error), highlight tick, and HighlightPrefs. - New SortableHeader component for column drag. - AttendeesCell now passes the original attendee index to PUT writes so edits reach the right row even after grouping into virtual/internal/ external sections. - Print page renders sanitized HTML via dangerouslySetInnerHTML on names and titles (titles still rendered as plain text on the print page). - Translation keys added in ar.json and en.json. Drift - Sanitization for attendee.title was identified by the architect review as a defense-in-depth gap and proposed as Task #130 rather than fixing inline; the print-page XSS path that depended on it was fixed directly.
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"google-auth-library": "^10.6.2",
|
||||
"pino": "^9",
|
||||
"pino-http": "^10",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
@@ -35,6 +36,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"esbuild-plugin-pino": "^2.3.3",
|
||||
"pg": "^8.20.0",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
const ALLOWED_FONT_FAMILIES = new Set<string>([
|
||||
"Cairo",
|
||||
"Tajawal",
|
||||
"Amiri",
|
||||
"Noto Naskh Arabic",
|
||||
"IBM Plex Sans Arabic",
|
||||
"system-ui",
|
||||
"sans-serif",
|
||||
"serif",
|
||||
"monospace",
|
||||
]);
|
||||
|
||||
const FONT_SIZE_RE = /^(?:[8-9]|[1-9]\d)px$/;
|
||||
const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i;
|
||||
|
||||
export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: ["span", "strong", "b", "em", "i", "u", "br"],
|
||||
allowedAttributes: {
|
||||
span: ["style"],
|
||||
strong: ["style"],
|
||||
b: ["style"],
|
||||
em: ["style"],
|
||||
i: ["style"],
|
||||
u: ["style"],
|
||||
},
|
||||
allowedStyles: {
|
||||
"*": {
|
||||
"color": [COLOR_RE],
|
||||
"background-color": [COLOR_RE],
|
||||
"font-weight": [/^(?:normal|bold|[1-9]00)$/i],
|
||||
"font-style": [/^(?:normal|italic|oblique)$/i],
|
||||
"text-decoration": [/^(?:none|underline)$/i],
|
||||
"text-align": [/^(?:left|right|center|start|end|justify)$/i],
|
||||
"font-size": [FONT_SIZE_RE],
|
||||
"font-family": [
|
||||
// Allow either an exact known family or a comma-separated list of
|
||||
// quoted/unquoted families that all match our allowlist.
|
||||
(value: string) => isAllowedFontFamilyValue(value),
|
||||
] as unknown as RegExp[],
|
||||
},
|
||||
},
|
||||
allowedSchemes: [],
|
||||
disallowedTagsMode: "discard",
|
||||
enforceHtmlBoundary: false,
|
||||
// Keep text content of disallowed tags rather than dropping it entirely;
|
||||
// this matters when a user pastes from Word and we strip the wrapper.
|
||||
exclusiveFilter: () => false,
|
||||
};
|
||||
|
||||
function isAllowedFontFamilyValue(raw: string): boolean {
|
||||
// Split on commas, strip whitespace + surrounding quotes, then check each
|
||||
// candidate name. If any unknown name appears we reject the whole value.
|
||||
const parts = raw.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
||||
if (parts.length === 0) return false;
|
||||
return parts.every((p) => ALLOWED_FONT_FAMILIES.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a Tiptap-style rich-text HTML string from a client.
|
||||
*
|
||||
* - Allowed tags: span, strong/b, em/i, u, br
|
||||
* - Allowed inline styles: color, background-color, font-weight, font-style,
|
||||
* text-decoration, text-align, font-size (px, 8-99), font-family (allowlist)
|
||||
* - All other tags/attributes/styles are stripped.
|
||||
*
|
||||
* Empty / null / undefined input becomes "".
|
||||
*/
|
||||
export function sanitizeRichText(input: unknown): string {
|
||||
if (input === null || input === undefined) return "";
|
||||
const s = typeof input === "string" ? input : String(input);
|
||||
if (s.length === 0) return "";
|
||||
return sanitizeHtml(s, RICH_TEXT_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for places that previously accepted plain text but now
|
||||
* accept rich text — null/empty preserved as null when needed.
|
||||
*/
|
||||
export function sanitizeRichTextOrNull(input: unknown): string | null {
|
||||
if (input === null || input === undefined) return null;
|
||||
const s = sanitizeRichText(input);
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
@@ -25,12 +25,14 @@ import {
|
||||
executiveMeetingFontSettingsTable,
|
||||
rolesTable,
|
||||
usersTable,
|
||||
type ExecutiveMeetingAttendee,
|
||||
} from "@workspace/db";
|
||||
import {
|
||||
requireAuth,
|
||||
requireExecutiveAccess,
|
||||
getEffectiveRoleIds,
|
||||
} from "../middlewares/auth";
|
||||
import { sanitizeRichText } from "../lib/sanitize";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -164,16 +166,20 @@ const timeSchema = z
|
||||
.regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM");
|
||||
const positiveIntSchema = z.number().int().positive();
|
||||
|
||||
// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap
|
||||
// output). The byte count grows quickly with inline <span style=...> wrappers,
|
||||
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
|
||||
// sanitizer strips disallowed tags/attrs before persistence.
|
||||
const attendeeSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
name: z.string().trim().min(1).max(5000),
|
||||
title: z.string().trim().max(200).nullable().optional(),
|
||||
attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const meetingBaseFields = {
|
||||
titleAr: z.string().trim().min(1).max(300),
|
||||
titleEn: z.string().trim().min(1).max(300),
|
||||
titleAr: z.string().trim().min(1).max(10000),
|
||||
titleEn: z.string().trim().min(1).max(10000),
|
||||
meetingDate: dateSchema,
|
||||
dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
|
||||
startTime: timeSchema.nullable().optional(),
|
||||
@@ -228,6 +234,14 @@ const duplicateSchema = z.object({
|
||||
targetDate: dateSchema,
|
||||
});
|
||||
|
||||
const reorderSchema = z.object({
|
||||
meetingDate: dateSchema,
|
||||
orderedIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(2, "Need at least 2 meetings to reorder")
|
||||
.max(200),
|
||||
});
|
||||
|
||||
const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/);
|
||||
|
||||
@@ -585,8 +599,8 @@ router.post(
|
||||
const dailyNumber =
|
||||
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
|
||||
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
||||
titleAr: data.titleAr,
|
||||
titleEn: data.titleEn ?? "",
|
||||
titleAr: sanitizeRichText(data.titleAr),
|
||||
titleEn: sanitizeRichText(data.titleEn ?? ""),
|
||||
meetingDate: data.meetingDate,
|
||||
dailyNumber,
|
||||
startTime: data.startTime ?? null,
|
||||
@@ -610,7 +624,7 @@ router.post(
|
||||
await tx.insert(executiveMeetingAttendeesTable).values(
|
||||
attendees.map((a, idx) => ({
|
||||
meetingId: meeting.id,
|
||||
name: a.name,
|
||||
name: sanitizeRichText(a.name),
|
||||
title: a.title ?? null,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
@@ -667,8 +681,10 @@ router.patch(
|
||||
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
|
||||
updatedBy: userId,
|
||||
};
|
||||
if (data.titleAr !== undefined) updateValues.titleAr = data.titleAr;
|
||||
if (data.titleEn !== undefined) updateValues.titleEn = data.titleEn ?? "";
|
||||
if (data.titleAr !== undefined)
|
||||
updateValues.titleAr = sanitizeRichText(data.titleAr);
|
||||
if (data.titleEn !== undefined)
|
||||
updateValues.titleEn = sanitizeRichText(data.titleEn ?? "");
|
||||
if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate;
|
||||
if (data.dailyNumber !== undefined && data.dailyNumber !== null)
|
||||
updateValues.dailyNumber = data.dailyNumber;
|
||||
@@ -697,7 +713,7 @@ router.patch(
|
||||
await tx.insert(executiveMeetingAttendeesTable).values(
|
||||
data.attendees.map((a, idx) => ({
|
||||
meetingId: id,
|
||||
name: a.name,
|
||||
name: sanitizeRichText(a.name),
|
||||
title: a.title ?? null,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
@@ -793,7 +809,7 @@ router.put(
|
||||
await tx.insert(executiveMeetingAttendeesTable).values(
|
||||
data.attendees.map((a, idx) => ({
|
||||
meetingId: id,
|
||||
name: a.name,
|
||||
name: sanitizeRichText(a.name),
|
||||
title: a.title ?? null,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
@@ -837,8 +853,11 @@ router.post(
|
||||
const nextNumber = await nextDailyNumber(tx, data.targetDate);
|
||||
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
||||
dailyNumber: nextNumber,
|
||||
titleAr: source.titleAr,
|
||||
titleEn: source.titleEn,
|
||||
// Re-sanitize on duplicate so a row that was somehow stored with
|
||||
// disallowed markup (e.g. raw SQL import, schema regression) does
|
||||
// not get cloned into a fresh meeting unchecked.
|
||||
titleAr: sanitizeRichText(source.titleAr),
|
||||
titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn,
|
||||
meetingDate: data.targetDate,
|
||||
startTime: source.startTime,
|
||||
endTime: source.endTime,
|
||||
@@ -859,9 +878,12 @@ router.post(
|
||||
if (!meeting) throw new Error("duplicate_failed");
|
||||
if (source.attendees && source.attendees.length > 0) {
|
||||
await tx.insert(executiveMeetingAttendeesTable).values(
|
||||
source.attendees.map((a, idx) => ({
|
||||
source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({
|
||||
meetingId: meeting.id,
|
||||
name: a.name,
|
||||
// source already-stored values are sanitized at write time, but
|
||||
// re-running keeps a defense-in-depth boundary if anything was
|
||||
// imported via raw SQL.
|
||||
name: sanitizeRichText(a.name),
|
||||
title: a.title,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
@@ -891,6 +913,127 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// REORDER (within a single day)
|
||||
// =====================================================================
|
||||
|
||||
router.post(
|
||||
"/executive-meetings/reorder",
|
||||
requireExecutiveAccess,
|
||||
requireMutate,
|
||||
async (req, res): Promise<void> => {
|
||||
const data = parseBody(res, reorderSchema, req.body);
|
||||
if (!data) return;
|
||||
const userId = req.session.userId!;
|
||||
const dedup = new Set(data.orderedIds);
|
||||
if (dedup.size !== data.orderedIds.length) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
|
||||
inArray(executiveMeetingsTable.id, data.orderedIds),
|
||||
),
|
||||
);
|
||||
if (rows.length !== data.orderedIds.length) {
|
||||
throw Object.assign(new Error("missing_or_cross_day"), {
|
||||
httpStatus: 400,
|
||||
code: "invalid_ids",
|
||||
});
|
||||
}
|
||||
// Snapshot the current (start_time, end_time, daily_number) sorted
|
||||
// by current daily_number — the slots we will redistribute.
|
||||
const slots = rows
|
||||
.slice()
|
||||
.sort((a, b) => a.dailyNumber - b.dailyNumber)
|
||||
.map((r) => ({
|
||||
startTime: r.startTime,
|
||||
endTime: r.endTime,
|
||||
dailyNumber: r.dailyNumber,
|
||||
}));
|
||||
const byId = new Map(rows.map((r) => [r.id, r] as const));
|
||||
// Two-phase update to avoid (date, daily_number) unique conflicts.
|
||||
// Phase 1: park into negative numbers.
|
||||
for (let i = 0; i < data.orderedIds.length; i++) {
|
||||
const id = data.orderedIds[i]!;
|
||||
await tx
|
||||
.update(executiveMeetingsTable)
|
||||
.set({ dailyNumber: -(i + 1), updatedBy: userId })
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
}
|
||||
// Phase 2: assign final slot.
|
||||
for (let i = 0; i < data.orderedIds.length; i++) {
|
||||
const id = data.orderedIds[i]!;
|
||||
const slot = slots[i]!;
|
||||
await tx
|
||||
.update(executiveMeetingsTable)
|
||||
.set({
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
dailyNumber: slot.dailyNumber,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
}
|
||||
await logAudit(tx, {
|
||||
action: "reorder",
|
||||
entityType: "meeting",
|
||||
entityId: data.orderedIds[0]!,
|
||||
oldValue: {
|
||||
meetingDate: data.meetingDate,
|
||||
order: rows
|
||||
.slice()
|
||||
.sort((a, b) => a.dailyNumber - b.dailyNumber)
|
||||
.map((r) => r.id),
|
||||
},
|
||||
newValue: {
|
||||
meetingDate: data.meetingDate,
|
||||
order: data.orderedIds,
|
||||
},
|
||||
performedBy: userId,
|
||||
});
|
||||
const updated = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
|
||||
inArray(executiveMeetingsTable.id, data.orderedIds),
|
||||
),
|
||||
);
|
||||
// Validate no two share dailyNumber post-update (defensive).
|
||||
const seen = new Set<number>();
|
||||
for (const u of updated) {
|
||||
if (seen.has(u.dailyNumber)) {
|
||||
throw new Error("duplicate_daily_number_after_reorder");
|
||||
}
|
||||
seen.add(u.dailyNumber);
|
||||
}
|
||||
return { updated, byId };
|
||||
});
|
||||
// Return the updated meetings in the new order, with attendees.
|
||||
const idToFetch = data.orderedIds;
|
||||
const fullList = await Promise.all(
|
||||
idToFetch.map((id) => fetchMeetingWithAttendees(id)),
|
||||
);
|
||||
res.json({ meetings: fullList.filter(Boolean) });
|
||||
} catch (err) {
|
||||
const status = (err as { httpStatus?: number }).httpStatus ?? 500;
|
||||
const code = (err as { code?: string }).code ?? "server_error";
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
res.status(status).json({ error: msg, code });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// REQUESTS
|
||||
// =====================================================================
|
||||
|
||||
@@ -538,3 +538,116 @@ test("PDF archives: POST creates a snapshot and GET returns it", async () => {
|
||||
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("Reorder: POST /reorder swaps daily numbers + start times within a day", async () => {
|
||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "أول", titleEn: "First", meetingDate: today,
|
||||
startTime: "09:00", endTime: "09:30",
|
||||
});
|
||||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ثاني", titleEn: "Second", meetingDate: today,
|
||||
startTime: "10:00", endTime: "10:30",
|
||||
});
|
||||
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ثالث", titleEn: "Third", meetingDate: today,
|
||||
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: today, orderedIds: [C.id, B.id, A.id] });
|
||||
assert.equal(reorder.status, 200);
|
||||
|
||||
const day = await api(adminCookie, "GET",
|
||||
`/api/executive-meetings?date=${today}`);
|
||||
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);
|
||||
// The first slot now holds C, the third now holds A.
|
||||
assert.ok(cAfter.dailyNumber < bAfter.dailyNumber);
|
||||
assert.ok(bAfter.dailyNumber < aAfter.dailyNumber);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user