RBAC 3.0
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createPermissionGroup,
|
||||
createUser,
|
||||
deletePermissionGroup,
|
||||
deleteUser,
|
||||
getPermissionGroup,
|
||||
getUserByUsername,
|
||||
getUserGrantedPermissions,
|
||||
getUserGroupIds,
|
||||
listPermissionGroups,
|
||||
setUserExtraPermissions,
|
||||
setUserGroups,
|
||||
updatePermissionGroup,
|
||||
} from "./users";
|
||||
import { hasPermission, getUserPermissions } from "./rbac";
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
const createdGroupIds: number[] = [];
|
||||
|
||||
async function tempUser(role: "viewer" | "operator" | "admin") {
|
||||
const username = `pg_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
|
||||
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserByUsername(username);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
return user!;
|
||||
}
|
||||
|
||||
function tempGroup(name: string, permissions: string[]) {
|
||||
const result = createPermissionGroup(name + "_" + Date.now() + "_" + Math.floor(Math.random() * 1000), null, permissions, null);
|
||||
expect(result.success).toBe(true);
|
||||
createdGroupIds.push(result.group!.id);
|
||||
return result.group!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (createdUserIds.length > 0) {
|
||||
const id = createdUserIds.pop();
|
||||
if (typeof id === "number") deleteUser(id);
|
||||
}
|
||||
while (createdGroupIds.length > 0) {
|
||||
const id = createdGroupIds.pop();
|
||||
if (typeof id === "number") deletePermissionGroup(id);
|
||||
}
|
||||
});
|
||||
|
||||
describe("permission groups CRUD", () => {
|
||||
test("createPermissionGroup persists the group with permissions", () => {
|
||||
const group = tempGroup("crud_one", ["clients:metadata", "clients:disconnect"]);
|
||||
expect(group.name).toContain("crud_one");
|
||||
expect(group.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
|
||||
const loaded = getPermissionGroup(group.id);
|
||||
expect(loaded?.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
|
||||
});
|
||||
|
||||
test("createPermissionGroup rejects duplicate names", () => {
|
||||
const group = tempGroup("dup", []);
|
||||
const dup = createPermissionGroup(group.name, null, [], null);
|
||||
expect(dup.success).toBe(false);
|
||||
expect(dup.error).toMatch(/already exists/i);
|
||||
});
|
||||
|
||||
test("updatePermissionGroup replaces the permission set", () => {
|
||||
const group = tempGroup("upd", ["clients:metadata"]);
|
||||
const result = updatePermissionGroup(group.id, { permissions: ["clients:uninstall"] });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.group!.permissions).toEqual(["clients:uninstall"]);
|
||||
});
|
||||
|
||||
test("listPermissionGroups returns all created groups", () => {
|
||||
const a = tempGroup("list_a", ["chat:write"]);
|
||||
const b = tempGroup("list_b", ["clients:metadata"]);
|
||||
const ids = new Set(listPermissionGroups().map((g) => g.id));
|
||||
expect(ids.has(a.id)).toBe(true);
|
||||
expect(ids.has(b.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user permission groups + extras", () => {
|
||||
test("setUserGroups + getUserGrantedPermissions unions group perms", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const g1 = tempGroup("gp_a", ["clients:metadata"]);
|
||||
const g2 = tempGroup("gp_b", ["clients:disconnect"]);
|
||||
setUserGroups(viewer.id, [g1.id, g2.id]);
|
||||
|
||||
const granted = getUserGrantedPermissions(viewer.id);
|
||||
expect(granted.has("clients:metadata")).toBe(true);
|
||||
expect(granted.has("clients:disconnect")).toBe(true);
|
||||
expect(granted.has("clients:uninstall")).toBe(false);
|
||||
});
|
||||
|
||||
test("setUserExtraPermissions adds a permission outside any group", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
|
||||
expect(getUserGrantedPermissions(viewer.id).has("clients:uninstall")).toBe(true);
|
||||
});
|
||||
|
||||
test("hasPermission union: viewer with a group gains the perm", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const g = tempGroup("hp_a", ["clients:metadata"]);
|
||||
expect(hasPermission(viewer.role, "clients:metadata", viewer.id)).toBe(false);
|
||||
setUserGroups(viewer.id, [g.id]);
|
||||
expect(hasPermission(viewer.role, "clients:metadata", viewer.id)).toBe(true);
|
||||
});
|
||||
|
||||
test("groups are additive only — they cannot revoke an admin perm", async () => {
|
||||
const admin = await tempUser("admin");
|
||||
setUserGroups(admin.id, []);
|
||||
expect(hasPermission(admin.role, "clients:metadata", admin.id)).toBe(true);
|
||||
});
|
||||
|
||||
test("userOverride still revokes — an operator with can_build=0 keeps no clients:build via empty group", async () => {
|
||||
const op = await tempUser("operator");
|
||||
// Operator has clients:build by default. Removing all groups + extras does
|
||||
// NOT take it away because role grants it.
|
||||
setUserGroups(op.id, []);
|
||||
setUserExtraPermissions(op.id, []);
|
||||
expect(hasPermission(op.role, "clients:build", op.id)).toBe(true);
|
||||
// Only the legacy can_build override (userOverride returning false) revokes.
|
||||
const { setUserCanBuild } = await import("./users");
|
||||
setUserCanBuild(op.id, false);
|
||||
expect(hasPermission(op.role, "clients:build", op.id)).toBe(false);
|
||||
// Now add an extra → it gets granted back.
|
||||
setUserExtraPermissions(op.id, ["clients:build"]);
|
||||
expect(hasPermission(op.role, "clients:build", op.id)).toBe(true);
|
||||
});
|
||||
|
||||
test("getUserPermissions includes role + group + extra permissions for a viewer", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const g = tempGroup("up_a", ["clients:metadata"]);
|
||||
setUserGroups(viewer.id, [g.id]);
|
||||
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
|
||||
const perms = new Set(getUserPermissions(viewer.id, viewer.role));
|
||||
expect(perms.has("clients:metadata")).toBe(true);
|
||||
expect(perms.has("clients:uninstall")).toBe(true);
|
||||
// Viewer has no role-level perms
|
||||
expect(perms.has("clients:control")).toBe(false);
|
||||
expect(perms.has("users:manage")).toBe(false);
|
||||
});
|
||||
|
||||
test("getUserGroupIds returns assigned ids", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const g = tempGroup("gid_a", []);
|
||||
setUserGroups(viewer.id, [g.id]);
|
||||
expect(getUserGroupIds(viewer.id)).toContain(g.id);
|
||||
});
|
||||
|
||||
test("deleting a group removes it from users", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const g = tempGroup("del_a", ["clients:metadata"]);
|
||||
setUserGroups(viewer.id, [g.id]);
|
||||
expect(getUserGroupIds(viewer.id)).toContain(g.id);
|
||||
deletePermissionGroup(g.id);
|
||||
expect(getUserGroupIds(viewer.id)).not.toContain(g.id);
|
||||
// Stop the afterEach from double-deleting
|
||||
createdGroupIds.splice(createdGroupIds.indexOf(g.id), 1);
|
||||
});
|
||||
|
||||
test("deleting a user clears their group assignments + extras from cache", async () => {
|
||||
const viewer = await tempUser("viewer");
|
||||
const g = tempGroup("dl_user", ["clients:metadata"]);
|
||||
setUserGroups(viewer.id, [g.id]);
|
||||
setUserExtraPermissions(viewer.id, ["clients:disconnect"]);
|
||||
expect(getUserGrantedPermissions(viewer.id).size).toBeGreaterThan(0);
|
||||
deleteUser(viewer.id);
|
||||
createdUserIds.splice(createdUserIds.indexOf(viewer.id), 1);
|
||||
expect(getUserGrantedPermissions(viewer.id).size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user