Unify the §5 admin-test duplication (todo §5 test cleanup); the three admin-screen HTTP tests (Users/Groups/Roles) in app.test.ts repeated an identical ~13-line harness preamble (createApp+listen+url+CSRF token+admin cookie+get/post), an identical gate block, and a stateful in-memory KetoClient defined 3× (trivial stubKeto + two byte-identical inline fakes). Extracted adminHarness(t,opts)→{url,token,get,post}, assertAdminGate(url,get,path), and one fakeKeto(tuples?,over?) that subsumes stubKeto (login tests now fakeKeto([],…)) and both admin fakes (fakeKeto(tuples) / fakeKeto(tuples,{expand})); hoisted shared sameSet/matchesTuple up beside it. Per-module unit files keep their matrix pattern (no force-merge across modules; build*ListModel stays per-file). −30 net lines, zero coverage lost; typecheck + 244 units green.
This commit is contained in:
130
src/app.test.ts
130
src/app.test.ts
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { after, before, test, type TestContext } from "node:test";
|
import { after, before, test, type TestContext } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createApp } from "./app.ts";
|
import { createApp, type AppOptions } from "./app.ts";
|
||||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||||
import { staticJwks } from "./jwks.ts";
|
import { staticJwks } from "./jwks.ts";
|
||||||
@@ -228,7 +228,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
|
|||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
||||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
||||||
const keto = stubKeto({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
|
||||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
||||||
|
|
||||||
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
||||||
@@ -393,22 +393,55 @@ const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
|
|||||||
updateMetadataPublic: async () => ({ id: "x" }),
|
updateMetadataPublic: async () => ({ id: "x" }),
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
const stubKeto = (over: Partial<KetoClient>): KetoClient => ({
|
const sameSet = (a?: SubjectSet, b?: SubjectSet): boolean =>
|
||||||
|
(!a && !b) || (!!a && !!b && a.namespace === b.namespace && a.object === b.object && a.relation === b.relation);
|
||||||
|
const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boolean =>
|
||||||
|
(f.namespace === undefined || t.namespace === f.namespace) &&
|
||||||
|
(f.object === undefined || t.object === f.object) &&
|
||||||
|
(f.relation === undefined || t.relation === f.relation) &&
|
||||||
|
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
|
||||||
|
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
|
||||||
|
// A stateful in-memory KetoClient over a tuple array (writes mutate it); used by login + the admin screens.
|
||||||
|
const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => ({
|
||||||
check: async () => false,
|
check: async () => false,
|
||||||
deleteTuple: async () => {},
|
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
|
||||||
expand: async () => ({ type: "leaf" }),
|
expand: async () => ({ type: "leaf" }),
|
||||||
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
|
||||||
writeTuple: async () => {},
|
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||||
|
|
||||||
|
// Shared harness for the §5 admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||||
|
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||||
|
const ADMIN_CSRF = "admin-secret";
|
||||||
|
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
||||||
|
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), ...opts });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const token = issueCsrfToken(ADMIN_CSRF);
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
||||||
|
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
|
||||||
|
const post = (path: string, body: string) =>
|
||||||
|
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
||||||
|
return { get, post, token, url };
|
||||||
|
}
|
||||||
|
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||||
|
async function assertAdminGate(url: string, get: (path: string, roles?: string[]) => Promise<Response>, path: string) {
|
||||||
|
const anon = await fetch(url + path, { redirect: "manual" });
|
||||||
|
assert.equal(anon.status, 303);
|
||||||
|
assert.equal(anon.headers.get("location"), "/login");
|
||||||
|
assert.equal((await get(path, [])).status, 403);
|
||||||
|
}
|
||||||
|
|
||||||
test("login completion (/auth/complete): a live session mints the JWT cookie; no session → /login, no cookie", async (t) => {
|
test("login completion (/auth/complete): a live session mints the JWT cookie; no session → /login, no cookie", async (t) => {
|
||||||
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
|
||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||||
const keto = stubKeto({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
||||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string) => {
|
const complete = async (app: ReturnType<typeof createApp>, cookie?: string) => {
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -423,7 +456,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
||||||
|
|
||||||
// No Kratos session: nothing minted, bounce to /login with no cookie.
|
// No Kratos session: nothing minted, bounce to /login with no cookie.
|
||||||
const none = await complete(createApp({ keto: stubKeto({}), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
|
const none = await complete(createApp({ keto: fakeKeto(), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
|
||||||
assert.equal(none.status, 303);
|
assert.equal(none.status, 303);
|
||||||
assert.equal(none.headers.get("location"), "/login");
|
assert.equal(none.headers.get("location"), "/login");
|
||||||
assert.equal(none.headers.get("set-cookie"), null);
|
assert.equal(none.headers.get("set-cookie"), null);
|
||||||
@@ -474,23 +507,9 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
listIdentities: async () => ({ identities: store, nextPageToken: null }),
|
listIdentities: async () => ({ identities: store, nextPageToken: null }),
|
||||||
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
|
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
|
||||||
});
|
});
|
||||||
const csrfSecret = "admin-secret";
|
const { get, post, token, url } = await adminHarness(t, { kratosAdmin });
|
||||||
const app = createApp({ csrfSecret, jwks: staticJwks([ecJwk]), kratosAdmin });
|
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
|
||||||
t.after(() => app.close());
|
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const token = issueCsrfToken(csrfSecret);
|
|
||||||
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
|
||||||
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
|
|
||||||
const post = (path: string, body: string) =>
|
|
||||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
|
||||||
|
|
||||||
// Gate: anonymous → /login; a signed-in non-admin → 403.
|
await assertAdminGate(url, get, "/admin/users");
|
||||||
const anon = await fetch(url + "/admin/users", { redirect: "manual" });
|
|
||||||
assert.equal(anon.status, 303);
|
|
||||||
assert.equal(anon.headers.get("location"), "/login");
|
|
||||||
assert.equal((await get("/admin/users", [])).status, 403);
|
|
||||||
|
|
||||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||||
const listHtml = await (await get("/admin/users")).text();
|
const listHtml = await (await get("/admin/users")).text();
|
||||||
@@ -547,16 +566,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Built-in Groups admin screen (§5): gate + list/create/membership/delete over HTTP against a
|
// Built-in Groups admin screen (§5): gate + list/create/membership/delete over HTTP against a
|
||||||
// fake in-memory Keto (tuples are the only state) and a stub Kratos admin (resolves member emails).
|
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
|
||||||
const sameSet = (a?: SubjectSet, b?: SubjectSet): boolean =>
|
|
||||||
(!a && !b) || (!!a && !!b && a.namespace === b.namespace && a.object === b.object && a.relation === b.relation);
|
|
||||||
const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boolean =>
|
|
||||||
(f.namespace === undefined || t.namespace === f.namespace) &&
|
|
||||||
(f.object === undefined || t.object === f.object) &&
|
|
||||||
(f.relation === undefined || t.relation === f.relation) &&
|
|
||||||
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
|
|
||||||
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
|
|
||||||
|
|
||||||
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
|
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
|
||||||
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
||||||
const grace = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
const grace = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
||||||
@@ -565,31 +575,11 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||||
];
|
];
|
||||||
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
|
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
|
||||||
const keto: KetoClient = {
|
const keto = fakeKeto(tuples);
|
||||||
check: async () => false,
|
|
||||||
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
|
|
||||||
expand: async () => ({ type: "leaf" }),
|
|
||||||
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
|
|
||||||
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
|
|
||||||
};
|
|
||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const csrfSecret = "groups-secret";
|
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
const app = createApp({ csrfSecret, jwks: staticJwks([ecJwk]), keto, kratosAdmin });
|
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
|
||||||
t.after(() => app.close());
|
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const token = issueCsrfToken(csrfSecret);
|
|
||||||
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
|
||||||
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
|
|
||||||
const post = (path: string, body: string) =>
|
|
||||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
|
||||||
|
|
||||||
// Gate: anonymous → /login; a signed-in non-admin → 403.
|
await assertAdminGate(url, get, "/admin/groups");
|
||||||
const anon = await fetch(url + "/admin/groups", { redirect: "manual" });
|
|
||||||
assert.equal(anon.status, 303);
|
|
||||||
assert.equal(anon.headers.get("location"), "/login");
|
|
||||||
assert.equal((await get("/admin/groups", [])).status, 403);
|
|
||||||
|
|
||||||
// List: the existing group shows + the "add" link.
|
// List: the existing group shows + the "add" link.
|
||||||
const listHtml = await (await get("/admin/groups")).text();
|
const listHtml = await (await get("/admin/groups")).text();
|
||||||
@@ -654,31 +644,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
tuple: { namespace: "", object: "", relation: "", subject_set: set },
|
tuple: { namespace: "", object: "", relation: "", subject_set: set },
|
||||||
type: "union",
|
type: "union",
|
||||||
});
|
});
|
||||||
const keto: KetoClient = {
|
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
||||||
check: async () => false,
|
|
||||||
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
|
|
||||||
expand: async (set) => expandSet(set),
|
|
||||||
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) }),
|
|
||||||
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
|
|
||||||
};
|
|
||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const csrfSecret = "roles-secret";
|
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
const app = createApp({ csrfSecret, jwks: staticJwks([ecJwk]), keto, kratosAdmin });
|
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
|
||||||
t.after(() => app.close());
|
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const token = issueCsrfToken(csrfSecret);
|
|
||||||
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
|
||||||
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
|
|
||||||
const post = (path: string, body: string) =>
|
|
||||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
|
||||||
|
|
||||||
// Gate: anonymous → /login; a signed-in non-admin → 403.
|
await assertAdminGate(url, get, "/admin/roles");
|
||||||
const anon = await fetch(url + "/admin/roles", { redirect: "manual" });
|
|
||||||
assert.equal(anon.status, 303);
|
|
||||||
assert.equal(anon.headers.get("location"), "/login");
|
|
||||||
assert.equal((await get("/admin/roles", [])).status, 403);
|
|
||||||
|
|
||||||
// List: the existing role shows + the "add" link.
|
// List: the existing role shows + the "add" link.
|
||||||
const listHtml = await (await get("/admin/roles")).text();
|
const listHtml = await (await get("/admin/roles")).text();
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -99,7 +99,7 @@ everything via Docker.
|
|||||||
- [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green.
|
- [x] Wire into the menu (admin section, permission-gated). → Extracted `adminSection(current?)` in `admin-nav.ts` as the single source of truth for the built-in screens' menu links: a permission-gated (`admin`) "Admin" header whose children are Users/Groups/Roles. Wired into the **global** dashboard menu (`dashboard.ts` appends `adminSection()`) so an admin sees the section on `/`; `composeNav`'s `filterByRoles` drops the whole gated header + subtree for a non-admin/anonymous (cosmetic — the routes themselves stay independently `GuardError(403)`-gated). The in-screen `adminNav()` now reuses the same `adminSection(current)` (Dashboard link + the active-marked section) so the two navs can't drift; narrowed `AdminScreen` to `groups|roles|users` (the home link was never `current`). Reuses existing sprite icons (no icon-guard change). Tests-first: `dashboard.test.ts` (admin→section present with the three hrefs; non-admin→absent) + `app.test.ts` HTTP integration (admin JWT→`/admin/users` link rendered, anonymous→absent). Default anonymous `/` render is byte-equivalent (section filtered out) so the visual E2E is unaffected. README Layout line updated. Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. 242→244 units + typecheck green.
|
||||||
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`/`views/`/`config/`/docs (weighted to the §5 admin screens). Architecture: **no Critical/High** (functional-core/imperative-shell genuinely honored, security primitives sound). Product: **2 Critical + 1 High**. **Fixed now (tests-first):** (1) Critical (product) — the Roles "Effective access" view showed group→role membership *transitively* but `login.ts` `readRoles` granted only **direct** memberships into the JWT, so a user holding a role *only via a group* was listed as having it yet gated as if not (two screens contradicting). Per the user's call, made `readRoles` transitive: enumerate the defined roles + Keto-`check` each (resolves group membership), so the JWT now matches the Effective-access view + the OPL model — at login/refresh only, never per request (README login section + `admin-roles.ts` header updated). (2) Critical (product) — no confirmation on destructive actions: added a server-rendered (zero-JS) confirm step (`views/admin/confirm.ejs` + `partials/confirm-body.ejs`, shared `buildConfirmModel`) — `GET /admin/{users,groups,roles}/:id/delete` renders an interstitial (Cancel + the real POST); each detail/edit Delete control is now a link to it. (3) High (product) — self-lockout: an admin can no longer delete or deactivate **their own** account, revoke **their own** (direct) admin grant, or delete the **admin role** outright (each → 400 + inline error). Covers the direct-grant paths (incl. the bootstrap-seeded admin, which holds a direct grant); admin held *only* via a group can still be self-revoked, so the robust "last effective admin won't drop" check is deferred to **§9** (stability-reviewer Medium). (4) MEDIUM (arch M1 pt.1) — extracted the gate+CSRF preamble copied verbatim across the 3 admin handlers into `admin-nav.ts` `requireAdmin`/`guardedForm` (one security-critical copy, can't drift). (5) MEDIUM (arch M4) — `shellUser` no longer blanks the email: name = email local part, full email beneath (matches `toUserView`). Tests-first throughout (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual E2E + the full-stack auth-refresh E2E green (the latter re-verifies live login→transitive `readRoles`→`roles:["admin"]`). **Deferred (reviewer-scoped, not the §5 checkpoint):** the host internal route-table (fold the admin if-ladder + Hydra into `matchRoute`/`isAuthorized`, arch M1 pt.2) → **§6** (the 2nd/3rd Hydra screen is the forcing function); admin list-model/template near-duplication across Users/Groups/Roles (arch M3) → the §5 comment/test-cleanup items below (lines 101–102); success-flash after writes + welcoming empty-list states + warn-on-dangling-group-references + >250-row truncation notice (product Medium) → §5 polish / §8 E2E; `safeUrl()` href helper (arch L1 — the recovery link is server-built, not exploitable today) → **§7** (first untrusted-URL flow); oversized-body→500 should be 413 (arch M2) + prod Ory-URL `https` enforcement (arch L3) + `§N`-in-comments / README Layout drift (arch L4) → **§9** (ops/security).
|
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on all of `src/`/`views/`/`config/`/docs (weighted to the §5 admin screens). Architecture: **no Critical/High** (functional-core/imperative-shell genuinely honored, security primitives sound). Product: **2 Critical + 1 High**. **Fixed now (tests-first):** (1) Critical (product) — the Roles "Effective access" view showed group→role membership *transitively* but `login.ts` `readRoles` granted only **direct** memberships into the JWT, so a user holding a role *only via a group* was listed as having it yet gated as if not (two screens contradicting). Per the user's call, made `readRoles` transitive: enumerate the defined roles + Keto-`check` each (resolves group membership), so the JWT now matches the Effective-access view + the OPL model — at login/refresh only, never per request (README login section + `admin-roles.ts` header updated). (2) Critical (product) — no confirmation on destructive actions: added a server-rendered (zero-JS) confirm step (`views/admin/confirm.ejs` + `partials/confirm-body.ejs`, shared `buildConfirmModel`) — `GET /admin/{users,groups,roles}/:id/delete` renders an interstitial (Cancel + the real POST); each detail/edit Delete control is now a link to it. (3) High (product) — self-lockout: an admin can no longer delete or deactivate **their own** account, revoke **their own** (direct) admin grant, or delete the **admin role** outright (each → 400 + inline error). Covers the direct-grant paths (incl. the bootstrap-seeded admin, which holds a direct grant); admin held *only* via a group can still be self-revoked, so the robust "last effective admin won't drop" check is deferred to **§9** (stability-reviewer Medium). (4) MEDIUM (arch M1 pt.1) — extracted the gate+CSRF preamble copied verbatim across the 3 admin handlers into `admin-nav.ts` `requireAdmin`/`guardedForm` (one security-critical copy, can't drift). (5) MEDIUM (arch M4) — `shellUser` no longer blanks the email: name = email local part, full email beneath (matches `toUserView`). Tests-first throughout (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual E2E + the full-stack auth-refresh E2E green (the latter re-verifies live login→transitive `readRoles`→`roles:["admin"]`). **Deferred (reviewer-scoped, not the §5 checkpoint):** the host internal route-table (fold the admin if-ladder + Hydra into `matchRoute`/`isAuthorized`, arch M1 pt.2) → **§6** (the 2nd/3rd Hydra screen is the forcing function); admin list-model/template near-duplication across Users/Groups/Roles (arch M3) → the §5 comment/test-cleanup items below (lines 101–102); success-flash after writes + welcoming empty-list states + warn-on-dangling-group-references + >250-row truncation notice (product Medium) → §5 polish / §8 E2E; `safeUrl()` href helper (arch L1 — the recovery link is server-built, not exploitable today) → **§7** (first untrusted-URL flow); oversized-body→500 should be 413 (arch M2) + prod Ory-URL `https` enforcement (arch L3) + `§N`-in-comments / README Layout drift (arch L4) → **§9** (ops/security).
|
||||||
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §5 admin accretion. The §5 code was authored dense, so the wins are targeted: tightened the three near-identical module-header blocks (`admin-users`/`admin-groups`/`admin-roles`) — dropped per-file restatement the README/code already carry (subject-form detail → "see parseSubject", "no user/group store" → covered by README "stateless", the verbatim "it gates… CSRF-guards… maps each action to a RouteResult" boilerplate → "gated admin-only, CSRF-guarded"). README **Layout**: compressed the `views/` run-on (long admin/ + per-body-partial enumeration → grouped) and fixed an accuracy gap — it now lists the §5 delete-confirm view. Left intact: the EJS view config-doc headers (the only schema for untyped locals), the security-rationale comments, and the legitimate §9 forward-ref in `admin-roles.ts` (the deferred last-effective-admin check). Docs/comments-only (per AGENTS.md, no stability-reviewer needed); typecheck + 244 units green.
|
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Pass over the §5 admin accretion. The §5 code was authored dense, so the wins are targeted: tightened the three near-identical module-header blocks (`admin-users`/`admin-groups`/`admin-roles`) — dropped per-file restatement the README/code already carry (subject-form detail → "see parseSubject", "no user/group store" → covered by README "stateless", the verbatim "it gates… CSRF-guards… maps each action to a RouteResult" boilerplate → "gated admin-only, CSRF-guarded"). README **Layout**: compressed the `views/` run-on (long admin/ + per-body-partial enumeration → grouped) and fixed an accuracy gap — it now lists the §5 delete-confirm view. Left intact: the EJS view config-doc headers (the only schema for untyped locals), the security-rationale comments, and the legitimate §9 forward-ref in `admin-roles.ts` (the deferred last-effective-admin check). Docs/comments-only (per AGENTS.md, no stability-reviewer needed); typecheck + 244 units green.
|
||||||
- [ ] 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.
|
- [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 §5 admin tests. The genuine §5-era duplication was all in `app.test.ts`: the three admin-screen HTTP tests (Users/Groups/Roles) each repeated an identical ~13-line harness preamble (createApp + listen + url + CSRF token + admin cookie + get/post), an identical 5-line gate block, and a stateful in-memory `KetoClient` defined 3× (the trivial `stubKeto` + two byte-identical inline fakes). Unified into shared helpers — `adminHarness(t, opts)` → `{url, token, get, post}`, `assertAdminGate(url, get, path)`, and one `fakeKeto(tuples?, over?)` that subsumes `stubKeto` (the login tests now use `fakeKeto([], …)`) and both inline admin fakes (`fakeKeto(tuples)` / `fakeKeto(tuples, { expand })`); hoisted the shared `sameSet`/`matchesTuple` up next to it. The per-module unit files (admin-users/groups/roles + the focused units) already follow the deliberate matrix pattern and the §3/§4 "don't force-merge across distinct modules" rule, so the near-identical `build*ListModel` tests stay per-file (each guards its own function; the source-side list-model dedup is the deferred arch-M3 item, not the test side). −30 net lines, zero coverage lost; typecheck + 244 units green.
|
||||||
|
|
||||||
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest)
|
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest)
|
||||||
- [ ] Login-challenge handler: authenticate via Kratos session, accept/reject.
|
- [ ] Login-challenge handler: authenticate via Kratos session, accept/reject.
|
||||||
|
|||||||
Reference in New Issue
Block a user