diff --git a/src/admin-nav.test.ts b/src/admin-nav.test.ts new file mode 100644 index 0000000..6daba0e --- /dev/null +++ b/src/admin-nav.test.ts @@ -0,0 +1,96 @@ +// Direct units for the admin section's pure nav + auth helpers (todo §8). They're security-critical +// (requireAdmin/guardedForm gate every admin write) and reused across all four admin screens, so pin +// the contract here in isolation — the admin-*.test.ts HTTP tests exercise them only end-to-end. +import assert from "node:assert/strict"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { Socket } from "node:net"; +import { test } from "node:test"; +import { + ADMIN_PERMISSION, ADMIN_USERS_BASE, adminNav, adminSection, buildConfirmModel, guardedForm, requireAdmin, +} from "./admin-nav.ts"; +import { buildContext, type RequestContext, type User } from "./context.ts"; +import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "./csrf.ts"; +import { GuardError } from "./guards.ts"; +import { DEFAULT_MENU } from "./menu-config.ts"; + +const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] }; +const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; + +function reqCtx(opts: { body?: string; cookie?: string; method?: string; user?: User | null } = {}): RequestContext { + const req = new IncomingMessage(new Socket()); + req.method = opts.method ?? "GET"; + req.url = "/admin/users"; + if (opts.cookie) req.headers.cookie = opts.cookie; + req.push(opts.body ?? null); + if (opts.body != null) req.push(null); + return buildContext(req, new ServerResponse(req), { user: opts.user ?? null }); +} + +const labels = (nodes: { label: string }[]): string[] => nodes.map((n) => n.label); + +// ---- nav helpers ---- + +test("adminSection: gated Admin header over the four screens; current marks the item + opens the header", () => { + const plain = adminSection(); + assert.equal(plain.id, "admin"); + assert.equal(plain.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin + assert.equal(plain.open, undefined); + assert.deepEqual(plain.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]); + assert.deepEqual(labels(plain.children ?? []), ["Users", "Groups", "Roles", "OAuth2 clients"]); + assert.ok(plain.children?.every((c) => c.current === undefined)); // nothing active + + const onRoles = adminSection("roles"); + assert.equal(onRoles.open, true); + assert.equal(onRoles.children?.find((c) => c.id === "roles")?.current, true); + assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined); +}); + +test("adminNav: prepends Dashboard and role-filters the section (admin sees it, others get only Dashboard)", () => { + const forAdmin = adminNav(admin.roles, DEFAULT_MENU, "users"); + assert.deepEqual(labels(forAdmin), ["Dashboard", "Admin"]); + // composeNav strips `id` from rendered nodes but keeps `current`/`href`, so match the active item by href. + assert.equal(forAdmin.find((n) => n.label === "Admin")!.children?.find((c) => c.href === ADMIN_USERS_BASE)?.current, true); + + assert.deepEqual(labels(adminNav(member.roles, DEFAULT_MENU, "users")), ["Dashboard"]); // non-admin → gated section dropped + assert.deepEqual(labels(adminNav([], DEFAULT_MENU, "users")), ["Dashboard"]); // anonymous too +}); + +// ---- auth gates ---- + +test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => { + assert.throws(() => requireAdmin(reqCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login"); + assert.throws(() => requireAdmin(reqCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403); + assert.equal(requireAdmin(reqCtx({ user: admin })), admin); +}); + +test("guardedForm: valid double-submit → the parsed body, bad/missing token → 403, non-POST → undefined", async () => { + const secret = "test-secret"; + const token = issueCsrfToken(secret); + const post = (over: { body?: string; cookie?: string }) => reqCtx({ method: "POST", ...over }); + + // cookie token === submitted field, both a genuine signature → the form is returned + const ok = await guardedForm(post({ body: `${CSRF_FIELD}=${encodeURIComponent(token)}&name=Bo`, cookie: `${CSRF_COOKIE}=${token}` }), secret); + assert.equal(ok?.get("name"), "Bo"); + + await assert.rejects(guardedForm(post({ body: `${CSRF_FIELD}=${encodeURIComponent(token)}` }), secret), // no cookie + (e: unknown) => e instanceof GuardError && e.status === 403); + await assert.rejects(guardedForm(post({ body: `${CSRF_FIELD}=nope`, cookie: `${CSRF_COOKIE}=${token}` }), secret), // field ≠ cookie + (e: unknown) => e instanceof GuardError && e.status === 403); + + assert.equal(await guardedForm(reqCtx({ method: "GET" }), secret), undefined); // not a mutation → no gate, no body read +}); + +// ---- confirm-page model ---- + +test("buildConfirmModel wires the danger action, message, role-filtered nav and shell", () => { + 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", + csrfToken: "tok", current: "users", menu: DEFAULT_MENU, message: "Delete ada@x.io?", title: "Delete user", user: admin, + }); + 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.ok(labels(model.nav).includes("Admin")); // admin user ⇒ section present in the in-screen sidebar + assert.equal(model.shell.title, "Delete user"); +}); diff --git a/todo.md b/todo.md index 1bbcef7..b7135d2 100644 --- a/todo.md +++ b/todo.md @@ -117,7 +117,7 @@ everything via Docker. - [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §7 test accretion (`shifts.test.ts`, `chrome.test.ts`, `plugin-api.test.ts`, `shell.test.ts`, + the §7 additions to `app`/`dashboard`/`bootstrap`). The §7 tests were written tests-first across four small commits, so unlike §6 (a triplicated degrade matrix) there was no boilerplate accretion — the per-module matrices are one-contract-per-test and the reference plugin's `shifts.test.ts` deliberately tests its pure builders in isolation (the dev/test pattern the contract preaches), so those stay. **One genuine "combine in a good way":** `dashboard.test.ts` had two sibling tests — "wires in the permission-gated Admin section" (§5) and "merges discovered plugin nav fragments, permission-filtered" (§7) — that assert the *same* contract (`buildDashboardModel` role-filters a gated nav source via `composeNav`) on two sources. Merged into one "dashboard role-filters the gated Admin section and plugin fragments, each independently", which also **strengthens** coverage: it now asserts cross-gating (an `admin` doesn't see the plugin section, a `scheduling:read` holder doesn't see Admin) that neither original checked — 3 model builds vs 4, all prior assertions preserved. Left separate (distinct code paths/levels, not fat): the chrome-unit vs dashboard-unit vs app-HTTP plugin-nav tests (three different functions — `buildPluginChrome`, `buildDashboardModel`, the rendered shell — each independently merges fragments), and the two `app.test.ts` plugin integration tests (RouteResult shapes/static/405 vs chrome+CSRF round-trip — different surfaces, own fixtures). Pure test refactor, no production code touched (per the §6 test-cleanup precedent, no stability reviewer). 301 → 300 units; typecheck + tests green. ## 8. Testing & CI -- [ ] node --test units across helpers / router / nav / auth (tests-first throughout). +- [x] node --test units across helpers / router / nav / auth (tests-first throughout). → Audited unit coverage across the four areas; built tests-first through §0–§7, it was already near-complete — **helpers** (`list-query`/`paginate`/`body`/`icons`/`config`/`context`/`flow-view`/`gen-jwks`/`hooks`/`shell-context`, `static` via `app.test.ts`), **router** (`router`/`view-resolver`/`plugin`/`discovery`), **nav** (`nav`/`nav-tree`/`chrome`/`menu-config`/dashboard merge), **auth** (`jwt`/`jwt-middleware`/`jwks`/`guards`/`login`/`csrf`/`cookie`/`kratos-*`/`keto-client`/`oauth-*`/`hydra-admin`) all carry direct `node --test` units. **One genuine gap closed:** `admin-nav.ts` — its pure nav helpers (`adminSection`/`adminNav`) and security-critical auth gates (`requireAdmin`/`guardedForm`, the shared gate+CSRF preamble for every admin write) were exercised only *indirectly* via the admin HTTP integration tests. Added `src/admin-nav.test.ts` (tests-first style, against the existing contract): `adminSection` (gated "Admin" header over the 4 screens, `current` marks+opens), `adminNav` (prepends Dashboard, role-filters the section — admin sees it, non-admin/anon get only Dashboard; asserts via `href` since composeNav strips `id` but keeps `current`), `requireAdmin` (anon→401→/login, non-admin→403, admin→user), `guardedForm` (valid double-submit→parsed body, missing/forged token→403, non-POST→undefined), `buildConfirmModel`. Only `server.ts` (entry-point composition root, exercised by every E2E boot) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code (no stability reviewer, per the §6/§7 test-cleanup precedent). - [ ] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. - [ ] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. - [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.