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 consumerUserId; let consumerGroupId; const createdRoleIds = []; const createdGroupIds = []; 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=")); } before(async () => { const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; adminUsername = `roles_admin_${stamp}`; const a = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Roles Admin', 'en', true) RETURNING id`, [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], ); adminId = a.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], ); 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 consumer = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Roles Consumer', 'en', true) RETURNING id`, [ `roles_consumer_${stamp}`, `roles_consumer_${stamp}@example.com`, TEST_PASSWORD_HASH, ], ); consumerUserId = consumer.rows[0].id; createdUserIds.push(consumerUserId); const grp = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, 'roles test group') RETURNING id`, [`RolesGrp_${stamp}`], ); consumerGroupId = grp.rows[0].id; createdGroupIds.push(consumerGroupId); adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); }); after(async () => { if (createdRoleIds.length) { await pool.query( `DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [createdRoleIds], ); await pool.query( `DELETE FROM group_roles WHERE role_id = ANY($1::int[])`, [createdRoleIds], ); await pool.query( `DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`, [createdRoleIds], ); await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ createdRoleIds, ]); } if (createdGroupIds.length) { await pool.query( `DELETE FROM group_roles WHERE group_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query( `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ createdGroupIds, ]); } for (const uid of createdUserIds) { 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(); }); function uniqueRoleName(prefix) { const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${stamp}`; } test("POST /api/roles creates a role and returns 201", async () => { const name = uniqueRoleName("role_create"); const res = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name, descriptionEn: "Created by automated test", descriptionAr: "تم إنشاؤه بواسطة الاختبار", }), }); assert.equal(res.status, 201); const created = await res.json(); createdRoleIds.push(created.id); assert.equal(created.name, name); assert.equal(created.descriptionEn, "Created by automated test"); assert.equal(created.descriptionAr, "تم إنشاؤه بواسطة الاختبار"); assert.equal(created.isSystem, false); const dbRow = await pool.query(`SELECT id, name FROM roles WHERE id = $1`, [ created.id, ]); assert.equal(dbRow.rowCount, 1); assert.equal(dbRow.rows[0].name, name); }); test("POST /api/roles with a duplicate name returns 409", async () => { const name = uniqueRoleName("role_dup"); const first = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name }), }); assert.equal(first.status, 201); const firstBody = await first.json(); createdRoleIds.push(firstBody.id); const dup = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name }), }); assert.equal(dup.status, 409); const body = await dup.json(); assert.match(String(body.error || ""), /already taken/i); const dbRows = await pool.query(`SELECT id FROM roles WHERE name = $1`, [ name, ]); assert.equal(dbRows.rowCount, 1, "duplicate must not insert a second row"); }); test("POST /api/roles rejects an invalid name with 400", async () => { const before = await pool.query(`SELECT COUNT(*)::int AS c FROM roles`); const res = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name: "1bad name!" }), }); assert.equal(res.status, 400); const after = await pool.query(`SELECT COUNT(*)::int AS c FROM roles`); assert.equal(after.rows[0].c, before.rows[0].c, "no role row should leak"); }); test("PATCH /api/roles/:id updates description without touching the name", async () => { const name = uniqueRoleName("role_patch"); const create = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name, descriptionEn: "before" }), }); assert.equal(create.status, 201); const created = await create.json(); createdRoleIds.push(created.id); const patch = await fetch(`${API_BASE}/api/roles/${created.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ descriptionEn: "after", descriptionAr: "بعد" }), }); assert.equal(patch.status, 200); const updated = await patch.json(); assert.equal(updated.id, created.id); assert.equal(updated.name, name, "name must be unchanged"); assert.equal(updated.descriptionEn, "after"); assert.equal(updated.descriptionAr, "بعد"); const dbRow = await pool.query( `SELECT name, description_en, description_ar FROM roles WHERE id = $1`, [created.id], ); assert.equal(dbRow.rows[0].name, name); assert.equal(dbRow.rows[0].description_en, "after"); assert.equal(dbRow.rows[0].description_ar, "بعد"); }); test("DELETE /api/roles/:id refuses to delete a system role with 400", async () => { const sysRow = await pool.query( `SELECT id FROM roles WHERE name = 'admin' LIMIT 1`, ); assert.ok(sysRow.rowCount > 0, "system 'admin' role must exist"); const sysId = sysRow.rows[0].id; const res = await fetch(`${API_BASE}/api/roles/${sysId}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 400); const body = await res.json(); assert.match(String(body.error || ""), /system role/i); const stillThere = await pool.query(`SELECT id FROM roles WHERE id = $1`, [ sysId, ]); assert.equal(stillThere.rowCount, 1, "system role must not be deleted"); }); test("DELETE /api/roles/:id returns 409 with userCount/groupCount when in use", async () => { const name = uniqueRoleName("role_inuse"); const create = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name }), }); assert.equal(create.status, 201); const created = await create.json(); createdRoleIds.push(created.id); await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, [consumerUserId, created.id], ); await pool.query( `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`, [consumerGroupId, created.id], ); const res = await fetch(`${API_BASE}/api/roles/${created.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const body = await res.json(); assert.match(String(body.error || ""), /in use/i); assert.equal(body.userCount, 1); assert.equal(body.groupCount, 1); const stillThere = await pool.query(`SELECT id FROM roles WHERE id = $1`, [ created.id, ]); assert.equal(stillThere.rowCount, 1, "in-use role must not be deleted"); }); test("DELETE /api/roles/:id returns 204 and removes the row when unused", async () => { const name = uniqueRoleName("role_del"); const create = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name }), }); assert.equal(create.status, 201); const created = await create.json(); const res = await fetch(`${API_BASE}/api/roles/${created.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const gone = await pool.query(`SELECT id FROM roles WHERE id = $1`, [ created.id, ]); assert.equal(gone.rowCount, 0, "role row should be gone after delete"); });