§8 unit coverage audit (todo §8); node --test units across helpers/router/nav/auth. Built tests-first through §0-§7, coverage was already near-complete — every helper/router/nav/auth module carries direct units (static.ts via app.test.ts). Closed the one genuine gap: admin-nav.ts's pure nav helpers (adminSection/adminNav) and security-critical auth gates (requireAdmin/guardedForm, the shared gate+CSRF preamble for every admin write) were only exercised indirectly via the admin HTTP tests. New src/admin-nav.test.ts: adminSection (gated header + current/open), adminNav (Dashboard prepend + role-filter), requireAdmin (401/login, 403, user), guardedForm (valid double-submit / bad-token-403 / non-POST-undefined), buildConfirmModel. Only server.ts (entry-point composition root) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code.

This commit is contained in:
2026-06-19 15:53:20 +02:00
parent 29737d65a0
commit 1961a4c163
2 changed files with 97 additions and 1 deletions

96
src/admin-nav.test.ts Normal file
View File

@@ -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");
});

View File

@@ -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.