Files
TX/artifacts/api-server/tests/apps-builtin-route-lock.test.mjs
T
riyadhafraa 6a01ce852a Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
  ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
  external_iframe (renders inside /embedded/:id). External URL input
  shown conditionally and required when an external mode is chosen.
- Internal route field is hidden entirely when an external mode is
  selected, and locked (readOnly + lock hint) when editing a built-in
  app. Slug input is also locked (readOnly) for built-in apps so the
  built-in identity cannot drift via the form.

Backend:
- apps schema gains image_url, external_url, open_mode (default
  'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
  isBuiltinAppSlug. Exposed via subpath export
  `@workspace/db/built-in-apps`; the file has zero imports so the
  browser bundle uses it without pulling in `pg`. tx-os now imports it
  directly — duplicate FE constant removed.
- Built-in slug list: services, notifications, admin, notes,
  my-orders, orders-incoming, executive-meetings (everything with a
  hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar /
  documents are seeded but admin-defined and remain editable.
- PATCH /apps/:id rejects route changes whose previous slug is
  built-in with 400 + code='builtin_route_locked'. Same-route no-op
  is allowed; non-route updates on built-ins still work.
- PATCH /apps/:id ALSO rejects slug changes when the previous slug is
  built-in (code='builtin_slug_locked'). UpdateAppBody zod schema
  intentionally omits slug, so we inspect req.body.slug raw before zod
  stripping. Closes the 2-step bypass: rename slug (allowed) → change
  route (now previous.slug looks non-built-in, allowed).
- POST /apps and PATCH /apps/:id reject externalUrl values that are
  not http:// or https:// (code='invalid_external_url'). Prevents
  shipping javascript:/data:/file: payloads tenant-wide via launcher.

Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
  back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
  builtinPathLocked, embeddedFrame.*.
- scripts/src/seed.ts: drift guard throws if a seeded built-in slug
  uses a route that does not match the hardcoded SPA route.

Tests:
- New API test apps-builtin-route-lock.test.mjs (6/6 pass): reject
  built-in route change, allow non-route built-in updates, allow
  non-builtin route changes, reject built-in slug change (anti-bypass),
  reject non-http(s) externalUrl scheme + accept https, allow built-in
  same-route no-op.
- New Playwright E2E admin-app-image-external-embedded.spec.mjs
  (passes): launcher renders custom image_url, external_tab opens
  external URL via window.open, external_iframe navigates to
  /embedded/:id and renders <iframe src=externalUrl>.

Out of scope (pre-existing, not introduced here):
- Failing tests in executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts.
2026-05-12 12:37:34 +00:00

257 lines
9.8 KiB
JavaScript

// Task #517: 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;
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);
});
after(async () => {
if (createdAppIds.length > 0) {
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [createdAppIds]);
}
if (adminId) {
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]);
}
await pool.end();
});
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 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}`);
});