2026-04-22 08:40:38 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 10:26:53 +00:00
|
|
|
test("admin lists default seed groups including Admins, Tx, Everyone", async () => {
|
2026-04-22 08:40:38 +00:00
|
|
|
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"));
|
2026-04-22 10:26:53 +00:00
|
|
|
assert.ok(names.has("Tx"));
|
2026-04-22 08:40:38 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 09:15:57 +00:00
|
|
|
test("PATCH /api/groups/:id updates fields and replaces assignments transactionally", async () => {
|
|
|
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
|
const create = await fetch(`${API_BASE}/api/groups`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ name: `UpdGrp_${stamp}`, descriptionEn: "before" }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(create.status, 201);
|
|
|
|
|
const created = await create.json();
|
|
|
|
|
createdGroupIds.push(created.id);
|
|
|
|
|
|
|
|
|
|
const newName = `UpdGrpRenamed_${stamp}`;
|
|
|
|
|
const patch = await fetch(`${API_BASE}/api/groups/${created.id}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
name: newName,
|
|
|
|
|
descriptionEn: "after",
|
|
|
|
|
userIds: [adminId, regularId],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(patch.status, 200);
|
|
|
|
|
const updated = await patch.json();
|
|
|
|
|
assert.equal(updated.name, newName);
|
|
|
|
|
assert.equal(updated.descriptionEn, "after");
|
|
|
|
|
assert.deepEqual(new Set(updated.userIds), new Set([adminId, regularId]));
|
|
|
|
|
|
|
|
|
|
// Replace assignment with just the regular user; admin should be removed.
|
|
|
|
|
const patch2 = await fetch(`${API_BASE}/api/groups/${created.id}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ userIds: [regularId] }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(patch2.status, 200);
|
|
|
|
|
const detail = await (
|
|
|
|
|
await fetch(`${API_BASE}/api/groups/${created.id}`, { headers: { Cookie: adminCookie } })
|
|
|
|
|
).json();
|
|
|
|
|
assert.deepEqual(detail.userIds, [regularId]);
|
|
|
|
|
|
|
|
|
|
// Bad userId on update — must 400, must not partially apply (membership unchanged).
|
|
|
|
|
const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM users`);
|
|
|
|
|
const badId = Number(maxRow.rows[0].m) + 100000;
|
|
|
|
|
const patchBad = await fetch(`${API_BASE}/api/groups/${created.id}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ userIds: [adminId, badId] }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(patchBad.status, 400);
|
|
|
|
|
const detail2 = await (
|
|
|
|
|
await fetch(`${API_BASE}/api/groups/${created.id}`, { headers: { Cookie: adminCookie } })
|
|
|
|
|
).json();
|
|
|
|
|
assert.deepEqual(detail2.userIds, [regularId], "membership must be unchanged after rejected update");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("creating/updating a group with invalid roleIds (or appIds) returns 400", async () => {
|
|
|
|
|
const maxRoleRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM roles`);
|
|
|
|
|
const maxAppRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
|
|
|
|
|
const badRoleId = Number(maxRoleRow.rows[0].m) + 100000;
|
|
|
|
|
const badAppId = Number(maxAppRow.rows[0].m) + 100000;
|
|
|
|
|
|
|
|
|
|
// CREATE with bad roleId — must 400 and create no row.
|
|
|
|
|
const beforeCount = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
|
|
|
|
|
const createRes = await fetch(`${API_BASE}/api/groups`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ name: `BadRole_${Date.now()}`, roleIds: [badRoleId] }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(createRes.status, 400);
|
|
|
|
|
const afterCount = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
|
|
|
|
|
assert.equal(afterCount.rows[0].c, beforeCount.rows[0].c);
|
|
|
|
|
|
|
|
|
|
// Build a clean group, then PATCH with bad roleIds and bad appIds.
|
|
|
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
|
const seedRes = await fetch(`${API_BASE}/api/groups`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ name: `BadRolePatch_${stamp}` }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(seedRes.status, 201);
|
|
|
|
|
const seed = await seedRes.json();
|
|
|
|
|
createdGroupIds.push(seed.id);
|
|
|
|
|
|
|
|
|
|
const patchRoleRes = await fetch(`${API_BASE}/api/groups/${seed.id}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ roleIds: [badRoleId] }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(patchRoleRes.status, 400);
|
|
|
|
|
|
|
|
|
|
const patchAppRes = await fetch(`${API_BASE}/api/groups/${seed.id}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ appIds: [badAppId] }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(patchAppRes.status, 400);
|
|
|
|
|
|
|
|
|
|
// Confirm no role/app links leaked onto the seed group from rejected updates.
|
|
|
|
|
const detail = await (
|
|
|
|
|
await fetch(`${API_BASE}/api/groups/${seed.id}`, { headers: { Cookie: adminCookie } })
|
|
|
|
|
).json();
|
|
|
|
|
assert.deepEqual(detail.roleIds, []);
|
|
|
|
|
assert.deepEqual(detail.appIds, []);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("DELETE /api/groups/:id removes a custom group; system groups are protected", async () => {
|
|
|
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
|
const create = await fetch(`${API_BASE}/api/groups`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({ name: `DelGrp_${stamp}` }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(create.status, 201);
|
|
|
|
|
const created = await create.json();
|
|
|
|
|
|
|
|
|
|
const del = await fetch(`${API_BASE}/api/groups/${created.id}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
headers: { Cookie: adminCookie },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(del.status, 204);
|
|
|
|
|
const after = await pool.query(`SELECT id FROM groups WHERE id = $1`, [created.id]);
|
|
|
|
|
assert.equal(after.rowCount, 0, "group row should be gone");
|
|
|
|
|
|
|
|
|
|
// System-group guard: trying to delete Everyone must fail and leave row intact.
|
|
|
|
|
const sysRow = await pool.query(
|
|
|
|
|
`SELECT id FROM groups WHERE name = 'Everyone' AND is_system = 1`,
|
|
|
|
|
);
|
|
|
|
|
assert.ok(sysRow.rowCount > 0, "Everyone system group must exist");
|
|
|
|
|
const everyoneId = sysRow.rows[0].id;
|
|
|
|
|
const denied = await fetch(`${API_BASE}/api/groups/${everyoneId}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
headers: { Cookie: adminCookie },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(denied.status, 400);
|
|
|
|
|
const stillThere = await pool.query(`SELECT id FROM groups WHERE id = $1`, [everyoneId]);
|
|
|
|
|
assert.equal(stillThere.rowCount, 1, "system group must not be deleted");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("/auth/register auto-assigns the new user to the Everyone system group", async () => {
|
|
|
|
|
// Ensure registration is open for the duration of this test, then restore.
|
|
|
|
|
const prior = await pool.query(
|
|
|
|
|
`SELECT registration_open FROM app_settings WHERE id = 1`,
|
|
|
|
|
);
|
|
|
|
|
const priorOpen = prior.rows[0]?.registration_open;
|
|
|
|
|
await pool.query(
|
|
|
|
|
`INSERT INTO app_settings (id, registration_open) VALUES (1, true)
|
|
|
|
|
ON CONFLICT (id) DO UPDATE SET registration_open = true`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
|
const username = `reg_everyone_${stamp}`;
|
|
|
|
|
let newUserId;
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
username,
|
|
|
|
|
email: `${username}@example.com`,
|
|
|
|
|
password: TEST_PASSWORD,
|
|
|
|
|
displayNameEn: "Reg Everyone",
|
|
|
|
|
preferredLanguage: "en",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(res.status, 201, `register expected 201, got ${res.status}`);
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
assert.ok(Array.isArray(body.groups), "auth user payload must include groups[]");
|
|
|
|
|
assert.ok(
|
|
|
|
|
body.groups.some((g) => g.name === "Everyone"),
|
|
|
|
|
"register response must list Everyone in groups",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Confirm at the DB level that the membership was actually written.
|
|
|
|
|
const userRow = await pool.query(`SELECT id FROM users WHERE username = $1`, [username]);
|
|
|
|
|
newUserId = userRow.rows[0]?.id;
|
|
|
|
|
const linkRow = await pool.query(
|
|
|
|
|
`SELECT 1 FROM user_groups ug
|
|
|
|
|
JOIN groups g ON g.id = ug.group_id
|
|
|
|
|
WHERE ug.user_id = $1 AND g.name = 'Everyone'`,
|
|
|
|
|
[newUserId],
|
|
|
|
|
);
|
|
|
|
|
assert.equal(linkRow.rowCount, 1, "user_groups must link new user to Everyone");
|
|
|
|
|
} finally {
|
|
|
|
|
if (newUserId !== undefined) {
|
|
|
|
|
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [newUserId]);
|
|
|
|
|
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [newUserId]);
|
|
|
|
|
await pool.query(`DELETE FROM users WHERE id = $1`, [newUserId]);
|
|
|
|
|
}
|
|
|
|
|
if (priorOpen !== undefined) {
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE app_settings SET registration_open = $1 WHERE id = 1`,
|
|
|
|
|
[priorOpen],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 08:40:38 +00:00
|
|
|
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));
|
|
|
|
|
});
|