205 lines
7.1 KiB
JavaScript
205 lines
7.1 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 groupAdminUsername;
|
||
|
|
let memberUsername;
|
||
|
|
let outsiderUsername;
|
||
|
|
let groupAdminId;
|
||
|
|
let memberId;
|
||
|
|
let outsiderId;
|
||
|
|
let groupAdminCookie;
|
||
|
|
let memberCookie;
|
||
|
|
let outsiderCookie;
|
||
|
|
let groupId;
|
||
|
|
let restrictedAppId;
|
||
|
|
let unrestrictedAppId;
|
||
|
|
let permissionId;
|
||
|
|
|
||
|
|
async function login(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)}`;
|
||
|
|
groupAdminUsername = `gv_gadmin_${stamp}`;
|
||
|
|
memberUsername = `gv_member_${stamp}`;
|
||
|
|
outsiderUsername = `gv_out_${stamp}`;
|
||
|
|
|
||
|
|
// Create three users without any direct admin role.
|
||
|
|
const ga = 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`,
|
||
|
|
[groupAdminUsername, `${groupAdminUsername}@ex.com`, TEST_PASSWORD_HASH],
|
||
|
|
);
|
||
|
|
groupAdminId = ga.rows[0].id;
|
||
|
|
|
||
|
|
const m = await pool.query(
|
||
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||
|
|
VALUES ($1, $2, $3, 'Member', 'en', true) RETURNING id`,
|
||
|
|
[memberUsername, `${memberUsername}@ex.com`, TEST_PASSWORD_HASH],
|
||
|
|
);
|
||
|
|
memberId = m.rows[0].id;
|
||
|
|
|
||
|
|
const o = await pool.query(
|
||
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||
|
|
VALUES ($1, $2, $3, 'Outsider', 'en', true) RETURNING id`,
|
||
|
|
[outsiderUsername, `${outsiderUsername}@ex.com`, TEST_PASSWORD_HASH],
|
||
|
|
);
|
||
|
|
outsiderId = o.rows[0].id;
|
||
|
|
|
||
|
|
// Give member + outsider the basic 'user' role so they can list apps.
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_roles (user_id, role_id)
|
||
|
|
SELECT u.id, r.id FROM users u, roles r
|
||
|
|
WHERE u.id IN ($1, $2, $3) AND r.name = 'user'`,
|
||
|
|
[groupAdminId, memberId, outsiderId],
|
||
|
|
);
|
||
|
|
|
||
|
|
// Create a fresh group, attach 'admin' role to it (group-derived admin).
|
||
|
|
const grp = await pool.query(
|
||
|
|
`INSERT INTO groups (name, description_en) VALUES ($1, $2)
|
||
|
|
RETURNING id`,
|
||
|
|
[`vis_group_${stamp}`, "visibility test group"],
|
||
|
|
);
|
||
|
|
groupId = grp.rows[0].id;
|
||
|
|
|
||
|
|
// group-derived admin → groupAdmin user
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO group_roles (group_id, role_id)
|
||
|
|
SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||
|
|
[groupId],
|
||
|
|
);
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
|
||
|
|
[groupAdminId, groupId],
|
||
|
|
);
|
||
|
|
|
||
|
|
// Create an unrestricted app (no permissions attached).
|
||
|
|
const unr = await pool.query(
|
||
|
|
`INSERT INTO apps (name_en, name_ar, slug, icon_name, route, color, sort_order, is_active)
|
||
|
|
VALUES ($1, $1, $2, 'Grid', '/test-unr', '#6366f1', 999, true) RETURNING id`,
|
||
|
|
[`UnrestrictedApp_${stamp}`, `unr_${stamp}`],
|
||
|
|
);
|
||
|
|
unrestrictedAppId = unr.rows[0].id;
|
||
|
|
|
||
|
|
// Create a restricted app gated by a fresh permission.
|
||
|
|
const rest = await pool.query(
|
||
|
|
`INSERT INTO apps (name_en, name_ar, slug, icon_name, route, color, sort_order, is_active)
|
||
|
|
VALUES ($1, $1, $2, 'Grid', '/test-rest', '#6366f1', 998, true) RETURNING id`,
|
||
|
|
[`RestrictedApp_${stamp}`, `rest_${stamp}`],
|
||
|
|
);
|
||
|
|
restrictedAppId = rest.rows[0].id;
|
||
|
|
|
||
|
|
const perm = await pool.query(
|
||
|
|
`INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`,
|
||
|
|
[`vis_perm_${stamp}`, "visibility test perm"],
|
||
|
|
);
|
||
|
|
permissionId = perm.rows[0].id;
|
||
|
|
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
||
|
|
[restrictedAppId, permissionId],
|
||
|
|
);
|
||
|
|
|
||
|
|
// Grant the restricted app to the group via group_apps → only group members see it.
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
||
|
|
[groupId, restrictedAppId],
|
||
|
|
);
|
||
|
|
|
||
|
|
// Add 'member' user to the group too (so they should see the restricted app).
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
|
||
|
|
[memberId, groupId],
|
||
|
|
);
|
||
|
|
|
||
|
|
groupAdminCookie = await login(groupAdminUsername, TEST_PASSWORD);
|
||
|
|
memberCookie = await login(memberUsername, TEST_PASSWORD);
|
||
|
|
outsiderCookie = await login(outsiderUsername, TEST_PASSWORD);
|
||
|
|
});
|
||
|
|
|
||
|
|
after(async () => {
|
||
|
|
await pool.query(`DELETE FROM group_apps WHERE group_id = $1`, [groupId]);
|
||
|
|
await pool.query(`DELETE FROM group_roles WHERE group_id = $1`, [groupId]);
|
||
|
|
await pool.query(
|
||
|
|
`DELETE FROM user_groups WHERE user_id = ANY($1::int[]) OR group_id = $2`,
|
||
|
|
[[groupAdminId, memberId, outsiderId], groupId],
|
||
|
|
);
|
||
|
|
await pool.query(
|
||
|
|
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||
|
|
[[groupAdminId, memberId, outsiderId]],
|
||
|
|
);
|
||
|
|
await pool.query(`DELETE FROM groups WHERE id = $1`, [groupId]);
|
||
|
|
await pool.query(
|
||
|
|
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
|
||
|
|
[[restrictedAppId, unrestrictedAppId]],
|
||
|
|
);
|
||
|
|
await pool.query(`DELETE FROM permissions WHERE id = $1`, [permissionId]);
|
||
|
|
await pool.query(
|
||
|
|
`DELETE FROM apps WHERE id = ANY($1::int[])`,
|
||
|
|
[[restrictedAppId, unrestrictedAppId]],
|
||
|
|
);
|
||
|
|
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||
|
|
[groupAdminId, memberId, outsiderId],
|
||
|
|
]);
|
||
|
|
await pool.end();
|
||
|
|
});
|
||
|
|
|
||
|
|
async function listApps(cookie) {
|
||
|
|
const res = await fetch(`${API_BASE}/api/apps`, {
|
||
|
|
headers: { cookie },
|
||
|
|
});
|
||
|
|
assert.equal(res.status, 200);
|
||
|
|
return res.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
test("group-derived admin sees both restricted and unrestricted apps", async () => {
|
||
|
|
const apps = await listApps(groupAdminCookie);
|
||
|
|
const ids = apps.map((a) => a.id);
|
||
|
|
assert.ok(ids.includes(unrestrictedAppId), "admin should see unrestricted app");
|
||
|
|
assert.ok(ids.includes(restrictedAppId), "admin should see restricted app");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("group member sees the group-granted restricted app and the unrestricted one", async () => {
|
||
|
|
const apps = await listApps(memberCookie);
|
||
|
|
const ids = apps.map((a) => a.id);
|
||
|
|
assert.ok(ids.includes(unrestrictedAppId), "member should see unrestricted app");
|
||
|
|
assert.ok(
|
||
|
|
ids.includes(restrictedAppId),
|
||
|
|
"member should see restricted app via group_apps",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("non-member outsider sees unrestricted but NOT the restricted app", async () => {
|
||
|
|
const apps = await listApps(outsiderCookie);
|
||
|
|
const ids = apps.map((a) => a.id);
|
||
|
|
assert.ok(ids.includes(unrestrictedAppId), "outsider should see unrestricted app");
|
||
|
|
assert.ok(
|
||
|
|
!ids.includes(restrictedAppId),
|
||
|
|
"outsider must NOT see restricted app (no permission, no group)",
|
||
|
|
);
|
||
|
|
});
|