a794f92e61
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 by the form when an external mode is chosen. - Route field is locked (readOnly + lock hint) when editing a built-in app, since those slugs are hardcoded in the SPA router. 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, re-exported from lib/db. - 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. 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.*. - New tests in apps-builtin-route-lock.test.mjs (4/4 pass) covering reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, allow built-in same-route no-op. Notes / drift: - BUILTIN_APP_SLUGS is duplicated inline in admin.tsx (BUILTIN_APP_SLUGS_FE) because the browser bundle cannot import @workspace/db (pulls pg). Comment points at the canonical source; drift risk filed as a follow-up. - Pre-existing failures unrelated to this task: 3 tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts. Out of scope.
200 lines
7.2 KiB
JavaScript
200 lines
7.2 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 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}`);
|
|
});
|