71 lines
4.4 KiB
TypeScript
71 lines
4.4 KiB
TypeScript
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
|
// (requireAdmin/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_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
|
|
|
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
|
const member: User = { email: "bo@x.io", id: "u2", roles: ["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, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
|
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
|
};
|
|
}
|
|
|
|
// ---- nav fragment ----
|
|
|
|
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
|
assert.equal(ADMIN_NAV.id, "admin");
|
|
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
|
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/roles", "/admin/clients"]);
|
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
|
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
|
});
|
|
|
|
// ---- auth gates ----
|
|
|
|
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
|
|
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
|
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
|
|
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
|
|
});
|
|
|
|
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" });
|
|
});
|