Files
TX/artifacts/api-server/tests/groups-crud.test.mjs
T
riyadhafraa bf0768948e Groups + group-based RBAC: harden authz, atomic group writes, full admin UI
Auth middleware:
- Add getEffectiveRoleIds() that UNIONs user_roles with group_roles via
  group memberships, used by requireAdmin / requirePermission /
  userHasPermission / getUserRoles.
- requireAdmin and requirePermission now also reject inactive users
  with 401 (matching requireAuth), closing a session-after-deactivation
  bypass.

Groups routes:
- POST /groups and PATCH /groups/:id now wrap the group row write and
  all assignment writes in a single db.transaction via
  applyGroupAssignmentsTx(tx, ...), so partial state cannot leak.
- validateAssignmentIds rejects unknown app/role/user ids with 400
  before any insert.
- Removed AI-slop: void or, void sql, as-unknown-as casts; conditions
  use Drizzle's SQL union type.

Users route:
- /api/users supports q, groupId, status filters (server-side).

Admin UI (teaboy-os/admin.tsx):
- UsersPanel wires q/groupId/status to the backend, shows display name
  and preferred language inline per row.
- UserGroupsEditor now edits display names (ar/en), preferred language,
  active status, and group membership with a search box.
- GroupsPanel adds a top-level group search box.
- GroupDetailEditor Users tab adds a user search box.

Infra:
- scripts/post-merge.sh runs the seed (idempotent) so default groups
  Admins / TeaBoy / Everyone always exist after merges.

Tests (artifacts/api-server/tests/groups-crud.test.mjs, all passing):
- Admin-only access (regular user gets 403).
- Default seed groups exist.
- Create group + member assignment.
- Bad userIds yields 400 with no leaked group row.
- Admin role inherited via group_roles grants admin access.
- Deactivated admin session is rejected with 401.
- Group create rolls back atomically when assignment fails.
- /api/users q + groupId + status filters return correct rows.

Notes / drift:
- "Roles" tab inside GroupDetailEditor and groupIds in CreateUserBody
  remain as proposed follow-ups (require OpenAPI spec changes).
- Pre-existing pagination-test flake unrelated to this work.
2026-04-22 08:40:38 +00:00

