// PATCH /apps/:id must reject route changes whose target row's // slug is in BUILTIN_APP_SLUGS — those routes are hardcoded in the SPA // (artifacts/tx-os/src/App.tsx) and silently break the launcher when an // admin edits them. Other fields (icon, image, color, name, openMode, // externalUrl, sortOrder) must still be editable, including for built-in // apps. Apps whose slug is not built-in keep accepting route changes. 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 }); let adminId; let adminUsername; let adminCookie; let userId; let userUsername; let userCookie; let testPermissionId; const createdAppIds = []; async function loginAndGetCookie(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 expected 200, got ${res.status}`); const setCookie = res.headers.get("set-cookie"); return setCookie .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")); } async function createAppRow({ slug, route }) { const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const realSlug = slug ?? `customapp_${stamp}`; const realRoute = route ?? `/custom-${stamp}`; const { rows } = await pool.query( `INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order) VALUES ($1, $2, $3, $4, TRUE, 0) RETURNING id`, [realSlug, realSlug, realSlug, realRoute], ); createdAppIds.push(rows[0].id); return { id: rows[0].id, slug: realSlug, route: realRoute }; } before(async () => { const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; adminUsername = `admin_lock_${stamp}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $1, 'en', TRUE) RETURNING id`, [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], ); adminId = rows[0].id; await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, [adminId], ); adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); // Non-admin user used to verify GET /apps/:id visibility gating for // the new external-iframe / embedded flow. userUsername = `user_lock_${stamp}`; const userRow = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $1, 'en', TRUE) RETURNING id`, [userUsername, `${userUsername}@example.com`, TEST_PASSWORD_HASH], ); userId = userRow.rows[0].id; userCookie = await loginAndGetCookie(userUsername, TEST_PASSWORD); const permRow = await pool.query( `INSERT INTO permissions (name, description_en) VALUES ($1, 'lock-test') ON CONFLICT (name) DO UPDATE SET description_en = EXCLUDED.description_en RETURNING id`, [`apps_lock_visibility_${stamp}`], ); testPermissionId = permRow.rows[0].id; }); after(async () => { if (createdAppIds.length > 0) { await pool.query(`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, [createdAppIds]); await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [createdAppIds]); } if (testPermissionId) { await pool.query(`DELETE FROM permissions WHERE id = $1`, [testPermissionId]); } if (adminId) { await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]); await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]); } if (userId) { await pool.query(`DELETE FROM users WHERE id = $1`, [userId]); } await pool.end(); }); test("GET /apps/:id 404s for users who cannot see the app (visibility gate)", async () => { // Without this gate, any logged-in user could fetch externalUrl for // restricted apps via /embedded/:id and load them in an iframe. const created = await createAppRow({}); await pool.query( `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`, [created.id, testPermissionId], ); // Admin can still read it (admin role bypasses permission gates). const adminRes = await fetch(`${API_BASE}/api/apps/${created.id}`, { headers: { Cookie: adminCookie }, }); assert.equal(adminRes.status, 200, "admin must still see the app"); // Non-admin without the gating permission must get 404 (not 200, and // not 403, so we don't leak existence of restricted ids). const userRes = await fetch(`${API_BASE}/api/apps/${created.id}`, { headers: { Cookie: userCookie }, }); assert.equal(userRes.status, 404, `expected 404 for unauthorized user, got ${userRes.status}`); }); test("PATCH /apps/:id rejects route changes for built-in slugs with code=builtin_route_locked", async () => { // Pick a built-in slug. We INSERT a row with that exact slug — the live // DB seeds them too, but using a freshly inserted row keeps the test // self-contained and avoids touching the seeded /admin/etc apps. // First clean any stray prior test row for this slug; the schema has a // UNIQUE constraint on slug. const slug = "executive-meetings"; const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]); let appId; let originalRoute; if (original.rowCount > 0) { appId = original.rows[0].id; originalRoute = original.rows[0].route; } else { const created = await createAppRow({ slug, route: "/executive-meetings" }); appId = created.id; originalRoute = created.route; } const res = await fetch(`${API_BASE}/api/apps/${appId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie, }, body: JSON.stringify({ route: "/something-else" }), }); assert.equal(res.status, 400, `expected 400, got ${res.status}`); const body = await res.json(); assert.equal(body.code, "builtin_route_locked"); // Confirm the row was NOT mutated. const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [appId]); assert.equal(after.rows[0].route, originalRoute); }); test("PATCH /apps/:id allows non-route updates on built-in apps", async () => { const slug = "notes"; const original = await pool.query(`SELECT id, route, icon_name FROM apps WHERE slug = $1`, [slug]); let appId; let originalRoute; let originalIcon; if (original.rowCount > 0) { appId = original.rows[0].id; originalRoute = original.rows[0].route; originalIcon = original.rows[0].icon_name; } else { const created = await createAppRow({ slug, route: "/notes" }); appId = created.id; originalRoute = created.route; originalIcon = "Grid"; } // Change icon + openMode but keep route untouched (omit it). const res = await fetch(`${API_BASE}/api/apps/${appId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie, }, body: JSON.stringify({ iconName: "Sparkles", openMode: "internal" }), }); assert.equal(res.status, 200, `expected 200, got ${res.status}`); const after = await pool.query( `SELECT route, icon_name, open_mode FROM apps WHERE id = $1`, [appId], ); assert.equal(after.rows[0].route, originalRoute, "route must not change"); assert.equal(after.rows[0].icon_name, "Sparkles"); assert.equal(after.rows[0].open_mode, "internal"); // Restore icon so the test is idempotent for re-runs. await pool.query(`UPDATE apps SET icon_name = $1 WHERE id = $2`, [originalIcon, appId]); }); test("PATCH /apps/:id allows route changes on non-built-in apps", async () => { const created = await createAppRow({}); const newRoute = `${created.route}_v2`; const res = await fetch(`${API_BASE}/api/apps/${created.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie, }, body: JSON.stringify({ route: newRoute }), }); assert.equal(res.status, 200, `expected 200, got ${res.status}`); const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [created.id]); assert.equal(after.rows[0].route, newRoute); }); test("PATCH /apps/:id rejects slug changes for built-in apps with code=builtin_slug_locked", async () => { // Anti-bypass: without this, an admin could rename the slug to a // non-built-in value (allowed) and then change the route in a second // request (now the previous slug looks non-built-in and would pass). const slug = "executive-meetings"; const original = await pool.query(`SELECT id, slug, route FROM apps WHERE slug = $1`, [slug]); let appId; let originalSlug; let originalRoute; if (original.rowCount > 0) { appId = original.rows[0].id; originalSlug = original.rows[0].slug; originalRoute = original.rows[0].route; } else { const created = await createAppRow({ slug, route: "/executive-meetings" }); appId = created.id; originalSlug = slug; originalRoute = created.route; } const res = await fetch(`${API_BASE}/api/apps/${appId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ slug: `renamed_${Date.now()}` }), }); assert.equal(res.status, 400, `expected 400, got ${res.status}`); const body = await res.json(); assert.equal(body.code, "builtin_slug_locked"); const after = await pool.query(`SELECT slug, route FROM apps WHERE id = $1`, [appId]); assert.equal(after.rows[0].slug, originalSlug, "slug must not change"); assert.equal(after.rows[0].route, originalRoute, "route must not change"); }); test("PATCH /apps/:id rejects route changes for seeded built-ins (calendar)", async () => { // calendar/documents have no hardcoded SPA but are seeded // canonical apps; admins must not be able to repurpose their slug or // path or links/audits/deep-links break. const slug = "calendar"; const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]); if (original.rowCount === 0) return; const appId = original.rows[0].id; const originalRoute = original.rows[0].route; const res = await fetch(`${API_BASE}/api/apps/${appId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ route: `/calendar-renamed-${Date.now()}` }), }); assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.code, "builtin_route_locked"); const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [appId]); assert.equal(after.rows[0].route, originalRoute); }); test("POST /apps rejects externalUrl with non-http(s) scheme (XSS guard, create path)", async () => { const res = await fetch(`${API_BASE}/api/apps`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ slug: `xss-create-${Date.now()}`, nameAr: "ت", nameEn: "T", iconName: "Grid2X2", route: `/xss-${Date.now()}`, color: "#000000", sortOrder: 0, externalUrl: "javascript:alert(1)", openMode: "external_tab", }), }); assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.code, "invalid_external_url"); }); test("PATCH /apps/:id rejects externalUrl with non-http(s) scheme (XSS guard)", async () => { // javascript: in an iframe src or window.open target = stored XSS for // every user; data: / file: are similarly dangerous. Server must reject. const created = await createAppRow({}); for (const badUrl of ["javascript:alert(1)", "data:text/html,", "file:///etc/passwd"]) { const res = await fetch(`${API_BASE}/api/apps/${created.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ externalUrl: badUrl, openMode: "external_tab" }), }); assert.equal(res.status, 400, `expected 400 for ${badUrl}, got ${res.status}`); const body = await res.json(); assert.equal(body.code, "invalid_external_url", `wrong code for ${badUrl}`); } // https:// is accepted. const ok = await fetch(`${API_BASE}/api/apps/${created.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ externalUrl: "https://example.com/ok", openMode: "external_tab" }), }); assert.equal(ok.status, 200); }); test("PATCH /apps/:id with same route on built-in slug is a no-op success", async () => { // Sending route equal to current value should NOT be rejected, since // it isn't actually changing the path. const slug = "executive-meetings"; const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]); let appId; let route; if (original.rowCount > 0) { appId = original.rows[0].id; route = original.rows[0].route; } else { const created = await createAppRow({ slug, route: "/executive-meetings" }); appId = created.id; route = created.route; } const res = await fetch(`${API_BASE}/api/apps/${appId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie, }, body: JSON.stringify({ route }), }); assert.equal(res.status, 200, `expected 200, got ${res.status}`); });