Move test-only tooling to src/test-tooling, split positive, warn on undeclared gated permissions
CI / full-gate (push) Successful in 4m19s
CI / full-gate (push) Successful in 4m19s
This commit is contained in:
+2
-2
@@ -1,10 +1,10 @@
|
||||
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
|
||||
// (src/http/builtin-routes.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
|
||||
// (src/http/route-table.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
|
||||
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
|
||||
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
|
||||
// client means the path simply doesn't exist (404). `view` results render the core views.
|
||||
import { readFormBody } from "../http/body.ts";
|
||||
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
||||
import type { BuiltinRoute, RequestCsrf } from "../http/route-table.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { CSRF_FIELD } from "./csrf.ts";
|
||||
import { chosenLocale } from "../i18n/locale.ts";
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "./app-harness.ts";
|
||||
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { KratosError, type Flow, type FlowSubmission, type FlowType, type Kratos
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "../test-tooling/app-harness.ts";
|
||||
|
||||
// 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" });
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, mintJwt } from "./app-harness.ts";
|
||||
import { ecJwk, mintJwt } from "../test-tooling/app-harness.ts";
|
||||
|
||||
// ---- language (i18n) ----
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Identity } from "../auth/kratos-admin.ts";
|
||||
import { KratosError, type Session } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "./app-harness.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts";
|
||||
|
||||
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||
const demoPlugin: Plugin = {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CSRF_COOKIE } from "../auth/csrf.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||
import { ecJwk, session, startApp } from "./app-harness.ts";
|
||||
import { ecJwk, session, startApp } from "../test-tooling/app-harness.ts";
|
||||
|
||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./route-table.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// router), method-distinct entries on the same path.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-routes.ts";
|
||||
import { type BuiltinRoute, matchBuiltinRoute } from "./route-table.ts";
|
||||
|
||||
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => null, method, path });
|
||||
|
||||
|
||||
@@ -129,6 +129,30 @@ test("findConflicts: duplicate nav id is an error, a shared permission name only
|
||||
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||
});
|
||||
|
||||
const noopHandler = () => {};
|
||||
|
||||
test("findConflicts: a route or nav node gating on a permission no plugin declares warns once per plugin and name", () => {
|
||||
const gated = findConflicts([
|
||||
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "ghost:read" }], id: "a:root", label: "A", session: true }], routes: [
|
||||
{ handler: noopHandler, method: "GET", path: "/x", permission: "ghost:read" },
|
||||
{ handler: noopHandler, method: "GET", path: "/y", permission: "other:write" },
|
||||
] }),
|
||||
p({ id: "b", permissions: [{ name: "other:write" }] }),
|
||||
]);
|
||||
const messages = gated.filter((c) => c.kind === "permission").map((c) => c.message);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0] ?? "", /"ghost:read"/);
|
||||
assert.deepEqual(gated[0]?.plugins, ["a"]);
|
||||
assert.equal(gated[0]?.level, "warn");
|
||||
});
|
||||
|
||||
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
||||
assert.deepEqual(findConflicts([
|
||||
p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }),
|
||||
p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }),
|
||||
]), []);
|
||||
});
|
||||
|
||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||
const handler = () => ({ html: "x" });
|
||||
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
||||
|
||||
@@ -218,6 +218,8 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
out.push(...undeclaredGates(plugins));
|
||||
|
||||
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
|
||||
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
|
||||
collect(plugins, (plugin, push) => {
|
||||
@@ -229,6 +231,29 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Declaring is optional, but the admin screens offer only declared names: a gate on one nobody
|
||||
// declares answers 403 until someone writes the Keto tuple by hand.
|
||||
function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
||||
const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name));
|
||||
const out: PluginConflict[] = [];
|
||||
for (const plugin of plugins) {
|
||||
const gated = new Set<string>();
|
||||
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
||||
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
||||
for (const name of [...gated].sort()) {
|
||||
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; add it to a plugin's \`permissions\``, plugins: [plugin.id] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void {
|
||||
for (const node of nodes ?? []) {
|
||||
if (node.permission != null) push(node.permission);
|
||||
collectNavPermissions(node.children, push);
|
||||
}
|
||||
}
|
||||
|
||||
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
||||
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
||||
const owners = new Map<string, string[]>();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { TestContext } from "node:test";
|
||||
import { createApp } from "./app.ts";
|
||||
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import { createApp } from "../http/app.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
|
||||
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
// as false; media range and `or` syntax fail. A partial implementation counts as support, since MDN
|
||||
// marks edge gaps that way, such as `outline` ignoring `border-radius` before Chrome 94. A
|
||||
// vendor-prefixed name is one engine's by design and is skipped, as are `font-family` names: an engine
|
||||
// skips a family it lacks.
|
||||
// skips a family it lacks. A custom property's value is checked too, for functions and units though
|
||||
// not keywords: a value a floor engine can't parse makes every `var()` reading it invalid, so each
|
||||
// declaration using it is dropped, wherever the property is declared.
|
||||
// Not checked: the body of an at-rule other than @media and @supports.
|
||||
import bcd from "@mdn/browser-compat-data" with { type: "json" };
|
||||
import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
|
||||
@@ -114,23 +116,36 @@ function mediaQuery(sheet: Sheet, guards: string[], condition: string, offset: n
|
||||
}
|
||||
}
|
||||
|
||||
// The parts of an @supports condition that must hold for its block to apply: neither a `not (…)`
|
||||
// group nor an operand of an `or`, at any depth.
|
||||
// The parts of an @supports condition that must hold for its block to apply, as text a guard can
|
||||
// search: the condition without its `not (…)` groups and without any group joined by `or`, at every depth.
|
||||
function positive(condition: string): string {
|
||||
let top = "";
|
||||
let kept = "";
|
||||
const { groups, tail } = parenGroups(condition);
|
||||
if (OR.test(groups.map((g) => g.head).join("") + tail)) return "";
|
||||
return groups.map(keepGroup).join("") + tail;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
// The text between the previous group and this one's "(".
|
||||
head: string;
|
||||
inner: string;
|
||||
}
|
||||
|
||||
// The top-level parenthesised groups of `condition`, and the text after the last.
|
||||
function parenGroups(condition: string): { groups: Group[]; tail: string } {
|
||||
const groups: Group[] = [];
|
||||
let from = 0;
|
||||
for (let open = condition.indexOf("("); open >= 0; open = condition.indexOf("(", from)) {
|
||||
const close = closing(condition, open);
|
||||
const head = condition.slice(from, open);
|
||||
const inner = condition.slice(open + 1, close);
|
||||
top += head;
|
||||
if (NEGATED.test(head)) kept += head.replace(NEGATED, "");
|
||||
else if (/[\w-]$/.test(head)) kept += `${head}(${inner})`; // a function such as selector()
|
||||
else kept += `${head}(${positive(inner)})`;
|
||||
groups.push({ head: condition.slice(from, open), inner: condition.slice(open + 1, close) });
|
||||
from = close + 1;
|
||||
}
|
||||
return OR.test(top + condition.slice(from)) ? "" : kept + condition.slice(from);
|
||||
return { groups, tail: condition.slice(from) };
|
||||
}
|
||||
|
||||
function keepGroup({ head, inner }: Group): string {
|
||||
if (NEGATED.test(head)) return head.replace(NEGATED, "");
|
||||
if (/[\w-]$/.test(head)) return `${head}(${inner})`; // a function such as selector(): its argument names the feature
|
||||
return `${head}(${positive(inner)})`;
|
||||
}
|
||||
|
||||
const OR = /(?<![\w-])or\s/i;
|
||||
|
||||
Reference in New Issue
Block a user