Files
TX/artifacts/api-server/tests/app-permissions-crud.test.mjs
T
riyadhafraa 8b2fe3164d Task #109: Admin UI to manage app required permissions
- Added 3 admin-only API endpoints in artifacts/api-server/src/routes/apps.ts:
  - GET    /api/apps/:id/permissions   — list permissions gating an app
  - POST   /api/apps/:id/permissions   — add a permission (idempotent via
    onConflictDoNothing() on the (app_id, permission_id) composite PK)
  - DELETE /api/apps/:id/permissions/:permissionId — remove (idempotent, 204)
- Documented the new endpoints in lib/api-spec/openapi.yaml with two new schemas
  (AddAppPermissionBody, AppPermissionLink) and re-ran codegen.
- Added a "Required permissions" section (AppPermissionsEditor) to the existing
  Edit App dialog in artifacts/tx-os/src/pages/admin.tsx, using the generated
  hooks. The section is shown only when editing an existing app (it needs an
  app id). Wired up admin.appPermissions.* i18n keys in en.json + ar.json.
- Added artifacts/api-server/tests/app-permissions-crud.test.mjs with 5 tests
  (empty list, idempotent add, 404 on unknown app/perm, idempotent delete,
  403 for non-admins). All 5 pass; related tests
  (app-permissions-unique, apps-group-visibility, list-dependency-counts) still pass.
- Verified the new admin UI end-to-end with the testing skill: admin login,
  open Edit App dialog, add/remove a required permission, and confirm the
  section is hidden in the Add App dialog.

Notes / scope:
- Pre-existing duplicate rows in app_permissions had to be deduped and
  `pnpm --filter @workspace/db run push` was run once so the composite PK
  could be added (separate task "Re-run the Drizzle schema push" already
  exists for this project-wide chore).
- No audit logging here — separate existing task already covers it.
- The "test" workflow shows a pre-existing ECONNREFUSED race; an existing
  task already tracks making the test workflow wait for the API server.

Replit-Task-Id: 912bb163-6b6f-43d3-9934-41fc97519337
2026-04-29 15:31:42 +00:00