282 lines
11 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 regularId;
let regularUsername;
let adminCookie;
let regularCookie;
let createdGroupIds = [];
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="));
}
before(async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
adminUsername = `gr_admin_${stamp}`;
regularUsername = `gr_user_${stamp}`;
const a = 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`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
ON CONFLICT DO NOTHING`,
[adminId],
);
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Group User', 'en', true) RETURNING id`,
[regularUsername, `${regularUsername}@example.com`, TEST_PASSWORD_HASH],
);
regularId = r.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`,
[regularId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name = 'Everyone'
ON CONFLICT DO NOTHING`,
[regularId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
regularCookie = await loginAndGetCookie(regularUsername, TEST_PASSWORD);
});
after(async () => {
if (createdGroupIds.length) {
await pool.query(
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM group_apps WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
createdGroupIds,
]);
}
for (const uid of [adminId, regularId]) {
if (uid !== undefined) {
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
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/groups requires admin (regular user gets 403)", async () => {
const res = await fetch(`${API_BASE}/api/groups`, {
headers: { Cookie: regularCookie },
});
assert.equal(res.status, 403);
});
test("admin lists default seed groups including Admins, TeaBoy, Everyone", async () => {
const res = await fetch(`${API_BASE}/api/groups`, {
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 200);
const list = await res.json();
const names = new Set(list.map((g) => g.name));
assert.ok(names.has("Admins"));
assert.ok(names.has("TeaBoy"));
assert.ok(names.has("Everyone"));
});
test("admin creates a group, sees it, and assigns a member", async () => {
const name = `TestGrp_${Date.now().toString(36)}`;
const create = await fetch(`${API_BASE}/api/groups`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({
name,
descriptionEn: "Test group",
userIds: [regularId],
}),
});
assert.equal(create.status, 201);
const created = await create.json();
createdGroupIds.push(created.id);
assert.equal(created.name, name);
// Detail should include regularId
const detailRes = await fetch(`${API_BASE}/api/groups/${created.id}`, {
headers: { Cookie: adminCookie },
});
assert.equal(detailRes.status, 200);
const detail = await detailRes.json();
assert.ok(detail.userIds.includes(regularId));
});
test("creating a group with invalid userIds returns 400 and does not create rows", async () => {
const before = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM users`);
const badId = Number(maxRow.rows[0].m) + 100000;
const res = await fetch(`${API_BASE}/api/groups`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name: `Bad_${Date.now()}`, userIds: [badId] }),
});
assert.equal(res.status, 400);
const after = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
assert.equal(after.rows[0].c, before.rows[0].c, "no group should be created");
});
test("admin role inherited via group_roles grants access to admin endpoints", async () => {
// Create a group, attach the 'admin' role to it, add a fresh non-admin user
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const inheritedUsername = `gr_inherit_${stamp}`;
const u = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Inherited', 'en', true) RETURNING id`,
[inheritedUsername, `${inheritedUsername}@example.com`, TEST_PASSWORD_HASH],
);
const inheritedId = u.rows[0].id;
// No direct admin role; rely on group_roles
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`,
[inheritedId],
);
const grpName = `AdminInheritGrp_${stamp}`;
const g = await pool.query(
`INSERT INTO groups (name) VALUES ($1) RETURNING id`,
[grpName],
);
const grpId = g.rows[0].id;
createdGroupIds.push(grpId);
await pool.query(
`INSERT INTO group_roles (group_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[grpId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
[inheritedId, grpId],
);
const cookie = await loginAndGetCookie(inheritedUsername, TEST_PASSWORD);
const res = await fetch(`${API_BASE}/api/groups`, { headers: { Cookie: cookie } });
assert.equal(res.status, 200, "group-role-only user must be granted admin access");
// Cleanup user
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [inheritedId]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [inheritedId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [inheritedId]);
});
test("deactivated admin session is denied on admin endpoints", async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const u = `deact_admin_${stamp}`;
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Deact', 'en', true) RETURNING id`,
[u, `${u}@example.com`, TEST_PASSWORD_HASH],
);
const uid = r.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[uid],
);
const cookie = await loginAndGetCookie(u, TEST_PASSWORD);
const ok = await fetch(`${API_BASE}/api/groups`, { headers: { Cookie: cookie } });
assert.equal(ok.status, 200);
// Deactivate after login
await pool.query(`UPDATE users SET is_active = false WHERE id = $1`, [uid]);
const denied = await fetch(`${API_BASE}/api/groups`, { headers: { Cookie: cookie } });
assert.equal(denied.status, 401, "inactive user must be rejected");
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
});
test("group create rolls back atomically when assignment fails", async () => {
// Validation rejects bad userIds before any insert (validateAssignmentIds runs first),
// so no `groups` row should be created and we should get a clean 400.
const before = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
const badAppId = Number(maxRow.rows[0].m) + 100000;
const name = `RollbackGrp_${Date.now().toString(36)}`;
const res = await fetch(`${API_BASE}/api/groups`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name, appIds: [badAppId] }),
});
assert.equal(res.status, 400);
const after = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
assert.equal(after.rows[0].c, before.rows[0].c, "no group row should leak");
const leaked = await pool.query(`SELECT id FROM groups WHERE name = $1`, [name]);
assert.equal(leaked.rowCount, 0);
});
test("GET /api/users supports q, groupId, and status filters", async () => {
// q filter on partial username
const qRes = await fetch(
`${API_BASE}/api/users?q=${encodeURIComponent(regularUsername.slice(0, 8))}`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(qRes.status, 200);
const qList = await qRes.json();
assert.ok(qList.some((u) => u.id === regularId));
// status=active includes our active user
const statusRes = await fetch(`${API_BASE}/api/users?status=active`, {
headers: { Cookie: adminCookie },
});
const statusList = await statusRes.json();
assert.ok(statusList.every((u) => u.isActive === true));
// groupId filter — use Everyone (every user belongs)
const groupsRes = await fetch(`${API_BASE}/api/groups`, {
headers: { Cookie: adminCookie },
});
const groups = await groupsRes.json();
const everyone = groups.find((g) => g.name === "Everyone");
assert.ok(everyone, "Everyone group must exist");
const grpRes = await fetch(`${API_BASE}/api/users?groupId=${everyone.id}`, {
headers: { Cookie: adminCookie },
});
const grpList = await grpRes.json();
assert.ok(grpList.some((u) => u.id === adminId));
});