// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical // (requirePermission/guardedForm gate every admin write) and reused across all four screens, so pin the // contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts. // Import only from the #plugin-api barrel — the same contract boundary the plugin code uses. import assert from "node:assert/strict"; import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { test } from "node:test"; import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, adminPermission, buildConfirmModel, guardedForm, requirePermission } from "./admin-shared.ts"; const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] }; const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] }; const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] }; const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome; function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { const url = new URL("http://localhost/admin/users"); const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; req.method = opts.method ?? "GET"; return { chrome: CHROME, user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, verifyCsrf: opts.verifyCsrf ?? (() => true), }; } // ---- nav fragment ---- test("ADMIN_NAV: an ungated Admin header whose four screens each gate on their own read permission", () => { assert.equal(ADMIN_NAV.id, "admin"); // No gate on the header: a user may hold one screen's permission and not another's. composeNav // drops a header left with no visible children, so holding none of the four hides the section. assert.equal(ADMIN_NAV.permission, undefined); assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "permissions:read", "oauth2-clients:read"]); // Labels are catalog keys; the host translates them with this plugin's catalog when it composes // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys. assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.permissions", "admin.nav.clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined)); }); // ---- permission naming ---- test("adminPermission builds : — read for GET/HEAD, write for every mutation", () => { assert.equal(adminPermission("users", "GET"), "users:read"); assert.equal(adminPermission("users", "HEAD"), "users:read"); // a GET route also answers HEAD assert.equal(adminPermission("users", "POST"), "users:write"); assert.equal(adminPermission("groups", "DELETE"), "groups:write"); // anything that isn't a read is a write assert.equal(adminPermission("oauth2-clients", "get"), "oauth2-clients:read"); // method case is the caller's }); // ---- auth gates ---- test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => { assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403); assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader); // The whole point of the split: users:read opens the list but not the create/delete POSTs. assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403); assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer); // Resources don't leak into each other: a users holder is not a groups holder. assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403); }); test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => { const post = (over: { body?: string; verifyCsrf?: (s: string | null | undefined) => boolean }) => fakeCtx({ method: "POST", ...over }); const ok = await guardedForm(post({ body: "_csrf=tok&name=Bo", verifyCsrf: () => true })); assert.equal(ok?.get("name"), "Bo"); await assert.rejects(guardedForm(post({ body: "_csrf=nope&name=Bo", verifyCsrf: () => false })), // ctx.verifyCsrf rejects (e: unknown) => e instanceof GuardError && e.status === 403); assert.equal(await guardedForm(fakeCtx({ method: "GET" })), undefined); // not a mutation → no gate, no body read }); // ---- confirm-page model ---- test("buildConfirmModel wires the danger action, message, breadcrumbs and title (shell comes from ctx.chrome)", () => { const model = buildConfirmModel({ breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }], cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user", message: "Delete ada@x.io?", title: "Delete user", }); assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" }); assert.equal(model.message, "Delete ada@x.io?"); assert.equal(model.cancelHref, ADMIN_USERS_BASE); assert.equal(model.title, "Delete user"); assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" }); });