diff --git a/artifacts/api-server/tests/roles-crud.test.mjs b/artifacts/api-server/tests/roles-crud.test.mjs new file mode 100644 index 00000000..14cf6a76 --- /dev/null +++ b/artifacts/api-server/tests/roles-crud.test.mjs @@ -0,0 +1,303 @@ +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"); +});