a005acb93d
CI / full-gate (push) Successful in 2m38s
README loses the competitor comparison, the personas and the repeated philosophy; the
five near-identical E2E command blocks become a table plus one command, and the file
map a clause per entry. AGENTS.md keeps every decision but drops the narrative around
them. todo.md's completed items collapse to their task line — git holds the rest.
Comments lose restatement, README duplication and history ("used to", "originally",
dated notes). AGENTS.md gains a Prose discipline section making this a standing pass on
every change rather than a one-off cleanup.
src/compose.test.ts now expects 6 documented E2E run commands, not 10, since the README
states the command once instead of per suite.
1581 lines
95 KiB
TypeScript
1581 lines
95 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { generateKeyPairSync, randomUUID, sign, type JsonWebKey } from "node:crypto";
|
|
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { request as httpRequest } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { after, before, test, type TestContext } from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createApp, type AppOptions } from "./app.ts";
|
|
import { readFormBody } from "./body.ts";
|
|
import { createLogger } from "../logger.ts";
|
|
import { createDenylist } from "../auth/denylist.ts";
|
|
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
|
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
|
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
|
|
import { staticJwks } from "../auth/jwks.ts";
|
|
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
|
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
|
|
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
|
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
|
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
|
import { createI18n } from "../i18n/runtime.ts";
|
|
import type { MenuConfig } from "../ui/menu-config.ts";
|
|
import { loadI18n } from "../i18n/load.ts";
|
|
|
|
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
|
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
|
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
|
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
|
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
|
|
|
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
|
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
|
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
|
function mintJwt(payload: Record<string, unknown>): string {
|
|
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
|
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
|
}
|
|
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
|
|
const session = (permissions: string[] = []): string =>
|
|
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
|
|
|
|
const server = createApp({ jwks: staticJwks([ecJwk]) });
|
|
let base = "";
|
|
|
|
before(async () => {
|
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
base = `http://localhost:${(server.address() as AddressInfo).port}`;
|
|
});
|
|
|
|
after(() => server.close());
|
|
|
|
test("the dashboard at /dashboard: the instructional starter in the unified shell, gated to a session", async () => {
|
|
// The dashboard is gated to a signed-in user, so present a session.
|
|
const res = await fetch(base + "/dashboard", { headers: { cookie: session() } });
|
|
assert.equal(res.status, 200);
|
|
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
|
const html = await res.text();
|
|
// The unified app shell: the same sidebar/menu every page renders.
|
|
assert.match(html, /Plainpages/); // sidebar brand
|
|
assert.match(html, /<aside class="sidebar"/);
|
|
// The default is a short instructional starter, not a mock-data list.
|
|
assert.match(html, /Starter dashboard/);
|
|
assert.match(html, /export default definePlugin/); // shows how to replace it from a plugin
|
|
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
|
|
assert.doesNotMatch(html, /Avery Kline/);
|
|
|
|
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page.
|
|
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
|
|
assert.ok(csrfCookie, "GET /dashboard issues a CSRF cookie");
|
|
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
|
|
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
|
|
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
|
|
});
|
|
|
|
test("/ is the public landing: anonymous → 200 with intro + sign-in/register links, in the unified shell", async () => {
|
|
const res = await fetch(base + "/", { redirect: "manual" });
|
|
assert.equal(res.status, 200); // public — no redirect to sign in
|
|
const html = await res.text();
|
|
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
|
assert.match(html, /href="\/registration"/); // and to register
|
|
// the same app shell every page renders — the menu shows even when signed out (permission-filtered).
|
|
assert.match(html, /<aside class="sidebar"/);
|
|
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
|
});
|
|
|
|
test("/dashboard is gated: an anonymous visitor is bounced to sign in (return_to kept)", async () => {
|
|
const res = await fetch(base + "/dashboard", { redirect: "manual" });
|
|
assert.equal(res.status, 303);
|
|
assert.equal(res.headers.get("location"), "/login?return_to=%2Fdashboard");
|
|
});
|
|
|
|
test("plugins replace either landing: `home` owns the public /, `dashboard` owns the gated /dashboard", async (t) => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
|
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
|
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
|
// The dashboard view renders the native app shell from ctx.chrome.
|
|
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
|
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
|
const portal: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "board" }),
|
|
home: () => ({ data: { brand: "Acme" }, view: "welcome" }),
|
|
id: "portal",
|
|
};
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [portal], pluginsDir: dir });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
// `home` replaces the public landing — still ungated (anonymous sees it).
|
|
const pub = await fetch(url + "/", { redirect: "manual" });
|
|
assert.equal(pub.status, 200);
|
|
assert.match(await pub.text(), /Welcome to Acme/);
|
|
|
|
// `dashboard` replaces the gated dashboard — anonymous bounces, a session lands on the plugin's page.
|
|
assert.equal((await fetch(url + "/dashboard", { redirect: "manual" })).status, 303);
|
|
const board = await fetch(url + "/dashboard", { headers: { cookie: session() } });
|
|
assert.equal(board.status, 200);
|
|
const html = await board.text();
|
|
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell
|
|
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.user
|
|
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
|
|
});
|
|
|
|
test("renders branding from the menu config into the shell: logo + default theme", async (t) => {
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), menu: { branding: { logo: "/public/brand/logo.svg", name: "Acme Ops", theme: "dark" }, override: {} } });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/dashboard`, { headers: { cookie: session() } })).text();
|
|
|
|
assert.match(html, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/);
|
|
assert.match(html, /Acme Ops/);
|
|
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
|
|
});
|
|
|
|
test("emits a structured access-log line per request (the injected logger)", async (t) => {
|
|
const lines: string[] = [];
|
|
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`); // the public "/" — no auth
|
|
assert.equal(res.status, 200);
|
|
await res.text(); // consume the body so the connection closes (the access line emits on close)
|
|
|
|
// The line is emitted on connection close (after the body is sent) — poll briefly for it.
|
|
let line: string | undefined;
|
|
for (let i = 0; i < 50 && !line; i++) {
|
|
line = lines.find((l) => l.includes('"msg":"request"'));
|
|
if (!line) await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
assert.ok(line, "an access line is logged for the request");
|
|
const rec = JSON.parse(line!);
|
|
assert.equal(rec.method, "GET");
|
|
assert.equal(rec.path, "/"); // pathname only — the ?q=… query is dropped (may carry tokens)
|
|
assert.equal(rec.status, 200);
|
|
assert.equal(rec["service.name"], "plainpages");
|
|
assert.equal(typeof rec.ms, "number");
|
|
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
|
|
});
|
|
|
|
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => {
|
|
const lines: string[] = [];
|
|
const upstream: { traceparent: string | undefined; url: string }[] = [];
|
|
const realFetch = globalThis.fetch;
|
|
// Intercept only the upstream call; everything else (the test's own request to the server) passes through.
|
|
globalThis.fetch = async (input, init) => {
|
|
const url = String(input);
|
|
if (!url.startsWith("http://upstream.test")) return realFetch(input, init);
|
|
upstream.push({ traceparent: new Headers(init?.headers).get("traceparent") ?? undefined, url });
|
|
return new Response("[]", { headers: { "content-type": "application/json" }, status: 200 });
|
|
};
|
|
t.after(() => { globalThis.fetch = realFetch; });
|
|
|
|
const plugin: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
id: "obs",
|
|
routes: [{
|
|
handler: async (ctx) => {
|
|
ctx.log.info("ping handled", { who: "obs" }); // plugin logging via ctx.log
|
|
await ctx.log.fetch("http://upstream.test/data"); // an upstream call, traced + propagated
|
|
return { json: { ok: true } };
|
|
},
|
|
method: "GET",
|
|
path: "/ping",
|
|
}],
|
|
};
|
|
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }), plugins: [plugin] });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
const inbound = "0af7651916cd43dd8448eb211c80319c";
|
|
await (await fetch(base + "/obs/ping", { headers: { traceparent: `00-${inbound}-b7ad6b7169203331-01` } })).text();
|
|
|
|
// ctx.log emitted a line tagged with the request's id (handler ran inside the request trace).
|
|
let pl: string | undefined;
|
|
for (let i = 0; i < 50 && !pl; i++) { pl = lines.find((l) => l.includes('"msg":"ping handled"')); if (!pl) await new Promise((r) => setTimeout(r, 10)); }
|
|
assert.ok(pl, "ctx.log line is emitted");
|
|
const rec = JSON.parse(pl!);
|
|
assert.equal(rec.who, "obs");
|
|
assert.ok(rec.requestId, "the plugin line shares the request id");
|
|
|
|
// ctx.log.fetch propagated a W3C traceparent continuing the inbound distributed trace.
|
|
const up = upstream.find((r) => r.url === "http://upstream.test/data");
|
|
assert.ok(up?.traceparent, "ctx.log.fetch injects a traceparent");
|
|
assert.equal(up!.traceparent!.split("-")[1], inbound, "the upstream call continues the inbound trace");
|
|
});
|
|
|
|
test("ctx.log after a client abort doesn't throw: the request log is ended only once the handler unwinds", async (t) => {
|
|
// The request span is ended on response "close", which also fires on a premature client abort.
|
|
// The handler keeps running after that — its ctx.log must not throw "already ended", so end() is
|
|
// deferred until the handler settles (regression for the abort race).
|
|
let afterCloseOk = false;
|
|
let afterCloseErr: string | undefined;
|
|
const plugin: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
id: "slow",
|
|
routes: [{
|
|
handler: async (ctx) => {
|
|
await new Promise((r) => setTimeout(r, 120)); // outlasts the client abort below
|
|
try { ctx.log.info("after abort", {}); afterCloseOk = true; } // would throw if end() already ran
|
|
catch (e) { afterCloseErr = String(e); }
|
|
return { json: { ok: true } };
|
|
},
|
|
method: "GET",
|
|
path: "/go", // route mounts at /<id>/<path> → /slow/go
|
|
}],
|
|
};
|
|
const app = createApp({ log: createLogger({ level: "none" }), plugins: [plugin] });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
// Abort the request mid-handler (well before the 120ms), forcing res "close" while it still runs.
|
|
const ac = new AbortController();
|
|
setTimeout(() => ac.abort(), 20);
|
|
await assert.rejects(fetch(base + "/slow/go", { signal: ac.signal })); // the client sees the abort
|
|
|
|
await new Promise((r) => setTimeout(r, 200)); // let the handler finish post-abort
|
|
assert.equal(afterCloseErr, undefined, "ctx.log did not throw after the client disconnected");
|
|
assert.ok(afterCloseOk, "the handler logged successfully after close");
|
|
|
|
// The server is unharmed — a fresh request still succeeds.
|
|
assert.equal((await fetch(base + "/slow/go")).status, 200);
|
|
});
|
|
|
|
test("static serving: GET sends body + content-type, HEAD headers only, unsafe paths → 403", async () => {
|
|
const get = await fetch(base + "/public/css/styles.css");
|
|
assert.equal(get.status, 200);
|
|
assert.match(get.headers.get("content-type") ?? "", /text\/css/);
|
|
|
|
const head = await fetch(base + "/public/css/styles.css", { method: "HEAD" });
|
|
assert.equal(head.status, 200);
|
|
assert.ok(Number(head.headers.get("content-length")) > 0);
|
|
assert.equal((await head.text()).length, 0);
|
|
|
|
// Encoded traversal and a NUL byte are refused before touching the filesystem.
|
|
assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403);
|
|
assert.equal((await fetch(base + "/public/%00")).status, 403);
|
|
});
|
|
|
|
test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => {
|
|
// Default app (secureCookies off): a page (the public "/") and a static asset both carry the
|
|
// hardening headers, proving they're set once up front and survive each writeHead (paths merge).
|
|
for (const path of ["/", "/public/css/styles.css"]) {
|
|
const res = await fetch(base + path);
|
|
assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
|
|
assert.equal(res.headers.get("x-frame-options"), "DENY", path);
|
|
assert.match(res.headers.get("content-security-policy") ?? "", /default-src 'self'/, path);
|
|
assert.equal(res.headers.get("strict-transport-security"), null, path); // http dev → no HSTS
|
|
}
|
|
|
|
// A https deployment (SECURE_COOKIES=true) adds HSTS.
|
|
const secure = createApp({ secureCookies: true });
|
|
await new Promise<void>((r) => secure.listen(0, r));
|
|
t.after(() => secure.close());
|
|
const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`);
|
|
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
|
});
|
|
|
|
// Production caches compiled templates; rendering must stay correct across repeated requests.
|
|
test("renders correctly with template caching enabled", async () => {
|
|
const app = createApp({ cache: true });
|
|
try {
|
|
await new Promise<void>((resolve) => app.listen(0, resolve));
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}/`; // the public landing
|
|
for (let i = 0; i < 2; i++) {
|
|
const res = await fetch(url);
|
|
assert.equal(res.status, 200);
|
|
assert.match(await res.text(), /Plainpages/);
|
|
}
|
|
} finally {
|
|
app.close();
|
|
}
|
|
});
|
|
|
|
test("returns the 404 HTML page for unknown routes", async () => {
|
|
const res = await fetch(base + "/missing");
|
|
assert.equal(res.status, 404);
|
|
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
|
assert.match(await res.text(), /404/);
|
|
});
|
|
|
|
// Raw request so we can send an arbitrary Host (fetch derives Host from the URL); connect to the
|
|
// loopback server but present whatever host we want to exercise the canonical-host check.
|
|
function rawGet(port: number, path: string, host: string, method = "GET"): Promise<{ status: number; location: string | undefined; body: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = httpRequest({ host: "127.0.0.1", port, path, method, headers: { host } }, (res) => {
|
|
let body = "";
|
|
res.on("data", (c) => (body += c));
|
|
res.on("end", () => resolve({ status: res.statusCode ?? 0, location: res.headers.location, body }));
|
|
});
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
|
|
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
|
|
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const port = (app.address() as AddressInfo).port;
|
|
|
|
// Off-canonical host → 308 to the canonical origin, path + query preserved.
|
|
const off = await rawGet(port, "/dashboard?q=x", `127.0.0.1:${port}`);
|
|
assert.equal(off.status, 308);
|
|
assert.equal(off.location, "http://canonical.example:3000/dashboard?q=x");
|
|
|
|
// On the canonical host → no canonicalisation (the gated dashboard 303s to /login, never 308).
|
|
const on = await rawGet(port, "/dashboard", "canonical.example:3000");
|
|
assert.notEqual(on.status, 308);
|
|
|
|
// Static assets are host-agnostic (served before the check) so health checks on any host still pass.
|
|
const asset = await rawGet(port, "/public/css/styles.css", `127.0.0.1:${port}`);
|
|
assert.equal(asset.status, 200);
|
|
|
|
// A 308 must not replay a cross-host POST — non-GET/HEAD is left alone (not canonicalised).
|
|
const post = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "POST");
|
|
assert.notEqual(post.status, 308);
|
|
});
|
|
|
|
test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-agnostic deploys unaffected)", async () => {
|
|
// The shared `server` is built without appUrl, so any Host is served as-is (no 308).
|
|
const r = await rawGet(Number(new URL(base).port), "/missing", "anything.example");
|
|
assert.equal(r.status, 404); // reaches the normal handler, not a redirect
|
|
});
|
|
|
|
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
|
|
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>, which must
|
|
// land on a real themed page rather than the catch-all 404.
|
|
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
|
|
assert.equal(res.status, 200);
|
|
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
|
const html = await res.text();
|
|
assert.doesNotMatch(html, /Page not found/); // not the 404 view
|
|
assert.match(html, /sign in|sign-in|try again|something went wrong/i);
|
|
assert.match(html, /href="\/login"/); // a path back into auth
|
|
});
|
|
|
|
test("renders the 500 HTML page when a handler throws", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
|
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
|
|
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
|
|
try {
|
|
await new Promise<void>((resolve) => app.listen(0, resolve));
|
|
// A session reaches the (throwing) dashboard render; the gate would otherwise bounce to /login.
|
|
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/dashboard`, { headers: { cookie: session() } });
|
|
assert.equal(res.status, 500);
|
|
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
|
assert.match(await res.text(), /500/);
|
|
} finally {
|
|
app.close();
|
|
rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
|
const demoPlugin: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
id: "demo",
|
|
routes: [
|
|
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
|
|
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
|
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
|
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
|
|
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
|
|
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
|
|
],
|
|
};
|
|
|
|
async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): Promise<string> {
|
|
const app = createApp(pluginsDir ? { plugins, pluginsDir } : { plugins });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
}
|
|
|
|
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
|
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
|
mkdirSync(join(dir, "demo", "public"), { recursive: true });
|
|
// The view also include()s a core building-block partial, proving plugin views reuse them.
|
|
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<h1>Hello <%= who %></h1><%- include("partials/theme-switch") %>`);
|
|
writeFileSync(join(dir, "demo", "public", "app.css"), ".demo{color:red}");
|
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
|
const url = await startApp(t, [demoPlugin], dir);
|
|
|
|
// Path param + html
|
|
const hi = await fetch(url + "/demo/hello/world");
|
|
assert.equal(hi.status, 200);
|
|
assert.match(await hi.text(), /Hi world/);
|
|
|
|
// json
|
|
const data = await fetch(url + "/demo/data");
|
|
assert.match(data.headers.get("content-type") ?? "", /application\/json/);
|
|
assert.deepEqual(await data.json(), { ok: true });
|
|
|
|
// redirect (POST → 303 Location)
|
|
const go = await fetch(url + "/demo/go", { method: "POST", redirect: "manual" });
|
|
assert.equal(go.status, 303);
|
|
assert.equal(go.headers.get("location"), "/demo/hello/world");
|
|
|
|
// view rendered from the plugin's own views/, including a core partial
|
|
const page = await (await fetch(url + "/demo/page")).text();
|
|
assert.match(page, /Hello Plainpages/);
|
|
assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved
|
|
|
|
// static asset served from the plugin's own public/ at /public/<id>/
|
|
const css = await fetch(url + "/public/demo/app.css");
|
|
assert.equal(css.status, 200);
|
|
assert.match(css.headers.get("content-type") ?? "", /text\/css/);
|
|
assert.match(await css.text(), /\.demo/);
|
|
assert.equal((await fetch(url + "/public/demo/..%2f..%2fplugin.ts")).status, 403); // traversal still blocked
|
|
|
|
// gated route, anonymous → redirect to sign in (like the built-in screens), not a dead-end 403;
|
|
// the requested page is preserved as return_to so login lands the user back there.
|
|
const denied = await fetch(url + "/demo/secret", { redirect: "manual" });
|
|
assert.equal(denied.status, 303);
|
|
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
|
|
|
// a route marked public is reachable anonymously — no gate, no redirect.
|
|
const open = await fetch(url + "/demo/public-page", { redirect: "manual" });
|
|
assert.equal(open.status, 200);
|
|
assert.match(await open.text(), /open to all/);
|
|
|
|
// known path + wrong method → 405 with Allow; unknown path → 404
|
|
const wrong = await fetch(url + "/demo/data", { method: "DELETE" });
|
|
assert.equal(wrong.status, 405);
|
|
assert.match(wrong.headers.get("allow") ?? "", /GET/);
|
|
assert.equal((await fetch(url + "/demo/nope")).status, 404);
|
|
});
|
|
|
|
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf", async (t) => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
|
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
|
|
// The view composes the core shell from ctx.chrome — branding, the global nav — and its own
|
|
// CSRF-guarded form carrying chrome.csrfToken (the representative way a plugin form gets the token,
|
|
// independent of the shell's auth-dependent profile/sign-out block).
|
|
writeFileSync(join(dir, "panelkit", "views", "panel.ejs"),
|
|
`<%- include("partials/shell", { body: '<form method="post" action="/panelkit/save"><input type="hidden" name="_csrf" value="' + chrome.csrfToken + '" /></form>', brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), title, user: chrome.user }) %>`);
|
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
|
|
|
const plugin: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
id: "panelkit",
|
|
nav: [{ href: "/panelkit/panel", icon: "i-grid", id: "panelkit", label: "Panel kit" }],
|
|
routes: [
|
|
{ handler: (ctx) => ({ data: { chrome: ctx.chrome, title: "Panel" }, view: "panel" }), method: "GET", path: "/panel" },
|
|
{
|
|
handler: async (ctx) => {
|
|
const form = await readFormBody(ctx.req);
|
|
if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, "bad csrf");
|
|
return { redirect: "/panelkit/panel" };
|
|
},
|
|
method: "POST", path: "/save",
|
|
},
|
|
],
|
|
};
|
|
|
|
const secret = "test-csrf-secret";
|
|
const app = createApp({ csrfSecret: secret, plugins: [plugin], pluginsDir: dir });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
// GET renders the shell: branding (DEFAULT_MENU), the (ungated) plugin nav, and a CSRF cookie
|
|
// whose token is embedded in the plugin's own form (double-submit).
|
|
const res = await fetch(url + "/panelkit/panel");
|
|
assert.equal(res.status, 200);
|
|
const body = await res.text();
|
|
assert.match(body, /class="brand-name">Plainpages/);
|
|
assert.match(body, /Panel kit/);
|
|
const cookieTok = /plainpages_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
|
|
assert.ok(cookieTok, "a plugin route issues the CSRF cookie when fresh");
|
|
assert.equal(/name="_csrf" value="([^"]+)"/.exec(body)?.[1], cookieTok);
|
|
|
|
// POST with no token → 403 (ctx.verifyCsrf fails closed); matching cookie + field → 303.
|
|
assert.equal((await fetch(url + "/panelkit/save", { method: "POST", redirect: "manual" })).status, 403);
|
|
const tok = issueCsrfToken(secret);
|
|
const ok = await fetch(url + "/panelkit/save", {
|
|
body: `_csrf=${encodeURIComponent(tok)}`,
|
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${tok}` },
|
|
method: "POST", redirect: "manual",
|
|
});
|
|
assert.equal(ok.status, 303);
|
|
});
|
|
|
|
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
|
|
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
|
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
|
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 secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
|
|
|
// Token carrying the gating permission → the handler runs (200).
|
|
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
|
|
assert.equal(ok.status, 200);
|
|
assert.equal(await ok.text(), "secret");
|
|
|
|
// No cookie and an expired token both render anonymous → the gate bounces to sign in (303 → /login,
|
|
// remembering the gated page as return_to).
|
|
const noCookie = await secret();
|
|
assert.equal(noCookie.status, 303);
|
|
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
|
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
|
|
|
|
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
|
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
|
// composition + permission-filtering is covered in the admin-screen tests below.
|
|
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
|
|
assert.equal(dash.status, 200);
|
|
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
|
assert.equal(anonDash.status, 303);
|
|
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
|
|
});
|
|
|
|
test("revocation denylist: a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
|
|
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
|
|
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
|
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 secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
|
|
|
|
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
|
|
|
denylist.revoke("u1");
|
|
assert.equal((await secret(nowSec - 5)).status, 303); // the pre-revoke token now bounces to /login
|
|
assert.equal((await secret(nowSec + 5)).status, 200); // a fresh re-login (iat after the revoke) still works
|
|
});
|
|
|
|
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
|
|
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
|
|
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
|
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["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.
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const ok = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired } });
|
|
assert.equal(ok.status, 200);
|
|
assert.equal(await ok.text(), "secret");
|
|
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=/);
|
|
|
|
// Kratos session gone: no re-mint, the stale cookie is cleared, the now-anonymous request bounces to sign in.
|
|
const dead = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
|
await new Promise<void>((r) => dead.listen(0, r));
|
|
t.after(() => dead.close());
|
|
const denied = await fetch(`http://localhost:${(dead.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired }, redirect: "manual" });
|
|
assert.equal(denied.status, 303);
|
|
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
|
assert.match(denied.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
|
|
|
|
// Ory unreachable (not a dead session): whoami throws → degrade to anonymous (bounce to /login, not 500),
|
|
// and leave the cookie untouched so the token can re-mint once Ory recovers.
|
|
const down = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => { throw new KratosError("kratos down", 503, ""); }), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
|
await new Promise<void>((r) => down.listen(0, r));
|
|
t.after(() => down.close());
|
|
const outage = await fetch(`http://localhost:${(down.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired }, redirect: "manual" });
|
|
assert.equal(outage.status, 303);
|
|
assert.equal(outage.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
|
assert.equal(outage.headers.get("set-cookie"), null);
|
|
});
|
|
|
|
test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => {
|
|
const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0];
|
|
const guarded: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
id: "guarded",
|
|
routes: [
|
|
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
|
|
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
|
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
|
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
|
],
|
|
};
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
|
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 auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
|
|
|
|
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
|
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
|
assert.equal(anon.status, 303);
|
|
assert.equal(anon.headers.get("location"), "/login?return_to=%2Fguarded%2Fme");
|
|
const me = await fetch(url + "/guarded/me", auth([]));
|
|
assert.equal(me.status, 200);
|
|
assert.match(await me.text(), /hi a@b\.c/);
|
|
|
|
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
|
|
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
|
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
|
|
|
// check (live Keto): the keto verdict gates the handler.
|
|
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
|
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
|
|
|
// declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
|
|
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
|
assert.equal(gAnon.status, 303);
|
|
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
|
const gDenied = await fetch(url + "/guarded/gated", auth([]));
|
|
assert.equal(gDenied.status, 403);
|
|
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
|
|
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
|
|
});
|
|
|
|
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
|
const seen: string[] = [];
|
|
const hooked: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
hooks: {
|
|
onRequest: (c) => (c.url.pathname === "/hooked/blocked" ? { html: "blocked by hook", status: 403 } : undefined),
|
|
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
|
|
},
|
|
id: "hooked",
|
|
routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }],
|
|
};
|
|
const url = await startApp(t, [hooked]);
|
|
|
|
// onRequest short-circuits before routing — handler never runs; a fresh CSRF cookie still rides the
|
|
// response so a form the hook renders has its matching double-submit cookie.
|
|
const blocked = await fetch(url + "/hooked/blocked");
|
|
assert.equal(blocked.status, 403);
|
|
assert.match(await blocked.text(), /blocked by hook/);
|
|
assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
|
|
|
// A normal route runs the handler; onResponse observed its result.
|
|
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
|
|
assert.ok(seen.includes("/hooked/ok:handler ran"));
|
|
});
|
|
|
|
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
|
|
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
|
|
const loginFlow = (id: string): Flow => ({
|
|
id,
|
|
ui: {
|
|
action: `http://127.0.0.1:4433/self-service/login?flow=${id}`,
|
|
messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }],
|
|
method: "post",
|
|
nodes: [
|
|
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
|
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
|
|
node({ name: "password", required: true, type: "password" }, "Password"),
|
|
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
|
|
{ attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" },
|
|
],
|
|
},
|
|
});
|
|
|
|
function mockKratos(getFlow: KratosPublic["getFlow"]): KratosPublic {
|
|
return {
|
|
createLogoutFlow: async () => null,
|
|
getFlow,
|
|
initBrowserFlow: async (_t: FlowType) => ({ flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: ["csrf_token=abc; Path=/; HttpOnly"] }),
|
|
submitFlow: async () => { throw new Error("unused"); },
|
|
whoami: async () => null,
|
|
};
|
|
}
|
|
|
|
// GET dispatch for the themed auth pages: the same handler branches on session presence.
|
|
test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a signed-in user is sent home, except /settings", async (t) => {
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
// Anonymous, no ?flow= → init one + relay Kratos' CSRF cookie.
|
|
const init = await fetch(url + "/login", { redirect: "manual" });
|
|
assert.equal(init.status, 303);
|
|
assert.equal(init.headers.get("location"), "/login?flow=new1");
|
|
assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/);
|
|
// A stale flow id (Kratos 410) bounces back to a fresh init.
|
|
const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" });
|
|
assert.equal(stale.status, 303);
|
|
assert.equal(stale.headers.get("location"), "/login");
|
|
|
|
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
|
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
|
|
for (const path of ["/login", "/registration"]) {
|
|
const res = await fetch(url + path, signedIn);
|
|
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
|
assert.equal(res.headers.get("location"), "/dashboard");
|
|
}
|
|
assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1");
|
|
});
|
|
|
|
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
|
|
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
|
|
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
|
|
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
|
|
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
|
|
const sessionRace = new KratosError("Kratos init login flow failed (400)", 400, JSON.stringify({ error: { id: "session_already_available" } }));
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw sessionRace; } } });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
const recover = await fetch(url + "/login", { redirect: "manual" });
|
|
assert.equal(recover.status, 303);
|
|
assert.equal(recover.headers.get("location"), "/auth/complete");
|
|
// return_to is carried through so the deep link still lands after the JWT is minted.
|
|
const deep = await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
|
assert.equal(deep.headers.get("location"), "/auth/complete?return_to=%2Fadmin%2Fusers");
|
|
|
|
// A genuinely unexpected Kratos 400 is still surfaced as a 500 (not masked as a session race).
|
|
const app2 = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw new KratosError("bad", 400, JSON.stringify({ error: { id: "security_csrf_violation" } })); } } });
|
|
await new Promise<void>((r) => app2.listen(0, r));
|
|
t.after(() => app2.close());
|
|
const url2 = `http://localhost:${(app2.address() as AddressInfo).port}`;
|
|
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
|
|
});
|
|
|
|
// return_to: a deep-link login lands back on the requested page. The gate redirects to
|
|
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
|
|
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
|
|
test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => {
|
|
let lastReturnTo: string | undefined;
|
|
const kratos: KratosPublic = {
|
|
...mockKratos(async (_t, id) => loginFlow(id)),
|
|
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
|
};
|
|
const app = createApp({ kratos });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
// A host-relative deep link → wrapped: Kratos returns to <origin>/auth/complete?return_to=<path>,
|
|
// so the JWT is minted before the user lands on the page (query preserved, re-encoded).
|
|
await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" });
|
|
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/);
|
|
|
|
// An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos
|
|
// allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it).
|
|
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
|
await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
|
assert.equal(lastReturnTo, abs);
|
|
await fetch(url + "/login?return_to=" + encodeURIComponent("//evil.com"), { redirect: "manual" });
|
|
assert.equal(lastReturnTo, "//evil.com");
|
|
});
|
|
|
|
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
|
|
// generic "error on our end" 500 the catch-all renders.
|
|
test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => {
|
|
const boom = () => { throw new KratosError("kratos down", 503, ""); };
|
|
const down: KratosPublic = { ...mockKratos(async () => boom()), initBrowserFlow: async () => boom() };
|
|
const app = createApp({ kratos: down });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
const init = await fetch(url + "/login", { redirect: "manual" }); // init (no ?flow=) with Kratos down
|
|
assert.equal(init.status, 503);
|
|
assert.match(await init.text(), /unavailable/i);
|
|
assert.equal((await fetch(url + "/login?flow=f1")).status, 503); // fetching a flow, Kratos down
|
|
|
|
// A network-level throw (refused/timeout — not a KratosError) is treated the same way.
|
|
const refused: KratosPublic = { ...mockKratos(async () => { throw new Error("ECONNREFUSED"); }), initBrowserFlow: async () => { throw new Error("ECONNREFUSED"); } };
|
|
const app2 = createApp({ kratos: refused });
|
|
await new Promise<void>((r) => app2.listen(0, r));
|
|
t.after(() => app2.close());
|
|
assert.equal((await fetch(`http://localhost:${(app2.address() as AddressInfo).port}/login`, { redirect: "manual" })).status, 503);
|
|
});
|
|
|
|
test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => {
|
|
const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text();
|
|
|
|
// The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input.
|
|
assert.match(html, /<form class="auth-card" method="post" action="http:\/\/127\.0\.0\.1:4433\/self-service\/login\?flow=f1"/);
|
|
assert.match(html, /<input type="hidden" name="csrf_token" value="tok">/);
|
|
assert.match(html, /name="identifier"/);
|
|
assert.match(html, /name="password"[^>]*type="password"/);
|
|
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
|
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
|
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google);
|
|
// `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
|
|
assert.match(html, /<div class="sso"/);
|
|
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
|
|
// The flow-level error renders as an alert.
|
|
assert.match(html, /class="alert alert-neg"/);
|
|
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
|
|
});
|
|
|
|
// Login completion: /auth/complete is where Kratos lands the browser after login.
|
|
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
|
|
createIdentity: async () => { throw new Error("unused"); },
|
|
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
|
|
deleteIdentity: async () => {},
|
|
getIdentity: async () => null,
|
|
listIdentities: async () => ({ identities: [], nextPageToken: null }),
|
|
updateIdentity: async () => { throw new Error("unused"); },
|
|
updateMetadataPublic: async () => ({ id: "x" }),
|
|
...over,
|
|
});
|
|
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,
|
|
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); },
|
|
...over,
|
|
});
|
|
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
|
|
|
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
|
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
|
const ADMIN_CSRF = "admin-secret";
|
|
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
|
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
|
|
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
|
|
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...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 = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
|
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
|
const post = (path: string, body: string) =>
|
|
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
|
|
return { get, post, token, url };
|
|
}
|
|
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
|
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
|
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
|
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
|
async function assertAdminGate(url: string, get: (path: string, permissions?: 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?return_to=${encodeURIComponent(path)}`); // remembers the page
|
|
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) => {
|
|
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
|
|
let projected: unknown;
|
|
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 keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
|
|
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const q = returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : "";
|
|
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
|
};
|
|
|
|
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
|
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
|
assert.equal(ok.status, 303);
|
|
assert.equal(ok.headers.get("location"), "/dashboard");
|
|
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
|
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
|
|
|
|
// return_to: a safe host-relative target lands the user back where they were headed; an
|
|
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
|
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
|
|
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
|
|
|
|
// No Kratos session: nothing minted, bounce to /login with no cookie.
|
|
const none = await complete(createApp({ keto: fakeKeto(), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
|
|
assert.equal(none.status, 303);
|
|
assert.equal(none.headers.get("location"), "/login");
|
|
assert.equal(none.headers.get("set-cookie"), null);
|
|
});
|
|
|
|
test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clears our JWT; bad token → 403", async (t) => {
|
|
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
|
|
// Real Kratos keys off its own session cookie (plainpages_session), not our always-present CSRF cookie.
|
|
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie?.includes("plainpages_session") ? { logoutToken: "lt", logoutUrl } : null) };
|
|
const csrfSecret = "logout-secret";
|
|
const app = createApp({ csrfSecret, kratos });
|
|
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(csrfSecret);
|
|
const post = (cookie: string, body: string) =>
|
|
fetch(url + "/logout", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie }, method: "POST", redirect: "manual" });
|
|
|
|
// Valid double-submit (cookie token === form token) + active session → Kratos logout URL, JWT cleared.
|
|
const out = await post(`${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=x; plainpages_session=s`, `_csrf=${token}`);
|
|
assert.equal(out.status, 303);
|
|
assert.equal(out.headers.get("location"), logoutUrl);
|
|
assert.match(out.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
|
|
|
// No active Kratos session → clear our cookie and land on /login ourselves.
|
|
const none = await post(`${CSRF_COOKIE}=${token}`, `_csrf=${token}`);
|
|
assert.equal(none.status, 303);
|
|
assert.equal(none.headers.get("location"), "/login");
|
|
assert.match(none.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
|
|
|
// Missing field and a forged token are both refused (no Kratos call, no cookie cleared).
|
|
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "")).status, 403);
|
|
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "_csrf=forged.sig")).status, 403);
|
|
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
|
|
});
|
|
|
|
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
|
|
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
|
|
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
|
|
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
|
|
acceptLogoutRequest: async () => ({ redirect: "http://acme.example/post-logout" }),
|
|
createClient: async (c) => ({ ...c, client_id: "c1", client_secret: "s3cr3t" }),
|
|
deleteClient: async () => {},
|
|
getClient: async () => null,
|
|
getConsentRequest: async () => ({ challenge: "cons1", client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: OAUTH_SUBJECT }),
|
|
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
|
|
listClients: async () => ({ clients: [], nextPageToken: null }),
|
|
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
|
rejectLoginRequest: async () => { throw new Error("unused"); },
|
|
...over,
|
|
});
|
|
const OAUTH_SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
|
const oauthSession = (): Session => ({ active: true, identity: { id: OAUTH_SUBJECT, traits: { email: "ada@x.io" } } });
|
|
|
|
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
|
|
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
|
|
let acceptedSubject: string | undefined;
|
|
const hydra = stubHydra({ acceptLoginRequest: async (_c, b) => { acceptedSubject = b.subject; return { redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }; } });
|
|
|
|
const signedIn = createApp({ hydra, kratos: withWhoami(async () => ({ active: true, identity }) as Session) });
|
|
await new Promise<void>((r) => signedIn.listen(0, r));
|
|
t.after(() => signedIn.close());
|
|
const base = `http://localhost:${(signedIn.address() as AddressInfo).port}`;
|
|
|
|
// Signed in: accept the challenge with the Kratos identity → 303 to Hydra's resume URL.
|
|
const accept = await fetch(base + "/oauth2/login?login_challenge=chal1", { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
|
|
assert.equal(accept.status, 303);
|
|
assert.match(accept.headers.get("location") ?? "", /\/oauth2\/auth\?login_verifier=v/);
|
|
assert.equal(acceptedSubject, identity.id);
|
|
|
|
// Missing login_challenge → 400 (someone hit the endpoint directly).
|
|
assert.equal((await fetch(base + "/oauth2/login", { redirect: "manual" })).status, 400);
|
|
|
|
// Not signed in: bounce to the themed login, return_to carrying an absolute URL back to here.
|
|
const anon = createApp({ hydra: stubHydra(), kratos: withWhoami(async () => null) });
|
|
await new Promise<void>((r) => anon.listen(0, r));
|
|
t.after(() => anon.close());
|
|
const bounce = await fetch(`http://localhost:${(anon.address() as AddressInfo).port}/oauth2/login?login_challenge=chal1`, { redirect: "manual" });
|
|
assert.equal(bounce.status, 303);
|
|
const loc = bounce.headers.get("location") ?? "";
|
|
assert.match(loc, /^\/login\?return_to=/);
|
|
assert.match(decodeURIComponent(loc.split("return_to=")[1]!), /^http:\/\/[^/]+\/oauth2\/login\?login_challenge=chal1$/);
|
|
});
|
|
|
|
test("OAuth2 consent challenge (/oauth2/consent): skip auto-accepts; a third-party shows the screen; allow/deny POST; CSRF-guarded; missing challenge", async (t) => {
|
|
const csrfSecret = "consent-secret";
|
|
let granted: { grant_scope?: string[]; session?: unknown } | undefined;
|
|
const hydra = stubHydra({
|
|
acceptConsentRequest: async (_c, b) => { granted = b; return { redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }; },
|
|
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
|
});
|
|
const app = createApp({ csrfSecret, hydra, kratos: withWhoami(async () => oauthSession()) });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
const token = issueCsrfToken(csrfSecret);
|
|
const post = (body: string) =>
|
|
fetch(base + "/oauth2/consent", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${token}` }, method: "POST", redirect: "manual" });
|
|
|
|
// Third-party (default stub: not first-party, not skipped) → 200 consent screen listing the
|
|
// client + scopes, with a CSRF cookie its form echoes back; posts to our own /oauth2/consent.
|
|
const page = await fetch(base + "/oauth2/consent?consent_challenge=cons1", { redirect: "manual" });
|
|
assert.equal(page.status, 200);
|
|
const html = await page.text();
|
|
assert.match(html, /Authorize Acme Reports/);
|
|
assert.match(html, /openid/);
|
|
assert.match(html, /profile/);
|
|
assert.match(html, /action="\/oauth2\/consent"/);
|
|
// Informed consent: the screen names the account being authorized + offers a sign-out escape.
|
|
assert.match(html, /Signed in as.*ada@x\.io/s);
|
|
assert.match(html, /action="\/logout".*Sign out/s);
|
|
assert.match(page.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
|
|
|
// Allow → 303 to Hydra, granting the scopes re-read from the challenge (never form-supplied) +
|
|
// id_token claims from the Kratos identity.
|
|
const allow = await post(`_csrf=${token}&consent_challenge=cons1&decision=allow`);
|
|
assert.equal(allow.status, 303);
|
|
assert.match(allow.headers.get("location") ?? "", /\/oauth2\/auth\?consent_verifier=v/);
|
|
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
|
|
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
|
|
|
|
// Deny → 303 back to the client with access_denied.
|
|
const deny = await post(`_csrf=${token}&consent_challenge=cons1&decision=deny`);
|
|
assert.equal(deny.status, 303);
|
|
assert.equal(deny.headers.get("location"), "http://acme.example/cb?error=access_denied");
|
|
|
|
// Forged/missing CSRF → 403 (no Hydra call); missing challenge → 400.
|
|
assert.equal((await post("decision=allow")).status, 403);
|
|
assert.equal((await fetch(base + "/oauth2/consent", { redirect: "manual" })).status, 400);
|
|
|
|
// A Hydra-skipped client auto-accepts on GET (no screen) → 303 to Hydra.
|
|
const skip = createApp({ hydra: stubHydra({ getConsentRequest: async () => ({ challenge: "cons1", requested_scope: ["openid"], skip: true, subject: OAUTH_SUBJECT }) }), kratos: withWhoami(async () => oauthSession()) });
|
|
await new Promise<void>((r) => skip.listen(0, r));
|
|
t.after(() => skip.close());
|
|
const auto = await fetch(`http://localhost:${(skip.address() as AddressInfo).port}/oauth2/consent?consent_challenge=cons1`, { redirect: "manual" });
|
|
assert.equal(auto.status, 303);
|
|
assert.match(auto.headers.get("location") ?? "", /consent_verifier=v/);
|
|
});
|
|
|
|
test("OAuth2 RP-initiated logout (/oauth2/logout): accepts the logout challenge → 303 to Hydra; missing → 400", async (t) => {
|
|
let acceptedChallenge: string | undefined;
|
|
const hydra = stubHydra({ acceptLogoutRequest: async (c) => { acceptedChallenge = c; return { redirect: "http://acme.example/post-logout" }; } });
|
|
const app = createApp({ hydra, kratos: withWhoami(async () => null) });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
const ok = await fetch(base + "/oauth2/logout?logout_challenge=lc1", { redirect: "manual" });
|
|
assert.equal(ok.status, 303);
|
|
assert.equal(ok.headers.get("location"), "http://acme.example/post-logout");
|
|
assert.equal(acceptedChallenge, "lc1");
|
|
|
|
assert.equal((await fetch(base + "/oauth2/logout", { redirect: "manual" })).status, 400);
|
|
});
|
|
|
|
// All three OAuth2 challenge endpoints share one degrade contract (the documented "byte-identical"
|
|
// behaviour): a stale/consumed challenge (Hydra 4xx — back button, slow login) → recoverable 400,
|
|
// a genuine Hydra outage (5xx) → 500.
|
|
test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, outage 5xx → 500", async (t) => {
|
|
const endpoints: { make: (status: number) => Partial<HydraAdmin>; path: string }[] = [
|
|
{ make: (s) => ({ getLoginRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/login?login_challenge=x" },
|
|
{ make: (s) => ({ getConsentRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/consent?consent_challenge=x" },
|
|
{ make: (s) => ({ acceptLogoutRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/logout?logout_challenge=x" },
|
|
];
|
|
for (const { make, path } of endpoints) {
|
|
for (const [status, expected] of [[410, 400], [503, 500]] as const) {
|
|
const app = createApp({ hydra: stubHydra(make(status)), kratos: withWhoami(async () => null) });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}${path}`, { redirect: "manual" });
|
|
assert.equal(res.status, expected, `${path} ${status} → ${expected}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Built-in Users admin screen: gate + every CRUD action over HTTP against a mock Kratos admin.
|
|
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
|
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
|
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
|
|
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" }), mk("you@example.com", { id: "admin1" })];
|
|
let lastCreate: { traits?: unknown } | undefined;
|
|
const kratosAdmin = stubAdmin({
|
|
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
|
|
createRecoveryCode: async (id) => ({ code: "123456", link: `http://kratos/self-service/recovery?code=123456&id=${id}` }),
|
|
deleteIdentity: async (id) => { const i = store.findIndex((x) => x.id === id); if (i >= 0) store.splice(i, 1); },
|
|
getIdentity: async (id) => store.find((x) => x.id === id) ?? 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; },
|
|
});
|
|
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
|
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
|
|
|
await assertAdminGate(url, get, "/admin/users");
|
|
|
|
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
|
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
|
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
|
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
|
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
|
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
|
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
|
|
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
|
|
|
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
|
// resources don't leak — a users holder is not a groups holder.
|
|
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
|
|
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
|
|
const readOnlyPost = await fetch(url + "/admin/users", {
|
|
body: `_csrf=${token}&email=nope@example.com`,
|
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
|
method: "POST",
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(readOnlyPost.status, 403);
|
|
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
|
|
|
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
|
const listHtml = await (await get("/admin/users")).text();
|
|
assert.match(listHtml, /ada@example\.com/);
|
|
assert.match(listHtml, /href="\/admin\/users\/new"/);
|
|
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
|
|
|
|
// Create: the form renders; a valid post creates the identity and redirects to the list.
|
|
assert.match(await (await get("/admin/users/new")).text(), /Create user/);
|
|
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
|
assert.equal(created.status, 303);
|
|
assert.equal(created.headers.get("location"), "/admin/users");
|
|
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
|
|
|
// A create with no CSRF token is refused and creates nothing.
|
|
const before = store.length;
|
|
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
|
|
assert.equal(store.length, before);
|
|
|
|
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
|
// collection: a delete with no token is refused (403) and removes nothing.
|
|
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
|
assert.ok(store.some((x) => x.id === store[1]!.id));
|
|
|
|
// Edit: email is read-only + prefilled; a post rewrites the name.
|
|
const target = store[0]!;
|
|
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
|
|
assert.match(editHtml, /name="email"[^>]*readonly/);
|
|
assert.match(editHtml, /value="ada@example\.com"/);
|
|
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
|
assert.equal(updated.status, 303);
|
|
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
|
|
|
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
|
|
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
|
|
assert.equal(target.state, "inactive");
|
|
assert.equal(denylist.isRevoked(target.id, 0), true);
|
|
|
|
// Recovery: renders the edit page (200) showing the generated code (code-based; no admin-host link).
|
|
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
|
|
assert.equal(rec.status, 200);
|
|
const recHtml = await rec.text();
|
|
assert.match(recHtml, /Recovery code generated/);
|
|
assert.match(recHtml, /<code>123456<\/code>/);
|
|
assert.doesNotMatch(recHtml, /self-service\/recovery\?code=/); // the unreachable admin-API link is gone
|
|
|
|
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
|
|
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
|
|
assert.match(confirm, /Cancel/);
|
|
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
|
|
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
|
|
assert.equal(del.status, 303);
|
|
assert.ok(!store.some((x) => x.id === target.id));
|
|
|
|
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
|
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
|
assert.ok(store.some((x) => x.id === "admin1"));
|
|
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
|
|
|
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
|
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
|
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
|
});
|
|
|
|
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
|
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
|
|
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
|
|
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
|
const grace = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
|
const identities: Identity[] = [
|
|
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@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 keto = fakeKeto(tuples);
|
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
|
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
|
|
|
await assertAdminGate(url, get, "/admin/groups");
|
|
|
|
// List: the existing group shows + the "add" link.
|
|
const listHtml = await (await get("/admin/groups")).text();
|
|
assert.match(listHtml, /href="\/admin\/groups\/eng"/);
|
|
assert.match(listHtml, /href="\/admin\/groups\/new"/);
|
|
|
|
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
|
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
|
|
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
|
assert.equal(created.status, 303);
|
|
assert.equal(created.headers.get("location"), "/admin/groups/design");
|
|
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
|
|
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
|
const before = tuples.length;
|
|
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
|
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
|
|
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
|
assert.equal(tuples.length, before);
|
|
|
|
// Detail: lists the current member by email.
|
|
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
|
|
|
|
// Add a member, then remove it.
|
|
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
|
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
|
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
|
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
|
|
|
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
|
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=users%3Aread`);
|
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
|
|
|
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
|
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
|
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
|
assert.equal(del.status, 303);
|
|
assert.equal(del.headers.get("location"), "/admin/groups");
|
|
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
|
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
|
|
// orphaned grant would resurrect the moment someone re-created a group with the same name.
|
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
|
|
|
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
|
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
|
|
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
|
});
|
|
|
|
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
|
// so the checkboxes are a fixed list and the POST is the desired state.
|
|
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
|
const ada = randomUUID();
|
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
|
const keto = fakeKeto(tuples);
|
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
|
const denylist = createDenylist();
|
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
|
|
|
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
|
const edit = await (await get(`/admin/users/${ada}`)).text();
|
|
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
|
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
|
}
|
|
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
|
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
|
|
|
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
|
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
|
assert.equal(saved.status, 303);
|
|
assert.deepEqual(
|
|
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
|
["groups:read", "users:write"],
|
|
);
|
|
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
|
|
|
// A crafted POST can't grant something no plugin declares.
|
|
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
|
|
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
|
|
|
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
|
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
|
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
|
});
|
|
|
|
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
|
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
|
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
|
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
|
|
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
|
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
|
const keto = fakeKeto(tuples);
|
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
|
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
|
|
|
|
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
|
|
assert.equal(refused.status, 400);
|
|
assert.match(await refused.text(), /lock yourself out/);
|
|
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
|
|
|
// Granting yourself more is not a lockout, so it goes through.
|
|
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
|
assert.equal(granted.status, 303);
|
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
|
});
|
|
|
|
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
|
|
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
|
|
test("admin screens render no write affordance for a read-only holder", async (t) => {
|
|
const ada = randomUUID();
|
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
|
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
|
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
|
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
|
|
// "no Register button" assertion below would pass without proving anything.
|
|
const reporting = { client_id: "existing", client_name: "Reporting" };
|
|
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
|
|
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
|
const readOnly = ["users:read", "groups:read"];
|
|
|
|
const list = await (await get("/admin/users", readOnly)).text();
|
|
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
|
|
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
|
|
|
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
|
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
|
|
assert.doesNotMatch(detail, /Save changes/);
|
|
assert.doesNotMatch(detail, /Generate recovery code/);
|
|
assert.doesNotMatch(detail, /Delete user/);
|
|
assert.doesNotMatch(detail, /Save permissions/);
|
|
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
|
|
|
const group = await (await get("/admin/groups/eng", readOnly)).text();
|
|
assert.doesNotMatch(group, /Add a member/);
|
|
assert.doesNotMatch(group, /Delete group/);
|
|
assert.doesNotMatch(group, /Save permissions/);
|
|
|
|
// The OAuth2-clients screen is held to the same rule.
|
|
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
|
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
|
const clients = await clientsRes.text();
|
|
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
|
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
|
|
// The detail page is where Delete lives, so check it too and not just the list.
|
|
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
|
|
assert.match(clientDetail, /Reporting/);
|
|
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
|
|
|
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
|
// rendering a form whose submit would 403.
|
|
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
|
|
assert.equal((await get(path, readOnly)).status, 403, path);
|
|
}
|
|
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
|
|
|
// A writer sees the affordances the reader didn't.
|
|
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
|
assert.match(writable, /Save changes/);
|
|
assert.match(writable, /Save permissions/);
|
|
});
|
|
|
|
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
|
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
|
|
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
|
|
const store: OAuth2Client[] = [
|
|
{ client_id: "existing", client_name: "Reporting", redirect_uris: ["https://reporting.example/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" },
|
|
];
|
|
let seq = 0;
|
|
const hydra = stubHydra({
|
|
createClient: async (c) => { const created = { ...c, client_id: `gen-${++seq}`, client_secret: `secret-${seq}` }; store.push(created); return created; },
|
|
deleteClient: async (id) => { const i = store.findIndex((c) => c.client_id === id); if (i >= 0) store.splice(i, 1); },
|
|
getClient: async (id) => store.find((c) => c.client_id === id) ?? null,
|
|
listClients: async () => ({ clients: store, nextPageToken: null }),
|
|
});
|
|
const { get, post, token, url } = await adminHarness(t, { hydra });
|
|
|
|
await assertAdminGate(url, get, "/admin/clients");
|
|
|
|
// List: the existing client shows + the "register" link.
|
|
const listHtml = await (await get("/admin/clients")).text();
|
|
assert.match(listHtml, /href="\/admin\/clients\/existing"/);
|
|
assert.match(listHtml, /href="\/admin\/clients\/new"/);
|
|
assert.match(listHtml, /Reporting/);
|
|
|
|
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
|
// client and shows the one-time secret + id.
|
|
const formHtml = await (await get("/admin/clients/new")).text();
|
|
assert.match(formHtml, /Register client/);
|
|
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
|
|
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
|
|
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
|
const createdHtml = await created.text();
|
|
assert.match(createdHtml, /Client registered/);
|
|
assert.match(createdHtml, /secret-1/); // the one-time client_secret
|
|
assert.match(createdHtml, /gen-1/); // the generated client_id
|
|
assert.ok(store.some((c) => c.client_name === "Grafana" && c.token_endpoint_auth_method === "client_secret_basic"));
|
|
|
|
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
|
const before = store.length;
|
|
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
|
assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
|
assert.equal(store.length, before);
|
|
|
|
// Detail: read-only info, never the secret again, a delete control.
|
|
const detail = await (await get("/admin/clients/existing")).text();
|
|
assert.match(detail, /reporting\.example\/cb/);
|
|
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
|
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
|
|
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
|
|
|
|
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
|
assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/);
|
|
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
|
|
assert.equal(del.status, 303);
|
|
assert.equal(del.headers.get("location"), "/admin/clients");
|
|
assert.ok(!store.some((c) => c.client_id === "existing"));
|
|
|
|
// Unknown id → 404; malformed %-encoding doesn't 500.
|
|
assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
|
|
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
|
|
});
|
|
|
|
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
|
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
|
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
|
assert.equal(resolveStaticPath("/srv/public", "css/styles.css"), "/srv/public/css/styles.css");
|
|
});
|
|
|
|
test("contentTypeFor maps known and unknown extensions", () => {
|
|
assert.match(contentTypeFor("a.css"), /text\/css/);
|
|
assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
|
|
});
|
|
|
|
test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => {
|
|
const ids = new Set(["scheduling"]);
|
|
assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" });
|
|
assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" });
|
|
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
|
|
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
|
|
});
|
|
|
|
// ---- language (i18n) ----
|
|
|
|
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
|
|
async function localeApp(t: TestContext): Promise<string> {
|
|
const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
}
|
|
|
|
test("?locale serves that language and carries the choice onto the links the page renders", async (t) => {
|
|
const url = await localeApp(t);
|
|
const html = await (await fetch(`${url}/?locale=sv-SE`)).text();
|
|
|
|
assert.match(html, /<html lang="sv-SE" dir="ltr">/); // the document says what language it is in
|
|
assert.match(html, /Logga in/); // the landing page's own words
|
|
assert.doesNotMatch(html, /Operational web apps/);
|
|
// The chosen locale rides along, so clicking through the app stays in Swedish without a cookie.
|
|
assert.match(html, /href="\/login\?locale=sv-SE"/);
|
|
// …and the picker offers the other installed locale, pointing at this same page.
|
|
assert.match(html, /hreflang="en-US"/);
|
|
assert.match(html, /href="\/\?locale=en-US"/);
|
|
});
|
|
|
|
test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => {
|
|
const url = await localeApp(t);
|
|
const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text();
|
|
assert.match(swedish, /<html lang="sv-SE"/);
|
|
// The visitor never asked for a locale in the URL, so the links stay clean.
|
|
assert.match(swedish, /href="\/login"/);
|
|
|
|
const english = await (await fetch(`${url}/`, { headers: { "accept-language": "de-DE" } })).text();
|
|
assert.match(english, /<html lang="en-US"/); // nothing matches ⇒ the baseline
|
|
});
|
|
|
|
test("an uninstalled or malformed ?locale falls back instead of failing", async (t) => {
|
|
const url = await localeApp(t);
|
|
for (const bad of ["sv-FI", "klingon", "../../etc"]) {
|
|
const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`);
|
|
assert.equal(res.status, 200);
|
|
assert.match(await res.text(), /<html lang="en-US"/, `expected en-US for ${bad}`);
|
|
}
|
|
});
|
|
|
|
test("a redirect the host emits keeps the visitor's language", async (t) => {
|
|
const url = await localeApp(t);
|
|
const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first
|
|
assert.equal(res.status, 303);
|
|
const location = res.headers.get("location") ?? "";
|
|
assert.match(location, /^\/login\?/);
|
|
assert.match(location, /locale=sv-SE/);
|
|
});
|
|
|
|
test("the error pages speak the visitor's language too", async (t) => {
|
|
const url = await localeApp(t);
|
|
const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text();
|
|
assert.match(html, /<html lang="sv-SE"/);
|
|
assert.match(html, /Sidan hittades inte/);
|
|
});
|
|
|
|
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
|
|
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
|
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
|
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
|
|
|
|
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
|
|
const demo: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
|
|
home: (ctx) => ({ html: ctx.t("demo.hello") }),
|
|
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
|
|
id: "demo",
|
|
};
|
|
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
|
|
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
|
|
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 cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
|
|
|
|
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
|
|
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
|
|
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
|
|
});
|
|
|
|
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
|
|
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
|
|
// broken menu takes the error pages down with it.
|
|
let built = 0;
|
|
const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} };
|
|
const app = createApp({ jwks: staticJwks([ecJwk]), menu });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
|
|
const res = await fetch(`${url}/no-such-page`);
|
|
assert.equal(res.status, 404);
|
|
assert.match(await res.text(), /Page not found/);
|
|
assert.equal(built, 0);
|
|
});
|
|
|
|
test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-"));
|
|
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
|
// The view renders the picker exactly as the shell does.
|
|
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`);
|
|
const demo: Plugin = {
|
|
apiVersion: "1.0.0",
|
|
id: "demo",
|
|
routes: [
|
|
{ handler: () => ({ view: "page" }), method: "GET", path: "/thing" },
|
|
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing" },
|
|
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling
|
|
],
|
|
};
|
|
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir });
|
|
await new Promise<void>((r) => app.listen(0, r));
|
|
t.after(() => app.close());
|
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
|
const post = (path: string, headers: Record<string, string> = {}) => fetch(url + path, { headers, method: "POST" });
|
|
|
|
// A POST whose path also answers GET → the picker points at that page.
|
|
assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/);
|
|
// A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405.
|
|
const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` });
|
|
assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/);
|
|
// …and with no referer to fall back on, the front page.
|
|
assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/);
|
|
});
|