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, Tx, 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("Tx")); 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("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], ); } } }); 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)); });