0cee551f60
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers.
353 lines
14 KiB
JavaScript
353 lines
14 KiB
JavaScript
// 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 <Route> 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,<script>1</script>", "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}`);
|
|
});
|