Files
TX/artifacts/api-server/tests/apps-group-visibility.test.mjs
T
Riyadh 2b4640a4c5 Groups CRUD: address validator round 2 + 3 blockers
Round 2 fixes:
- apps.ts getVisibleAppsForUser uses getEffectiveRoleIds() so admin
  detection honors group_roles inheritance (closes group-only-admin
  bypass). Exported helper from middlewares/auth.ts.
- POST /users accepts optional groupIds[] via new CreateUserBody
  schema. Validates ids exist; user create + auto-Everyone + requested
  groups happen in one transaction. OpenAPI extended; api-zod and
  api-client-react regenerated.
- DELETE /users/:id 400s on self-delete.
- scripts/src/seed.ts: removed `void inArray;` slop and unused import.
- Locales (ar+en): added admin.users.col.{displayNameAr,displayNameEn,
  language} and admin.groups.searchPlaceholder.

Round 3 fixes:
- Admin Users "Add User" dialog gets a groupIds checkbox multi-select
  populated from useListGroups; createUser sends groupIds when set.
- PATCH /users/:id validates ALL groupIds before deleting memberships
  (returns 400 on any invalid id); replacement wrapped in transaction
  to avoid partial-loss windows.
- Apps admin SQL bug: `${arr} = ANY(...)` produced bad params; now uses
  `and(inArray(rolesTable.id, ids), eq(name,'admin'))`.
- New tests (artifacts/api-server/tests/apps-group-visibility.test.mjs):
  group-derived admin sees all; group member sees group-granted +
  unrestricted; outsider sees only unrestricted. All 3 pass.

Typecheck clean; full api test suite green except pre-existing
admin-app-opens-pagination flake (unrelated). Architect: PASS.
2026-04-22 08:54:08 +00:00

205 lines
7.1 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 groupAdminUsername;
let memberUsername;
let outsiderUsername;
let groupAdminId;
let memberId;
let outsiderId;
let groupAdminCookie;
let memberCookie;
let outsiderCookie;
let groupId;
let restrictedAppId;
let unrestrictedAppId;
let permissionId;
async function login(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="));
}
before(async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
groupAdminUsername = `gv_gadmin_${stamp}`;
memberUsername = `gv_member_${stamp}`;
outsiderUsername = `gv_out_${stamp}`;
// Create three users without any direct admin role.
const ga = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Group Admin', 'en', true) RETURNING id`,
[groupAdminUsername, `${groupAdminUsername}@ex.com`, TEST_PASSWORD_HASH],
);
groupAdminId = ga.rows[0].id;
const m = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Member', 'en', true) RETURNING id`,
[memberUsername, `${memberUsername}@ex.com`, TEST_PASSWORD_HASH],
);
memberId = m.rows[0].id;
const o = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Outsider', 'en', true) RETURNING id`,
[outsiderUsername, `${outsiderUsername}@ex.com`, TEST_PASSWORD_HASH],
);
outsiderId = o.rows[0].id;
// Give member + outsider the basic 'user' role so they can list apps.
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id FROM users u, roles r
WHERE u.id IN ($1, $2, $3) AND r.name = 'user'`,
[groupAdminId, memberId, outsiderId],
);
// Create a fresh group, attach 'admin' role to it (group-derived admin).
const grp = await pool.query(
`INSERT INTO groups (name, description_en) VALUES ($1, $2)
RETURNING id`,
[`vis_group_${stamp}`, "visibility test group"],
);
groupId = grp.rows[0].id;
// group-derived admin → groupAdmin user
await pool.query(
`INSERT INTO group_roles (group_id, role_id)
SELECT $1, id FROM roles WHERE name = 'admin'`,
[groupId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
[groupAdminId, groupId],
);
// Create an unrestricted app (no permissions attached).
const unr = await pool.query(
`INSERT INTO apps (name_en, name_ar, slug, icon_name, route, color, sort_order, is_active)
VALUES ($1, $1, $2, 'Grid', '/test-unr', '#6366f1', 999, true) RETURNING id`,
[`UnrestrictedApp_${stamp}`, `unr_${stamp}`],
);
unrestrictedAppId = unr.rows[0].id;
// Create a restricted app gated by a fresh permission.
const rest = await pool.query(
`INSERT INTO apps (name_en, name_ar, slug, icon_name, route, color, sort_order, is_active)
VALUES ($1, $1, $2, 'Grid', '/test-rest', '#6366f1', 998, true) RETURNING id`,
[`RestrictedApp_${stamp}`, `rest_${stamp}`],
);
restrictedAppId = rest.rows[0].id;
const perm = await pool.query(
`INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`,
[`vis_perm_${stamp}`, "visibility test perm"],
);
permissionId = perm.rows[0].id;
await pool.query(
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
[restrictedAppId, permissionId],
);
// Grant the restricted app to the group via group_apps → only group members see it.
await pool.query(
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
[groupId, restrictedAppId],
);
// Add 'member' user to the group too (so they should see the restricted app).
await pool.query(
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
[memberId, groupId],
);
groupAdminCookie = await login(groupAdminUsername, TEST_PASSWORD);
memberCookie = await login(memberUsername, TEST_PASSWORD);
outsiderCookie = await login(outsiderUsername, TEST_PASSWORD);
});
after(async () => {
await pool.query(`DELETE FROM group_apps WHERE group_id = $1`, [groupId]);
await pool.query(`DELETE FROM group_roles WHERE group_id = $1`, [groupId]);
await pool.query(
`DELETE FROM user_groups WHERE user_id = ANY($1::int[]) OR group_id = $2`,
[[groupAdminId, memberId, outsiderId], groupId],
);
await pool.query(
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
[[groupAdminId, memberId, outsiderId]],
);
await pool.query(`DELETE FROM groups WHERE id = $1`, [groupId]);
await pool.query(
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
[[restrictedAppId, unrestrictedAppId]],
);
await pool.query(`DELETE FROM permissions WHERE id = $1`, [permissionId]);
await pool.query(
`DELETE FROM apps WHERE id = ANY($1::int[])`,
[[restrictedAppId, unrestrictedAppId]],
);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
[groupAdminId, memberId, outsiderId],
]);
await pool.end();
});
async function listApps(cookie) {
const res = await fetch(`${API_BASE}/api/apps`, {
headers: { cookie },
});
assert.equal(res.status, 200);
return res.json();
}
test("group-derived admin sees both restricted and unrestricted apps", async () => {
const apps = await listApps(groupAdminCookie);
const ids = apps.map((a) => a.id);
assert.ok(ids.includes(unrestrictedAppId), "admin should see unrestricted app");
assert.ok(ids.includes(restrictedAppId), "admin should see restricted app");
});
test("group member sees the group-granted restricted app and the unrestricted one", async () => {
const apps = await listApps(memberCookie);
const ids = apps.map((a) => a.id);
assert.ok(ids.includes(unrestrictedAppId), "member should see unrestricted app");
assert.ok(
ids.includes(restrictedAppId),
"member should see restricted app via group_apps",
);
});
test("non-member outsider sees unrestricted but NOT the restricted app", async () => {
const apps = await listApps(outsiderCookie);
const ids = apps.map((a) => a.id);
assert.ok(ids.includes(unrestrictedAppId), "outsider should see unrestricted app");
assert.ok(
!ids.includes(restrictedAppId),
"outsider must NOT see restricted app (no permission, no group)",
);
});