§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:
96
src/admin-nav.test.ts
Normal file
96
src/admin-nav.test.ts
Normal 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");
|
||||
});
|
||||
2
todo.md
2
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.
|
||||
|
||||
Reference in New Issue
Block a user