275 lines
9.5 KiB
JavaScript

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 nonAdminId;
let nonAdminUsername;
let nonAdminCookie;
const createdAppIds = [];
const createdPermissionIds = [];
const createdUserIds = [];
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 createApp(prefix) {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const slug = `${prefix}_${stamp}`;
const res = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order)
VALUES ($1, $2, $3, $4, true, 999)
RETURNING id`,
[slug, slug, slug, `/${slug}`],
);
const id = res.rows[0].id;
createdAppIds.push(id);
return id;
}
async function createPermission(prefix) {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const name = `${prefix}.${stamp}`;
const res = await pool.query(
`INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`,
[name, name],
);
const id = res.rows[0].id;
createdPermissionIds.push(id);
return { id, name };
}
async function getPairsForApp(appId) {
const rows = await pool.query(
`SELECT permission_id FROM app_permissions WHERE app_id = $1 ORDER BY permission_id`,
[appId],
);
return rows.rows.map((r) => r.permission_id);
}
before(async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
adminUsername = `app_perms_admin_${stamp}`;
nonAdminUsername = `app_perms_user_${stamp}`;
const admin = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'App Perms Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = admin.rows[0].id;
createdUserIds.push(adminId);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
const user = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'App Perms User', 'en', true) RETURNING id`,
[nonAdminUsername, `${nonAdminUsername}@example.com`, TEST_PASSWORD_HASH],
);
nonAdminId = user.rows[0].id;
createdUserIds.push(nonAdminId);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
nonAdminCookie = await loginAndGetCookie(nonAdminUsername, TEST_PASSWORD);
});
after(async () => {
if (createdAppIds.length) {
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 (createdPermissionIds.length) {
await pool.query(
`DELETE FROM app_permissions WHERE permission_id = ANY($1::int[])`,
[createdPermissionIds],
);
await pool.query(`DELETE FROM permissions WHERE id = ANY($1::int[])`, [
createdPermissionIds,
]);
}
for (const uid of createdUserIds) {
if (uid !== undefined) {
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
}
}
await pool.end();
});
test("GET /api/apps/:id/permissions lists nothing for a fresh app", async () => {
const appId = await createApp("apc_list_empty");
const res = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body, []);
});
test("POST /api/apps/:id/permissions adds a permission and is idempotent on duplicates", async () => {
const appId = await createApp("apc_add");
const { id: permId, name: permName } = await createPermission("apc.add");
// First insert: 201, body contains the link.
const first = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: permId }),
});
assert.equal(first.status, 201);
const firstBody = await first.json();
assert.equal(firstBody.appId, appId);
assert.equal(firstBody.permissionId, permId);
assert.deepEqual(await getPairsForApp(appId), [permId]);
// Re-adding the exact same pair must NOT throw a unique-violation; the
// route uses .onConflictDoNothing() so the UI can safely retry.
const second = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: permId }),
});
assert.equal(second.status, 201);
// Still exactly one row — the composite primary key was respected without
// an error bubbling up to the client.
assert.deepEqual(await getPairsForApp(appId), [permId]);
// List endpoint reflects the assignment with the joined permission record.
const list = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
headers: { Cookie: adminCookie },
});
assert.equal(list.status, 200);
const listBody = await list.json();
assert.equal(listBody.length, 1);
assert.equal(listBody[0].id, permId);
assert.equal(listBody[0].name, permName);
});
test("POST /api/apps/:id/permissions returns 404 for an unknown app or permission", async () => {
const appId = await createApp("apc_unknown");
const { id: permId } = await createPermission("apc.unknown");
const unknownAppRow = await pool.query(
`SELECT COALESCE(MAX(id), 0) AS m FROM apps`,
);
const unknownAppId = unknownAppRow.rows[0].m + 9999;
const unknownPermRow = await pool.query(
`SELECT COALESCE(MAX(id), 0) AS m FROM permissions`,
);
const unknownPermId = unknownPermRow.rows[0].m + 9999;
const noApp = await fetch(`${API_BASE}/api/apps/${unknownAppId}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: permId }),
});
assert.equal(noApp.status, 404);
const noPerm = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: unknownPermId }),
});
assert.equal(noPerm.status, 404);
// Neither rejected call should have left a row behind.
assert.deepEqual(await getPairsForApp(appId), []);
});
test("DELETE /api/apps/:id/permissions/:permissionId removes the pair and is idempotent", async () => {
const appId = await createApp("apc_delete");
const { id: p1 } = await createPermission("apc.delete.a");
const { id: p2 } = await createPermission("apc.delete.b");
// Seed the app with two permissions.
for (const pid of [p1, p2]) {
const r = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: pid }),
});
assert.equal(r.status, 201);
}
assert.deepEqual(
await getPairsForApp(appId),
[p1, p2].sort((a, b) => a - b),
);
const del = await fetch(
`${API_BASE}/api/apps/${appId}/permissions/${p1}`,
{ method: "DELETE", headers: { Cookie: adminCookie } },
);
assert.equal(del.status, 204);
assert.deepEqual(await getPairsForApp(appId), [p2]);
// Re-deleting a pair that no longer exists is a 204 no-op (the row count
// is zero but the contract is idempotent so the UI can safely retry).
const again = await fetch(
`${API_BASE}/api/apps/${appId}/permissions/${p1}`,
{ method: "DELETE", headers: { Cookie: adminCookie } },
);
assert.equal(again.status, 204);
assert.deepEqual(await getPairsForApp(appId), [p2]);
});
test("non-admins receive 403 from every app-permissions admin endpoint", async () => {
const appId = await createApp("apc_forbidden");
const { id: permId } = await createPermission("apc.forbidden");
const list = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
headers: { Cookie: nonAdminCookie },
});
assert.equal(list.status, 403);
const add = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: nonAdminCookie },
body: JSON.stringify({ permissionId: permId }),
});
assert.equal(add.status, 403);
const del = await fetch(
`${API_BASE}/api/apps/${appId}/permissions/${permId}`,
{ method: "DELETE", headers: { Cookie: nonAdminCookie } },
);
assert.equal(del.status, 403);
// None of the rejected calls should have written a row.
assert.deepEqual(await getPairsForApp(appId), []);
});