Extend the read-only treatment to OAuth2 clients and write-intent GETs
CI / full-gate (push) Successful in 2m42s
CI / full-gate (push) Successful in 2m42s
This commit is contained in:
+19
-2
@@ -1110,7 +1110,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
||||
|
||||
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
||||
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
||||
// sees Users and nothing else; holding none of the four, composeNav drops the emptied header.
|
||||
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
||||
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
||||
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
||||
@@ -1317,7 +1317,10 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get } = await adminHarness(t, { keto, kratosAdmin });
|
||||
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
|
||||
// "no Register button" assertion below would pass without proving anything.
|
||||
const hydra = stubHydra({ listClients: async () => ({ clients: [{ client_id: "existing", client_name: "Reporting" }], nextPageToken: null }) });
|
||||
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
||||
const readOnly = ["users:read", "groups:read"];
|
||||
|
||||
const list = await (await get("/admin/users", readOnly)).text();
|
||||
@@ -1337,6 +1340,20 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
||||
assert.doesNotMatch(group, /Delete group/);
|
||||
assert.doesNotMatch(group, /Save permissions/);
|
||||
|
||||
// The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
|
||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||
const clients = await clientsRes.text();
|
||||
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
||||
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
|
||||
|
||||
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
||||
// rendering a form whose submit would 403.
|
||||
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
|
||||
assert.equal((await get(path, readOnly)).status, 403, path);
|
||||
}
|
||||
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
||||
|
||||
// A writer sees the affordances the reader didn't.
|
||||
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||
assert.match(writable, /Save changes/);
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ export interface RequestContext {
|
||||
// Every permission the installed plugins declare, deduped and sorted — the fixed list an admin
|
||||
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
||||
// what *this user holds*. Empty when no installed plugin declares any.
|
||||
declaredPermissions: PermissionDecl[];
|
||||
declaredPermissions: readonly PermissionDecl[];
|
||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
@@ -66,7 +66,7 @@ export interface BuildContextOptions {
|
||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
chrome?: () => PageChrome;
|
||||
declaredPermissions?: PermissionDecl[];
|
||||
declaredPermissions?: readonly PermissionDecl[];
|
||||
user?: User | null;
|
||||
locale?: string;
|
||||
localeHref?: (href: string) => string;
|
||||
|
||||
Reference in New Issue
Block a user