Rename the coarse gate from role to permission, matching RBAC

This commit is contained in:
2026-08-03 17:02:47 +02:00
parent 04fe5b1e06
commit 096720904e
79 changed files with 744 additions and 738 deletions
+16 -16
View File
@@ -2,32 +2,32 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { composeNav, type NavNode } from "./nav.ts";
// Two plugin fragments; ids let the override target nodes, `role` gates per role.
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
const fragments: NavNode[][] = [
[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
],
}],
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
];
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]);
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
assert.deepEqual(tree, [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
]);
});
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
// A header the user can't reach takes its whole subtree, even visible children.
const gatedHeader: NavNode[][] = [[
{ id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [
@@ -36,26 +36,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
// No fragments / no roles → empty tree, never throws.
// No fragments / no permissions → empty tree, never throws.
assert.deepEqual(composeNav(), []);
});
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no roles, the public child keeps the
// A header with one public child + one gated child: with no permissions, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
],
}]];
// `public` is filter-only (like id/role) — never rendered into the output node.
// `public` is filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, []), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]);
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
{ href: "/a", id: "a", label: "Alpha" },
{ href: "/b", id: "b", label: "Beta" },
{ href: "/c", id: "c", label: "Gamma" },
{ href: "/secret", id: "secret", label: "Secret", role: "root" },
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
]];
const tree = composeNav(base, {
@@ -76,7 +76,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
hide: ["c"], // remove c from inside the group
}, ["root"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
assert.deepEqual(tree, [
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
{ href: "/a", label: "First" },