1ad311ad50
Original task
- Cover the new /api/roles create, update, and delete handlers with
automated tests so regressions in role validation, system-role
protection, and the in-use conflict response can't slip in unnoticed.
- Tests must run as part of the standard api-server test command.
What was added
- artifacts/api-server/tests/roles-crud.test.mjs with 7 tests:
* POST /api/roles -> 201 (also checks DB row + serialized fields)
* POST /api/roles duplicate name -> 409 (no second row inserted)
* POST /api/roles invalid name format -> 400 (no row leaked)
* PATCH /api/roles/:id description-only update -> 200, name unchanged
* DELETE /api/roles/:id on system role 'admin' -> 400, row preserved
* DELETE /api/roles/:id on in-use role -> 409 with userCount=1,
groupCount=1; row preserved
* DELETE /api/roles/:id on unused role -> 204, row gone
- The file follows the same pattern as groups-crud.test.mjs:
pg.Pool seed via DATABASE_URL, login -> connect.sid cookie,
thorough teardown of users / groups / roles / link tables.
Test infra
- No new test setup needed; the package's existing
"test": "node --test 'tests/**/*.test.mjs'" picks the file up
automatically. All 7 new tests pass.
Notes / drift
- Two pre-existing test failures (apps-open.test.mjs and one assertion
in groups-crud.test.mjs that depends on global group counts) are
unrelated to this task and were left untouched.
Replit-Task-Id: 4254b35b-ba28-443f-80a6-22f6ddfbedd6
304 lines
10 KiB
JavaScript
304 lines
10 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 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");
|
|
});
|