diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index a62bdcb2..f3d0b54a 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -9,7 +9,7 @@ "start": "node --enable-source-maps ./dist/index.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test:wait": "node ./scripts/wait-for-server.mjs", - "test:run": "node --test 'tests/*.test.mjs' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'", + "test:run": "node --test 'tests/*.test.mjs' && node --import tsx --test --experimental-test-module-mocks 'tests/*.test.ts' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'", "test": "node ./scripts/with-server.mjs -- pnpm run test:run", "migrate": "pnpm --filter @workspace/db run push && pnpm --filter scripts run seed" }, @@ -56,6 +56,7 @@ "pg": "^8.20.0", "pino-pretty": "^13", "socket.io-client": "^4.8.3", - "thread-stream": "3.1.0" + "thread-stream": "3.1.0", + "tsx": "catalog:" } } diff --git a/artifacts/api-server/tests/push-410-unit.test.ts b/artifacts/api-server/tests/push-410-unit.test.ts new file mode 100644 index 00000000..8905aa1c --- /dev/null +++ b/artifacts/api-server/tests/push-410-unit.test.ts @@ -0,0 +1,184 @@ +// True unit test for the 404/410 cleanup path in sendPushToUser. +// +// Runs in-process via `tsx` so we can `mock.module('web-push', ...)` to +// stub the actual delivery call. We: +// 1. Insert a real row in `push_subscriptions` for a freshly-created +// throwaway user. +// 2. Force the mocked `webpush.sendNotification` to throw a 410 once +// (and 404 once) the way the FCM/APNs bridges do. +// 3. Call `sendPushToUser` directly and assert the row was pruned. +// +// This is the closest we can get to exercising the production prune +// path without standing up a real push gateway, and it exercises the +// real DB + real lib code (only `web-push` and the socket-presence +// check are stubbed). +// +// Run with: +// pnpm --filter @workspace/api-server exec \ +// node --import tsx --test tests/push-410-unit.test.ts +import { test, before, after, mock } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +// Track sendNotification calls and let each test rig the next response. +const sendCalls: Array<{ endpoint: string }> = []; +let nextThrow: { statusCode: number } | null = null; + +mock.module("web-push", { + defaultExport: { + setVapidDetails: () => {}, + generateVAPIDKeys: () => ({ + publicKey: "B".padEnd(87, "A"), + privateKey: "C".padEnd(43, "A"), + }), + sendNotification: async (sub: { endpoint: string }) => { + sendCalls.push({ endpoint: sub.endpoint }); + if (nextThrow) { + const err: Error & { statusCode?: number } = new Error("stubbed"); + err.statusCode = nextThrow.statusCode; + nextThrow = null; + throw err; + } + return { statusCode: 201, body: "", headers: {} }; + }, + }, +}); + +// Block the socket-presence dedup gate from short-circuiting the send. +// `sendPushToUser` lazy-imports `../index.js` to read `io`; throwing +// here makes `isUserConnected` swallow the error and return false. +mock.module("../src/index.js", { + namedExports: { + get io() { + throw new Error("io not available in unit test"); + }, + }, +}); + +// Ensure VAPID is configured (the lib has its own fallback, but being +// explicit avoids surprise re-keying between runs). +process.env.VAPID_PUBLIC_KEY = + process.env.VAPID_PUBLIC_KEY ?? "B".padEnd(87, "A"); +process.env.VAPID_PRIVATE_KEY = + process.env.VAPID_PRIVATE_KEY ?? "C".padEnd(43, "A"); +process.env.VAPID_SUBJECT = process.env.VAPID_SUBJECT ?? "mailto:test@example.com"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); +const createdUserIds: number[] = []; + +async function createUser(prefix: string): Promise { + const username = `${prefix}_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; + const { rows } = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, '$2b$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $3, 'en', true) + RETURNING id`, + [username, `${username}@example.com`, prefix], + ); + const id = rows[0].id as number; + createdUserIds.push(id); + return id; +} + +async function insertSub(userId: number, endpoint: string): Promise { + await pool.query( + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) + VALUES ($1, $2, 'p256dh_test', 'auth_test')`, + [userId, endpoint], + ); +} + +async function rowExists(endpoint: string): Promise { + const r = await pool.query( + `SELECT 1 FROM push_subscriptions WHERE endpoint = $1`, + [endpoint], + ); + return (r.rowCount ?? 0) > 0; +} + +// Import AFTER mocks are registered. +const { sendPushToUser } = await import("../src/lib/push.js"); + +before(() => { + // Touch sendCalls so the import is exercised. + sendCalls.length = 0; +}); + +after(async () => { + if (createdUserIds.length > 0) { + await pool.query( + `DELETE FROM push_subscriptions WHERE user_id = ANY($1::int[])`, + [createdUserIds], + ); + await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ + createdUserIds, + ]); + } + await pool.end(); +}); + +test("410 Gone response prunes the stale push subscription", async () => { + const userId = await createUser("p410"); + const endpoint = `https://push.example.test/410-${Date.now()}`; + await insertSub(userId, endpoint); + assert.equal(await rowExists(endpoint), true); + + nextThrow = { statusCode: 410 }; + await sendPushToUser(userId, { + title: "t", + body: "b", + type: "note", + }); + + assert.equal(sendCalls.at(-1)?.endpoint, endpoint, "stub should have been called"); + assert.equal( + await rowExists(endpoint), + false, + "row must be pruned after a 410 response", + ); +}); + +test("404 Not Found response prunes the stale push subscription", async () => { + const userId = await createUser("p404"); + const endpoint = `https://push.example.test/404-${Date.now()}`; + await insertSub(userId, endpoint); + assert.equal(await rowExists(endpoint), true); + + nextThrow = { statusCode: 404 }; + await sendPushToUser(userId, { + title: "t", + body: "b", + type: "note", + }); + + assert.equal( + await rowExists(endpoint), + false, + "row must be pruned after a 404 response", + ); +}); + +test("non-410/404 errors leave the subscription intact", async () => { + const userId = await createUser("p500"); + const endpoint = `https://push.example.test/500-${Date.now()}`; + await insertSub(userId, endpoint); + + nextThrow = { statusCode: 500 }; + await sendPushToUser(userId, { + title: "t", + body: "b", + type: "note", + }); + + assert.equal( + await rowExists(endpoint), + true, + "transient 5xx must NOT delete the subscription", + ); +}); diff --git a/artifacts/api-server/tests/serial/executive-meetings-notifications.test.mjs b/artifacts/api-server/tests/serial/executive-meetings-notifications.test.mjs new file mode 100644 index 00000000..17fe8b6c --- /dev/null +++ b/artifacts/api-server/tests/serial/executive-meetings-notifications.test.mjs @@ -0,0 +1,665 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; +import { io as ioClient } from "socket.io-client"; + +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: [], + groupIds: [], + meetingIds: [], +}; + +function uniqueName(prefix) { + return `${prefix}_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +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); +} + +async function makeUser(prefix) { + 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, $4, 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], + ); + const id = rows[0].id; + created.userIds.push(id); + return { id, username }; +} + +async function grantRoleDirect(userId, roleName) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = $2 + ON CONFLICT DO NOTHING`, + [userId, roleName], + ); +} + +async function grantRoleViaGroup(userId, roleName, groupName) { + const g = await pool.query( + `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`, + [groupName, "notif fan-out test group"], + ); + const groupId = g.rows[0].id; + created.groupIds.push(groupId); + await pool.query( + `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, + [userId, groupId], + ); + await pool.query( + `INSERT INTO group_roles (group_id, role_id) + SELECT $1, id FROM roles WHERE name = $2`, + [groupId, roleName], + ); + return groupId; +} + +function connectSocket(cookie) { + return new Promise((resolve, reject) => { + const events = { created: [], changed: [] }; + const socket = ioClient(API_BASE, { + path: "/api/socket.io", + transports: ["websocket"], + forceNew: true, + reconnection: false, + extraHeaders: { Cookie: cookie }, + }); + socket.on("notification_created", (payload) => { + events.created.push(payload); + }); + socket.on("executive_meeting_notifications_changed", (payload) => { + events.changed.push(payload); + }); + socket.on("connect", () => resolve({ socket, events })); + socket.on("connect_error", (err) => reject(err)); + }); +} + +function clearSocketEvents(s) { + s.events.created.length = 0; + s.events.changed.length = 0; +} + +function waitMs(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +// Broadcasts fire via `void broadcast...()` after the HTTP response, so +// poll briefly until the captured events satisfy the predicate. +async function waitFor(predicate, { timeoutMs = 1500, intervalMs = 25 } = {}) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (predicate()) return; + await waitMs(intervalMs); + } + if (!predicate()) { + throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`); + } +} + +async function snapshotMaxIds() { + const en = await pool.query( + `SELECT COALESCE(MAX(id), 0) AS m FROM executive_meeting_notifications`, + ); + const n = await pool.query( + `SELECT COALESCE(MAX(id), 0) AS m FROM notifications`, + ); + return { emnMax: Number(en.rows[0].m), nMax: Number(n.rows[0].m) }; +} + +async function newRowsAfter(snap) { + const emn = await pool.query( + `SELECT id, + meeting_id AS "meetingId", + user_id AS "userId", + notification_type AS "notificationType", + status, + sent_at AS "sentAt" + FROM executive_meeting_notifications + WHERE id > $1 + ORDER BY id`, + [snap.emnMax], + ); + const n = await pool.query( + `SELECT id, + user_id AS "userId", + title_en AS "titleEn", + title_ar AS "titleAr", + type, + related_type AS "relatedType", + related_id AS "relatedId" + FROM notifications + WHERE id > $1 + ORDER BY id`, + [snap.nMax], + ); + return { emn: emn.rows, notifications: n.rows }; +} + +function rowsForUser(rows, userId) { + return rows.filter((r) => Number(r.userId) === Number(userId)); +} + +// Scope a snapshot diff to a single trigger so parallel test files +// writing notifications for the seeded admin actor can't trip our checks. +function scopeDiff(diff, { notificationType, meetingId, relatedType, relatedId }) { + const emn = diff.emn.filter((r) => { + if (notificationType && r.notificationType !== notificationType) return false; + if (meetingId !== undefined && meetingId !== null) { + if (Number(r.meetingId) !== Number(meetingId)) return false; + } + return true; + }); + const notifications = diff.notifications.filter((r) => { + if (relatedType && r.relatedType !== relatedType) return false; + if (relatedId !== undefined && relatedId !== null) { + if (Number(r.relatedId) !== Number(relatedId)) return false; + } + return true; + }); + return { emn, notifications }; +} + +let adminCookie = null; +let adminUserId = null; + +let approver1 = null; +let approver2 = null; +let coord1 = null; +let coord2 = null; + +let approver1Sock = null; +let approver2Sock = null; +let coord1Sock = null; +let coord2Sock = 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; + + approver1 = await makeUser("notif_appr1"); + await grantRoleDirect(approver1.id, "user"); + await grantRoleDirect(approver1.id, "executive_office_manager"); + approver1.cookie = await login(approver1.username, TEST_PASSWORD); + + approver2 = await makeUser("notif_appr2"); + await grantRoleDirect(approver2.id, "user"); + await grantRoleDirect(approver2.id, "executive_office_manager"); + // approver2 also has the role via a group, so getUserIdsForRoleNames + // sees them on both the direct and group-derived paths — dedupe must + // collapse this to a single row. + await grantRoleViaGroup( + approver2.id, + "executive_office_manager", + uniqueName("notif_grp"), + ); + approver2.cookie = await login(approver2.username, TEST_PASSWORD); + + coord1 = await makeUser("notif_coord1"); + await grantRoleDirect(coord1.id, "user"); + await grantRoleDirect(coord1.id, "executive_coordinator"); + coord1.cookie = await login(coord1.username, TEST_PASSWORD); + + coord2 = await makeUser("notif_coord2"); + await grantRoleDirect(coord2.id, "user"); + await grantRoleDirect(coord2.id, "executive_coordinator"); + coord2.cookie = await login(coord2.username, TEST_PASSWORD); + + approver1Sock = await connectSocket(approver1.cookie); + approver2Sock = await connectSocket(approver2.cookie); + coord1Sock = await connectSocket(coord1.cookie); + coord2Sock = await connectSocket(coord2.cookie); +}); + +after(async () => { + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + try { + s?.socket?.disconnect(); + } catch { + /* ignore */ + } + } + 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 = 'meeting'`, + [created.meetingIds], + ); + await pool.query( + `DELETE FROM executive_meeting_notifications WHERE meeting_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 executive_meeting_notifications WHERE user_id = ANY($1::int[])`, + [created.userIds], + ); + await pool.query( + `DELETE FROM notifications WHERE user_id = ANY($1::int[])`, + [created.userIds], + ); + await pool.query( + `DELETE FROM executive_meeting_notification_prefs WHERE user_id = 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 user_groups WHERE user_id = ANY($1::int[])`, [ + created.userIds, + ]); + await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ + created.userIds, + ]); + } + if (created.groupIds.length > 0) { + await pool.query( + `DELETE FROM group_roles WHERE group_id = ANY($1::int[])`, + [created.groupIds], + ); + await pool.query( + `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, + [created.groupIds], + ); + await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ + created.groupIds, + ]); + } + await pool.end(); +}); + +const today = new Date().toISOString().slice(0, 10); + +function assertRecipientGotOneOfEach(diff, userId, expected) { + const emnRows = rowsForUser(diff.emn, userId); + assert.equal( + emnRows.length, + 1, + `user ${userId} must have exactly 1 executive_meeting_notifications row, got ${emnRows.length}`, + ); + const emn = emnRows[0]; + assert.equal(emn.notificationType, expected.notificationType); + assert.equal(emn.status, "sent"); + assert.ok(emn.sentAt, "sent_at must be populated"); + if (expected.meetingId !== undefined) { + assert.equal(emn.meetingId, expected.meetingId); + } + + const nRows = rowsForUser(diff.notifications, userId); + assert.equal( + nRows.length, + 1, + `user ${userId} must have exactly 1 notifications row, got ${nRows.length}`, + ); + const n = nRows[0]; + assert.equal(n.type, "executive_meeting"); + if (expected.relatedType !== undefined) { + assert.equal(n.relatedType, expected.relatedType); + } + if (expected.relatedId !== undefined) { + assert.equal(Number(n.relatedId), Number(expected.relatedId)); + } +} + +function assertActorExcluded(diff, actorId) { + assert.equal( + rowsForUser(diff.emn, actorId).length, + 0, + `actor ${actorId} must NOT receive an executive_meeting_notifications row`, + ); + assert.equal( + rowsForUser(diff.notifications, actorId).length, + 0, + `actor ${actorId} must NOT receive a notifications row`, + ); +} + +async function expectSocketEventsFor( + userSocks, + notificationType, + meetingId, + { expectExactlyOne = false } = {}, +) { + await waitFor( + () => + userSocks.every((s) => + s.events.created.some((p) => p.notificationType === notificationType), + ) && + userSocks.some((s) => + s.events.changed.some( + (p) => p.notificationType === notificationType, + ), + ), + { timeoutMs: 2000 }, + ); + // Give a brief grace window so any duplicate emission would also land + // before we count, otherwise the strict count check could pass by + // racing the second event. + if (expectExactlyOne) await waitMs(150); + for (const s of userSocks) { + const matches = s.events.created.filter( + (p) => p.notificationType === notificationType, + ); + assert.ok( + matches.length >= 1, + `socket should have received notification_created for ${notificationType}`, + ); + if (expectExactlyOne) { + assert.equal( + matches.length, + 1, + `socket should have received exactly 1 notification_created for ${notificationType}, got ${matches.length}`, + ); + } + const got = matches[0]; + assert.equal(got.type, "executive_meeting"); + if (meetingId !== undefined) { + assert.equal(got.meetingId, meetingId); + } + } + const anyChanged = userSocks + .flatMap((s) => s.events.changed) + .find((p) => p.notificationType === notificationType); + assert.ok( + anyChanged, + `executive_meeting_notifications_changed must fire for ${notificationType}`, + ); + if (meetingId !== undefined) { + assert.equal(anyChanged.meetingId, meetingId); + } +} + +function expectNoSocketEventFor(sock, notificationType) { + const offending = sock.events.created.find( + (p) => p.notificationType === notificationType, + ); + assert.equal( + offending, + undefined, + `socket should NOT have received notification_created for ${notificationType}`, + ); +} + +test("meeting_created: fan-out excludes actor, dedupes direct+group, writes both tables, emits sockets", async () => { + for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) { + clearSocketEvents(s); + } + const before = await snapshotMaxIds(); + + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اجتماع إخطارات", + titleEn: "Notif fan-out meeting", + meetingDate: today, + attendees: [], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + await waitFor( + () => + approver1Sock.events.created.some( + (p) => p.notificationType === "meeting_created", + ) && + approver2Sock.events.created.some( + (p) => p.notificationType === "meeting_created", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + + assertRecipientGotOneOfEach(diff, approver1.id, { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + assertRecipientGotOneOfEach(diff, approver2.id, { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + assertActorExcluded(diff, adminUserId); + + await expectSocketEventsFor( + [approver1Sock, approver2Sock], + "meeting_created", + meeting.id, + { expectExactlyOne: true }, + ); + expectNoSocketEventFor(coord1Sock, "meeting_created"); + expectNoSocketEventFor(coord2Sock, "meeting_created"); +}); + + +// #238: opt-out coverage for filterRecipientsByNotificationPref. The HTTP +// surface gives us black-box access — set a pref row, trigger the event, +// assert delivery (or non-delivery) at the EMN + notifications + socket +// layer. Each test owns its own pref row(s) and cleans them up so the +// suite stays order-independent. +async function setPref(userId, notificationType, { inApp, email }) { + await pool.query( + `INSERT INTO executive_meeting_notification_prefs + (user_id, notification_type, in_app, email) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, notification_type) + DO UPDATE SET in_app = EXCLUDED.in_app, email = EXCLUDED.email`, + [userId, notificationType, inApp, email], + ); +} + +async function clearPref(userId, notificationType) { + await pool.query( + `DELETE FROM executive_meeting_notification_prefs + WHERE user_id = $1 AND notification_type = $2`, + [userId, notificationType], + ); +} + +test("pref opt-out: inApp=false drops the user from in-app fan-out (others unaffected)", async () => { + await setPref(approver1.id, "meeting_created", { inApp: false, email: true }); + try { + for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s); + const before = await snapshotMaxIds(); + + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "اختبار تعطيل", + titleEn: "Opt-out fan-out", + meetingDate: today, + attendees: [], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + // Wait for approver2 (default-on) so we know fan-out completed before + // checking that approver1 was dropped. + await waitFor( + () => + approver2Sock.events.created.some( + (p) => p.notificationType === "meeting_created", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + + assert.equal( + rowsForUser(diff.emn, approver1.id).length, + 0, + "opted-out user must NOT get an executive_meeting_notifications row", + ); + assert.equal( + rowsForUser(diff.notifications, approver1.id).length, + 0, + "opted-out user must NOT get a notifications row", + ); + assertRecipientGotOneOfEach(diff, approver2.id, { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + expectNoSocketEventFor(approver1Sock, "meeting_created"); + } finally { + await clearPref(approver1.id, "meeting_created"); + } +}); + +test("pref opt-out: missing pref row defaults to ON (user receives delivery)", async () => { + // No setPref call — approver1 has no row at all for this event type. + // This is the implicit default-on case. Assert it explicitly so a + // future regression (e.g. flipping the default to off) is caught. + await pool.query( + `DELETE FROM executive_meeting_notification_prefs + WHERE user_id = $1 AND notification_type = $2`, + [approver1.id, "meeting_created"], + ); + + for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s); + const before = await snapshotMaxIds(); + + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "افتراضي مفعل", + titleEn: "Default-on fan-out", + meetingDate: today, + attendees: [], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + await waitFor( + () => + approver1Sock.events.created.some( + (p) => p.notificationType === "meeting_created", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + assertRecipientGotOneOfEach(diff, approver1.id, { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); +}); + +test("pref opt-out: email=false does NOT affect the in-app channel", async () => { + // Channel independence — muting email must leave in-app delivery alone. + await setPref(approver1.id, "meeting_created", { inApp: true, email: false }); + try { + for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s); + const before = await snapshotMaxIds(); + + const create = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "قناة منفصلة", + titleEn: "Channel independence", + meetingDate: today, + attendees: [], + }); + assert.equal(create.status, 201); + const meeting = await create.json(); + created.meetingIds.push(meeting.id); + + await waitFor( + () => + approver1Sock.events.created.some( + (p) => p.notificationType === "meeting_created", + ), + { timeoutMs: 2000 }, + ); + + const diff = scopeDiff(await newRowsAfter(before), { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + assertRecipientGotOneOfEach(diff, approver1.id, { + notificationType: "meeting_created", + meetingId: meeting.id, + relatedType: "executive_meeting", + relatedId: meeting.id, + }); + } finally { + await clearPref(approver1.id, "meeting_created"); + } +}); diff --git a/artifacts/api-server/tests/serial/setup-wizard.test.mjs b/artifacts/api-server/tests/serial/setup-wizard.test.mjs new file mode 100644 index 00000000..8c02bbe5 --- /dev/null +++ b/artifacts/api-server/tests/serial/setup-wizard.test.mjs @@ -0,0 +1,330 @@ +import { test, after, before } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +// IMPORTANT: this file mutates shared tables (system_settings, user_roles) +// during snapshot/restore. node:test runs tests within a single file +// serially by default, but DO NOT run this file concurrently with other +// suites that touch the same rows. The repository-level `test` workflow +// runs api-server tests as one `node --test 'tests/**/*.test.mjs'` invocation +// which executes files in parallel — keep this file in its own logical +// group (already enforced by the snapshot/restore around every assertion) +// or migrate to a dedicated test schema if you add adjacent suites that +// also touch system_settings. +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 pool = new pg.Pool({ connectionString: DATABASE_URL }); + +// ----- snapshot + restore helpers ------------------------------------------------- +// The setup-wizard test needs to run against a DB that has NO admin and +// `system_settings.installed=false`. The shared dev DB already has both +// (seed runs at boot), so we snapshot the relevant rows, blank them +// out, run the test, then restore exactly what was there. +let snapshot = { + systemSettings: null, + adminUserIds: [], + adminUserRoleRows: [], + createdUsernames: [], +}; + +const TEST_USERNAME = `setupwiz_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; +const TEST_EMAIL = `${TEST_USERNAME}@tx.local`; + +before(async () => { + // Snapshot system_settings. + const sysRow = await pool.query("SELECT * FROM system_settings WHERE id = 1"); + snapshot.systemSettings = sysRow.rows[0] ?? null; + + // Snapshot every user with the admin role + their user_roles edges. + const adminRow = await pool.query( + "SELECT id FROM roles WHERE name = 'admin' LIMIT 1", + ); + if (adminRow.rowCount === 0) { + throw new Error("admin role missing in DB — seed first"); + } + const adminRoleId = adminRow.rows[0].id; + + const adminUsers = await pool.query( + `SELECT u.id FROM users u + JOIN user_roles ur ON ur.user_id = u.id + WHERE ur.role_id = $1`, + [adminRoleId], + ); + snapshot.adminUserIds = adminUsers.rows.map((r) => r.id); + + // Detach admin role from those users so the open-setup gate kicks in. + if (snapshot.adminUserIds.length > 0) { + const detached = await pool.query( + `DELETE FROM user_roles WHERE role_id = $1 AND user_id = ANY($2::int[]) + RETURNING user_id, role_id`, + [adminRoleId, snapshot.adminUserIds], + ); + snapshot.adminUserRoleRows = detached.rows; + } + + // Wipe install flag so the wizard treats this as a fresh install. + await pool.query("DELETE FROM system_settings WHERE id = 1"); +}); + +after(async () => { + // Remove any user the test created. + if (snapshot.createdUsernames.length > 0) { + await pool.query( + "DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1::text[]))", + [snapshot.createdUsernames], + ); + await pool.query( + "DELETE FROM user_groups WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1::text[]))", + [snapshot.createdUsernames], + ); + await pool.query("DELETE FROM users WHERE username = ANY($1::text[])", [ + snapshot.createdUsernames, + ]); + } + + // Restore system_settings to its pre-test state. + await pool.query("DELETE FROM system_settings WHERE id = 1"); + if (snapshot.systemSettings) { + const r = snapshot.systemSettings; + await pool.query( + `INSERT INTO system_settings + (id, installed, installed_at, base_url, local_domain, local_ip, https_mode, app_version, updated_at) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8)`, + [ + r.installed, + r.installed_at, + r.base_url, + r.local_domain, + r.local_ip, + r.https_mode, + r.app_version, + r.updated_at, + ], + ); + } + + // Re-attach the admin role to the original admin users. + if (snapshot.adminUserRoleRows.length > 0) { + for (const row of snapshot.adminUserRoleRows) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [row.user_id, row.role_id], + ); + } + } + + await pool.end(); +}); + +test("GET /api/setup/status reports setupRequired=true on a fresh DB", async () => { + const res = await fetch(`${API_BASE}/api/setup/status`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.installed, false); + assert.equal(body.setupRequired, true); + assert.equal(body.checks.db, "ok"); + assert.ok(typeof body.appVersion === "string"); +}); + +test("POST /api/setup/validate returns ok for a clean payload", async () => { + const res = await fetch(`${API_BASE}/api/setup/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: TEST_USERNAME, + email: TEST_EMAIL, + password: "ValidPass123!", + }, + }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); +}); + +test("POST /api/setup/validate returns 400 + per-field errors for invalid input", async () => { + const res = await fetch(`${API_BASE}/api/setup/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { username: "x", email: "not-an-email", password: "short" }, + }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.ok, false); + assert.ok(body.errors); +}); + +test("POST /api/setup/complete creates the admin and flips installed", async () => { + const res = await fetch(`${API_BASE}/api/setup/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: TEST_USERNAME, + email: TEST_EMAIL, + password: "ValidPass123!", + displayNameEn: "Wizard Test Admin", + }, + baseUrl: "https://tx.test.local", + localDomain: "tx.test.local", + localIp: "192.0.2.42", + httpsMode: "local", + }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + snapshot.createdUsernames.push(TEST_USERNAME); + + // Verify directly in DB. + const userRow = await pool.query( + "SELECT id FROM users WHERE username = $1", + [TEST_USERNAME], + ); + assert.equal(userRow.rowCount, 1); + const newId = userRow.rows[0].id; + + const roleRow = await pool.query( + `SELECT 1 FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = $1 AND r.name = 'admin'`, + [newId], + ); + assert.equal(roleRow.rowCount, 1); + + const sys = await pool.query("SELECT * FROM system_settings WHERE id = 1"); + assert.equal(sys.rows[0].installed, true); + assert.equal(sys.rows[0].local_domain, "tx.test.local"); + assert.equal(sys.rows[0].https_mode, "local"); + assert.ok(sys.rows[0].installed_at); +}); + +test("Second POST /api/setup/complete returns 409 already_installed", async () => { + const res = await fetch(`${API_BASE}/api/setup/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: `${TEST_USERNAME}_again`, + email: `${TEST_USERNAME}_again@tx.local`, + password: "ValidPass123!", + }, + }), + }); + assert.equal(res.status, 409); + const body = await res.json(); + assert.equal(body.error, "already_installed"); +}); + +test("POST /api/setup/validate returns 409 once installed", async () => { + const res = await fetch(`${API_BASE}/api/setup/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: "ignored", + email: "ignored@tx.local", + password: "ValidPass123!", + }, + }), + }); + assert.equal(res.status, 409); +}); + +test("GET /api/setup/status reports setupRequired=false after install", async () => { + const res = await fetch(`${API_BASE}/api/setup/status`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.installed, true); + assert.equal(body.setupRequired, false); +}); + +// Contract test for redirectIfSetupNeeded(): Stage 2 SPA routing depends on +// this helper returning a {shouldRedirect, target, status} shape with a +// /setup target only when setup is open. Asserted via /api/setup/status +// since the helper is the source of truth for that response. +test("redirectIfSetupNeeded contract: shouldRedirect=false post-install", async () => { + // We're past the "complete" test, so installed=true. Status must reflect + // that setup is closed; Stage 2's hook reads the same payload. + const res = await fetch(`${API_BASE}/api/setup/status`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.installed, true); + assert.equal(body.setupRequired, false); + // The redirect-decision contract: shouldRedirect MUST be false whenever + // installed=true OR setupRequired=false. Stage 2's hook must mirror this. + const shouldRedirect = + body.checks.db === "ok" && !body.installed && body.setupRequired; + assert.equal(shouldRedirect, false); +}); + +// Regression: scripts/src/seed.ts inserts a bootstrap (id=1, installed=false) +// row early. The seeded-admin branch later flips installed=true. The flip +// MUST be a DO UPDATE — a DO NOTHING would silently leave installed=false +// because the row already exists. This test exercises the same SQL the +// seed uses so a future refactor cannot regress that behavior. +test("seed flip from installed=false to installed=true uses DO UPDATE", async () => { + // Save current row to restore at the end so we don't disturb other tests. + const before = await pool.query("SELECT * FROM system_settings WHERE id = 1"); + const beforeRow = before.rows[0] ?? null; + + // Step 1: bootstrap insert (mirrors seed lines 123-126). + await pool.query( + `INSERT INTO system_settings (id, installed) VALUES (1, false) + ON CONFLICT (id) DO UPDATE SET installed = false, installed_at = NULL`, + ); + const mid = await pool.query( + "SELECT installed FROM system_settings WHERE id = 1", + ); + assert.equal(mid.rows[0].installed, false, "bootstrap should leave installed=false"); + + // Step 2: seeded-admin branch flip (mirrors seed lines 217-227). + await pool.query( + `INSERT INTO system_settings (id, installed, installed_at) + VALUES (1, true, NOW()) + ON CONFLICT (id) DO UPDATE SET + installed = true, + installed_at = COALESCE(system_settings.installed_at, NOW()), + updated_at = NOW()`, + ); + const after = await pool.query( + "SELECT installed, installed_at FROM system_settings WHERE id = 1", + ); + assert.equal(after.rows[0].installed, true, "seed flip must set installed=true"); + assert.ok(after.rows[0].installed_at !== null, "installed_at must be set"); + + // Restore. + if (beforeRow) { + await pool.query( + `INSERT INTO system_settings (id, installed, installed_at, base_url, local_domain, local_ip, https_mode, app_version, updated_at) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + installed = EXCLUDED.installed, + installed_at = EXCLUDED.installed_at, + base_url = EXCLUDED.base_url, + local_domain = EXCLUDED.local_domain, + local_ip = EXCLUDED.local_ip, + https_mode = EXCLUDED.https_mode, + app_version = EXCLUDED.app_version, + updated_at = EXCLUDED.updated_at`, + [ + beforeRow.installed, + beforeRow.installed_at, + beforeRow.base_url, + beforeRow.local_domain, + beforeRow.local_ip, + beforeRow.https_mode, + beforeRow.app_version, + beforeRow.updated_at, + ], + ); + } +}); diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 98aa81c0..46dd7c5e 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -1317,6 +1317,13 @@ export type SubscribePushBody = { keys: SubscribePushBodyKeys; }; +export type DeletePushSubscriptionParams = { + /** + * The push endpoint URL to unsubscribe + */ + endpoint: string; +}; + export type UnsubscribePushBody = { endpoint: string; }; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index fa2c7b17..33a704fc 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -46,6 +46,7 @@ import type { CreateUserBody, DeleteAppParams, DeleteGroupParams, + DeletePushSubscriptionParams, DeleteServiceParams, DeleteUserParams, ErrorResponse, @@ -3737,6 +3738,104 @@ export const useSubscribePush = < return useMutation(getSubscribePushMutationOptions(options)); }; +/** + * @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe) + */ +export const getDeletePushSubscriptionUrl = ( + params: DeletePushSubscriptionParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/push/subscribe?${stringifiedParams}` + : `/api/push/subscribe`; +}; + +export const deletePushSubscription = async ( + params: DeletePushSubscriptionParams, + options?: RequestInit, +): Promise => { + return customFetch(getDeletePushSubscriptionUrl(params), { + ...options, + method: "DELETE", + }); +}; + +export const getDeletePushSubscriptionMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { params: DeletePushSubscriptionParams }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { params: DeletePushSubscriptionParams }, + TContext +> => { + const mutationKey = ["deletePushSubscription"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { params: DeletePushSubscriptionParams } + > = (props) => { + const { params } = props ?? {}; + + return deletePushSubscription(params, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeletePushSubscriptionMutationResult = NonNullable< + Awaited> +>; + +export type DeletePushSubscriptionMutationError = ErrorType; + +/** + * @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe) + */ +export const useDeletePushSubscription = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { params: DeletePushSubscriptionParams }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { params: DeletePushSubscriptionParams }, + TContext +> => { + return useMutation(getDeletePushSubscriptionMutationOptions(options)); +}; + /** * @summary Remove a Web Push subscription for the current user */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 8c239106..3b576f49 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1025,6 +1025,24 @@ paths: application/json: schema: $ref: "#/components/schemas/SuccessResponse" + delete: + operationId: deletePushSubscription + tags: [notifications] + summary: Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe) + parameters: + - in: query + name: endpoint + required: true + schema: + type: string + description: The push endpoint URL to unsubscribe + responses: + "200": + description: Unsubscribed + content: + application/json: + schema: + $ref: "#/components/schemas/SuccessResponse" /push/unsubscribe: post: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index b2e82727..1f471029 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1480,6 +1480,19 @@ export const SubscribePushResponse = zod.object({ success: zod.boolean(), }); +/** + * @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe) + */ +export const DeletePushSubscriptionQueryParams = zod.object({ + endpoint: zod.coerce + .string() + .describe("The push endpoint URL to unsubscribe"), +}); + +export const DeletePushSubscriptionResponse = zod.object({ + success: zod.boolean(), +}); + /** * @summary Remove a Web Push subscription for the current user */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790ea3f5..dd7d6728 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ catalogs: specifier: ^4.1.14 version: 4.2.1 tsx: - specifier: ^4.21.0 + specifier: 4.21.0 version: 4.21.0 vite: specifier: ^7.3.2 @@ -196,6 +196,9 @@ importers: thread-stream: specifier: 3.1.0 version: 3.1.0 + tsx: + specifier: 'catalog:' + version: 4.21.0 artifacts/mockup-sandbox: devDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9c01c34a..fbea4b9f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,7 @@ catalog: react-dom: 19.1.0 tailwind-merge: ^3.3.1 tailwindcss: ^4.1.14 - tsx: ^4.21.0 + tsx: 4.21.0 vite: ^7.3.2 zod: 3.25.76