From 34dce6f6b503b362d05f4656b02f093d28ab3667 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 13:28:16 +0100 Subject: [PATCH 01/16] feat: createAppHarness and a stub admin built only on core's contract (duneorg/dune#97) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 +++ README.md | 37 +++++ deno.json | 9 +- src/app-harness.ts | 64 +++++++++ src/mod.ts | 2 + src/stub-admin.ts | 232 +++++++++++++++++++++++++++++++ tests/stub_admin_test.ts | 285 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 src/app-harness.ts create mode 100644 src/stub-admin.ts create mode 100644 tests/stub_admin_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9073430..8f1dcb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] + +### Added + +- **`createAppHarness()`** (`@dune/testing/app-harness`): the in-memory harness + plus the full `createDuneApp()` app, with `request(path)` going through plugin + routes, the response-transform pipeline and the content catch-all. +- **`createStubAdmin()`** (`@dune/testing/stub-admin`): a minimal replacement + admin built only on core's injected services, capabilities and mount API. It + proves Dune does not depend on `@dune/plugin-admin`: its tests cover login and + sessions, `auth.session` recognised by core's transform pipeline (and only its + own cookie), marker scrubbing, a page edit that fires `onPageUpdate`, a role + without edit rights, and a site with no admin at all. Needs `@dune/core` 0.36 + (duneorg/dune#97); version bump and the `^0.36` pin are release work. + ## [1.0.3] — 2026-10-28 ### Fixed diff --git a/README.md b/README.md index 0f8b606..5ab63d6 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,43 @@ Returns a `TestHarness` with: | `rebuild()` | Trigger a full content rebuild and search index update | | `dispose()` | Tear down the harness — safe to call multiple times | +### `createAppHarness(options?)` + +`createTestHarness()` reaches only the read-only content API. +`createAppHarness()` also builds the app `dune serve` builds, so a plugin's +`mount()` routes, the response-transform pipeline and the inline-edit WebSocket +route are all reachable in process. Same options as `createTestHarness()`; the +result adds `request(path, init?)`, which goes through the whole app. + +```ts +const h = await createAppHarness({ content, plugins: [myPlugin()] }); +const res = await h.request("/about/"); +``` + +Folder pages are served at their canonical path with a trailing slash +(`/about/`); `/about` answers 301. + +### `createStubAdmin(options?)` + +A minimal replacement admin, built only on the services, capabilities and mount +API core hands every plugin (it imports nothing from `@dune/plugin-admin`). It +keeps users and sessions through `services.users` and `services.sessions`, +provides the `auth.session` and `scheduler.executor` capabilities, seeds authz +tuples through `services.authz`, and offers its own `POST /stub-admin/login`, +`GET /stub-admin/pages` and `POST /stub-admin/pages/edit` (which, as the writer, +fires `onPageUpdate`). Use it to check that your plugin works with an admin that +is not the built-in one, or to build a conformance test for an admin of your +own. `declareCookie: false` makes it a provider that does not say which cookie +carries its session. + +```ts +const admin = createStubAdmin({ users: [{ username: "ed", password: "pw" }] }); +const h = await createAppHarness({ + content, + plugins: [admin.plugin, myPlugin()], +}); +``` + ### Options | Option | Default | Description | diff --git a/deno.json b/deno.json index 01bd8ec..2de3cca 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,9 @@ "license": "MIT", "exports": { ".": "./src/mod.ts", - "./harness": "./src/harness.ts" + "./harness": "./src/harness.ts", + "./app-harness": "./src/app-harness.ts", + "./stub-admin": "./src/stub-admin.ts" }, "imports": { "@std/assert": "jsr:@std/assert@^1", @@ -16,7 +18,10 @@ "@dune/core/engine": "jsr:@dune/core@^0.35/engine", "@dune/core/hooks": "jsr:@dune/core@^0.35/hooks", "@dune/core/search": "jsr:@dune/core@^0.35/search", - "@dune/core/config": "jsr:@dune/core@^0.35/config" + "@dune/core/config": "jsr:@dune/core@^0.35/config", + "@dune/core/fresh-app": "jsr:@dune/core@^0.35/fresh-app", + "@dune/core/workflow": "jsr:@dune/core@^0.35/workflow", + "@dune/core/plugin-api": "jsr:@dune/core@^0.35/plugin-api" }, "minimumDependencyAge": { "age": "P1D", diff --git a/src/app-harness.ts b/src/app-harness.ts new file mode 100644 index 0000000..1fc5be8 --- /dev/null +++ b/src/app-harness.ts @@ -0,0 +1,64 @@ +/** + * createAppHarness — the in-memory test harness plus the full Fresh app. + * + * `createTestHarness()` reaches only the read-only content API. This one also + * builds the app `dune serve` builds (`createDuneApp()`): content pages, plugin + * routes, the response-transform pipeline, the inline-edit WebSocket route, + * everything a plugin's `mount()` registers. Use it to test a plugin or an + * admin end to end, in process, without a server. + * + * ```ts + * import { createAppHarness } from "@dune/testing"; + * + * const h = await createAppHarness({ + * content: { "01.home/default.md": "---\ntitle: Home\n---\nHello" }, + * plugins: [myPlugin()], + * }); + * try { + * const res = await h.request("/"); + * assertEquals(res.status, 200); + * } finally { + * await h.dispose(); + * } + * ``` + * + * @module + */ + +import { createDuneApp } from "@dune/core/fresh-app"; +import { createTestHarness } from "./harness.ts"; +import type { TestHarness, TestHarnessOptions } from "./harness.ts"; + +/** A {@link TestHarness} that also serves the full Dune app. */ +export interface AppHarness extends TestHarness { + /** + * Send an in-process HTTP request through the whole app (page cache, + * plugin routes, response transforms, content catch-all). + */ + request(path: string, init?: RequestInit): Promise; +} + +/** + * Options for {@link createAppHarness}: those of {@link createTestHarness}, with + * the built-in admin disabled unless `disableAdmin: false`. + */ +export type AppHarnessOptions = TestHarnessOptions; + +/** Create an in-process Dune app over in-memory storage. */ +export async function createAppHarness( + options: AppHarnessOptions = {}, +): Promise { + const h = await createTestHarness(options); + const { app } = await createDuneApp(h.bootstrap, { + root: "/dune-test-harness", + port: 0, + dev: false, + }); + const handler = app.handler(); + return { + ...h, + request(path, init) { + return handler(new Request(new URL(path, "http://localhost:8000"), init)); + }, + }; +} diff --git a/src/mod.ts b/src/mod.ts index c7d6880..188c037 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -35,3 +35,5 @@ // re-exports the same named symbols but keeps the full resolved declaration // (and its JSDoc) intact. export * from "./harness.ts"; // createTestHarness, TestHarness, TestHarnessOptions, FixtureContent +export * from "./app-harness.ts"; // createAppHarness, AppHarness, AppHarnessOptions +export * from "./stub-admin.ts"; // createStubAdmin, StubAdmin, StubAdminOptions, StubAdminUser diff --git a/src/stub-admin.ts b/src/stub-admin.ts new file mode 100644 index 0000000..8d32346 --- /dev/null +++ b/src/stub-admin.ts @@ -0,0 +1,232 @@ +/** + * A minimal replacement admin, used to prove that Dune does not depend on + * `@dune/plugin-admin`. + * + * It implements just enough of an admin to exercise the plugin contract: it + * authenticates users and keeps sessions through core's injected services, + * provides the `auth.session` and `scheduler.executor` capabilities, seeds + * authorisation tuples through `services.authz`, and offers a login, a page + * listing and a page edit route of its own. It imports nothing from + * `@dune/plugin-admin`; everything it needs arrives through `PluginApi` and + * `MountApi`. + * + * ```ts + * import { createAppHarness, createStubAdmin } from "@dune/testing"; + * + * const admin = createStubAdmin({ users: [{ username: "ed", password: "pw", role: "admin" }] }); + * const h = await createAppHarness({ content: { ... }, plugins: [admin.plugin] }); + * ``` + * + * @module + */ + +import type { DunePlugin } from "@dune/core/hooks"; +import type { ScheduledAction } from "@dune/core/workflow"; +import type { PluginServices } from "@dune/core/plugin-api"; + +type UserStore = ReturnType; +type SessionManager = Awaited< + ReturnType +>; + +/** An account the stub seeds at startup. */ +export interface StubAdminUser { + username: string; + password: string; + /** Admin-tier role, default `"admin"`. */ + role?: string; +} + +/** Options for {@link createStubAdmin}. */ +export interface StubAdminOptions { + /** Accounts to create. Default: none. */ + users?: StubAdminUser[]; + /** Session cookie name. Default `"stub_sid"` (deliberately not the built-in admin's). */ + cookieName?: string; + /** Route prefix of the stub's own routes. Default `"/stub-admin"`. */ + prefix?: string; + /** + * Declare the session cookie to core (`RequestAuthenticator.sessionCookies`). + * Default true. Pass false to test the conservative behaviour for a provider + * that does not say which cookie carries its session. + */ + declareCookie?: boolean; +} + +/** The stub plugin plus what it recorded. */ +export interface StubAdmin { + plugin: DunePlugin; + /** Scheduled actions core handed to the `scheduler.executor` capability. */ + executedActions: ScheduledAction[]; + /** The cookie name sessions use. */ + cookieName: string; + /** Route prefix of the stub's own routes. */ + prefix: string; +} + +/** Create the stub admin. See the module documentation. */ +export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { + const cookieName = options.cookieName ?? "stub_sid"; + const prefix = options.prefix ?? "/stub-admin"; + const declareCookie = options.declareCookie ?? true; + const executedActions: ScheduledAction[] = []; + + // Created in setup() from the injected services, used by both capabilities + // and routes. Seeding finishes before the first request is served: mount() + // awaits `ready`. + let state: { + users: UserStore; + sessions: SessionManager | null; + passwords: PluginServices["passwords"]; + ready: Promise; + } | null = null; + + const readSessionId = (req: Request): string | null => { + const cookie = req.headers.get("cookie") ?? ""; + const m = cookie.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`)); + return m ? m[1] : null; + }; + + const authenticate = async (req: Request) => { + const sid = readSessionId(req); + if (!sid || !state) return { authenticated: false }; + await state.ready; + const session = await state.sessions!.get(sid); // set before `ready` resolves + if (!session) return { authenticated: false }; + const user = await state.users.getById(session.userId); + if (!user || user.enabled === false) return { authenticated: false }; + return { + authenticated: true, + user: { + id: user.id, + username: user.username, + email: user.email, + roles: user.roles, + }, + }; + }; + + const plugin: DunePlugin = { + name: "stub-admin", + version: "1.0.0", + description: + "A minimal replacement admin built on core's services and capabilities only.", + hooks: {}, + + setup(api) { + const { services, capabilities } = { + services: api.services, + capabilities: api.capabilities, + }; + const usersDir = `${services.paths.dataDir}/stub-admin/users`; + const users = services.users.createLocalStore({ usersDir }); + const sessionsReady = services.sessions.createManager({ + sessionsDir: `${services.paths.runtimeDir}/stub-admin/sessions`, + lifetimeMs: 60 * 60 * 1000, + }); + + const ready = (async () => { + const sessions = await sessionsReady; + state!.sessions = sessions; + for (const u of options.users ?? []) { + if (await users.getByUsername(u.username)) continue; + await users.create({ + email: `${u.username}@stub-admin.test`, + username: u.username, + name: u.username, + provider: "local", + passwordHash: await services.passwords.hash(u.password), + roles: [u.role ?? "admin"], + }); + } + const all = await users.list(); + await services.authz.seedAdminTuples( + all.map((u) => ({ id: u.id, role: u.roles[0] })), + ); + })(); + state = { users, sessions: null, passwords: services.passwords, ready }; + + capabilities.provide("auth.session", () => ({ + ...(declareCookie ? { sessionCookies: [cookieName] } : {}), + authenticate, + })); + capabilities.provide("scheduler.executor", () => ({ + execute: (action) => { + executedActions.push(action); + return Promise.resolve(); + }, + })); + }, + + async mount({ app, bootstrap }) { + await state!.ready; + const { users, passwords } = state!; + const sessions = state!.sessions!; + const json = (body: unknown, status = 200, headers: HeadersInit = {}) => + Response.json(body, { status, headers }); + + app.post(`${prefix}/login`, async (fc) => { + const body = await fc.req.json().catch(() => ({})); + const user = typeof body.username === "string" + ? await users.getByUsername(body.username) + : null; + // Verify against a dummy hash for unknown users so both paths cost the same. + const ok = await passwords.verify( + String(body.password ?? ""), + user?.passwordHash ?? passwords.dummyHash, + ); + if (!user || !ok || user.enabled === false) { + return json({ error: "Invalid credentials" }, 401); + } + const session = await sessions.create(user.id); + return json({ ok: true }, 200, { + "Set-Cookie": + `${cookieName}=${session.id}; Path=/; HttpOnly; SameSite=Lax`, + }); + }); + + app.get(`${prefix}/pages`, async (fc) => { + const who = await authenticate(fc.req); + if (!who.authenticated) return json({ error: "Unauthorized" }, 401); + return json({ + pages: bootstrap.engine.pages.map((p) => ({ + route: p.route, + title: p.title, + sourcePath: p.sourcePath, + })), + }); + }); + + // The stub is a writer, so it fires the matching event itself. + app.post(`${prefix}/pages/edit`, async (fc) => { + const who = await authenticate(fc.req); + if (!who.authenticated || !who.user) { + return json({ error: "Unauthorized" }, 401); + } + const allowed = bootstrap.authz + ? await bootstrap.authz.check({ + who: { type: "user", id: who.user.id }, + // deno-lint-ignore no-explicit-any + canThey: "pages.update" as any, + onWhat: { type: "app", id: "admin" }, + }) + : false; + if (!allowed) return json({ error: "Forbidden" }, 403); + const body = await fc.req.json().catch(() => ({})); + if ( + typeof body.sourcePath !== "string" || + typeof body.content !== "string" + ) { + return json({ error: "sourcePath and content are required" }, 400); + } + await bootstrap.storage.write(body.sourcePath, body.content); + await bootstrap.hooks.fire("onPageUpdate", { + sourcePath: body.sourcePath, + }); + return json({ ok: true }); + }); + }, + }; + + return { plugin, executedActions, cookieName, prefix }; +} diff --git a/tests/stub_admin_test.ts b/tests/stub_admin_test.ts new file mode 100644 index 0000000..7605d32 --- /dev/null +++ b/tests/stub_admin_test.ts @@ -0,0 +1,285 @@ +/** + * A replacement admin built only on core's injected services, capabilities and + * mount API. If these pass, Dune does not depend on `@dune/plugin-admin`. + */ + +import { assertEquals, assertExists, assertStringIncludes } from "@std/assert"; +import type { DunePlugin } from "@dune/core/hooks"; +import { createAppHarness, createStubAdmin } from "../src/mod.ts"; + +const content = { + "01.home/default.md": "---\ntitle: Home\n---\nHello world", + "02.about/default.md": "---\ntitle: About\n---\nAbout us", +}; + +/** Records the auth core hands to transformResponse and adds an editing marker. */ +function probe() { + const seen: { username?: string; canUpdate?: boolean; role?: string }[] = []; + const plugin: DunePlugin = { + name: "probe", + version: "1.0.0", + hooks: {}, + async transformResponse({ response, auth }) { + seen.push( + auth + ? { + username: auth.username, + role: auth.role, + canUpdate: auth.hasPermission("pages.update"), + } + : {}, + ); + const type = response.headers.get("Content-Type") ?? ""; + if (!type.includes("text/html")) return response; + const body = await response.text(); + return new Response( + body.replace("", `

m

`), + { + status: response.status, + headers: response.headers, + }, + ); + }, + }; + return { plugin, seen }; +} + +async function login( + h: Awaited>, + prefix: string, + username: string, + password: string, +) { + const res = await h.request(`${prefix}/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + return { res, cookie: res.headers.get("set-cookie")?.split(";")[0] }; +} + +Deno.test("stub admin: the public site serves with it installed, and core sees what it provides", async () => { + const admin = createStubAdmin({ + users: [{ username: "ed", password: "pw" }], + }); + const h = await createAppHarness({ content, plugins: [admin.plugin] }); + try { + const home = await h.request("/"); + assertEquals(home.status, 200); + assertStringIncludes( + await home.text(), + "Home | Dune Test Site", + ); + + const providers = Object.fromEntries( + h.bootstrap.capabilities.providers().map((p) => [p.name, p]), + ); + assertEquals(providers["auth.session"].provider, "stub-admin"); + assertEquals(providers["auth.session"].status, "active"); + assertEquals(providers["scheduler.executor"].status, "active"); + assertExists( + h.bootstrap.authz, + "core creates authz for any auth.session provider, with admin.enabled false", + ); + } finally { + await h.dispose(); + } +}); + +Deno.test("stub admin: login accepts the right password and sets its own cookie", async () => { + const admin = createStubAdmin({ + users: [{ username: "ed", password: "pw" }], + }); + const h = await createAppHarness({ content, plugins: [admin.plugin] }); + try { + const bad = await login(h, admin.prefix, "ed", "wrong"); + assertEquals(bad.res.status, 401); + assertEquals(bad.cookie, undefined); + assertEquals( + (await login(h, admin.prefix, "nobody", "pw")).res.status, + 401, + ); + + const good = await login(h, admin.prefix, "ed", "pw"); + assertEquals(good.res.status, 200); + assertStringIncludes(good.cookie ?? "", `${admin.cookieName}=`); + } finally { + await h.dispose(); + } +}); + +Deno.test("stub admin: core's transform pipeline recognises its session and only its session", async () => { + const admin = createStubAdmin({ + users: [{ username: "ed", password: "pw" }], + }); + const { plugin, seen } = probe(); + const h = await createAppHarness({ + content, + plugins: [admin.plugin, plugin], + }); + try { + const { cookie } = await login(h, admin.prefix, "ed", "pw"); + assertExists(cookie); + + const anonymous = await h.request("/about/"); + const anonHtml = await anonymous.text(); + assertEquals(seen.at(-1), {}, "no cookie: auth is null"); + assertEquals( + anonHtml.includes("data-dune-field"), + false, + "markers are scrubbed for anonymous visitors", + ); + + const other = await h.request("/about/", { + headers: { cookie: "analytics=1" }, + }); + await other.text(); + assertEquals(seen.at(-1), {}, "an unrelated cookie is not a session"); + + const editor = await h.request("/about/", { headers: { cookie } }); + const editorHtml = await editor.text(); + assertEquals(seen.at(-1), { + username: "ed", + role: "admin", + canUpdate: true, + }); + assertStringIncludes( + editorHtml, + "data-dune-field", + "editing markers survive for a valid editing session", + ); + } finally { + await h.dispose(); + } +}); + +Deno.test("stub admin: a forged cookie is not a session", async () => { + const admin = createStubAdmin({ + users: [{ username: "ed", password: "pw" }], + }); + const { plugin, seen } = probe(); + const h = await createAppHarness({ + content, + plugins: [admin.plugin, plugin], + }); + try { + const res = await h.request("/about/", { + headers: { cookie: `${admin.cookieName}=forged` }, + }); + assertEquals((await res.text()).includes("data-dune-field"), false); + assertEquals(seen.at(-1), {}); + } finally { + await h.dispose(); + } +}); + +Deno.test("stub admin: a provider that does not declare its cookie is still found (any cookie is tried)", async () => { + const admin = createStubAdmin({ + users: [{ username: "ed", password: "pw" }], + declareCookie: false, + cookieName: "weird_name", + }); + const { plugin, seen } = probe(); + const h = await createAppHarness({ + content, + plugins: [admin.plugin, plugin], + }); + try { + assertEquals( + h.bootstrap.capabilities.get("auth.session")?.sessionCookies, + undefined, + ); + const { cookie } = await login(h, admin.prefix, "ed", "pw"); + await (await h.request("/about/", { headers: { cookie: cookie! } })).text(); + assertEquals(seen.at(-1)?.username, "ed"); + } finally { + await h.dispose(); + } +}); + +Deno.test("stub admin: it edits a page through storage and fires onPageUpdate itself", async () => { + const admin = createStubAdmin({ + users: [{ username: "ed", password: "pw" }], + }); + const updates: unknown[] = []; + const listener: DunePlugin = { + name: "listener", + version: "1.0.0", + hooks: { onPageUpdate: ({ data }) => void updates.push(data) }, + }; + const h = await createAppHarness({ + content, + plugins: [admin.plugin, listener], + }); + try { + const edit = (cookie?: string) => + h.request(`${admin.prefix}/pages/edit`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify({ + sourcePath: "content/02.about/default.md", + content: "---\ntitle: About\n---\nEdited", + }), + }); + + assertEquals((await edit()).status, 401, "anonymous cannot edit"); + assertEquals(updates.length, 0); + + const { cookie } = await login(h, admin.prefix, "ed", "pw"); + const res = await edit(cookie); + assertEquals(res.status, 200); + assertEquals( + await h.storage.readText("content/02.about/default.md"), + "---\ntitle: About\n---\nEdited", + ); + assertEquals( + updates, + [{ sourcePath: "content/02.about/default.md" }], + "the writer fires the event, exactly once", + ); + + const list = await h.request(`${admin.prefix}/pages`, { + headers: { cookie: cookie! }, + }); + assertEquals((await list.json()).pages.length, 2); + } finally { + await h.dispose(); + } +}); + +Deno.test("stub admin: an account without an editing role is authenticated but cannot edit", async () => { + const admin = createStubAdmin({ + users: [{ username: "viewer", password: "pw", role: "member" }], + }); + const h = await createAppHarness({ content, plugins: [admin.plugin] }); + try { + const { cookie } = await login(h, admin.prefix, "viewer", "pw"); + const res = await h.request(`${admin.prefix}/pages/edit`, { + method: "POST", + headers: { "content-type": "application/json", cookie: cookie! }, + body: JSON.stringify({ + sourcePath: "content/02.about/default.md", + content: "x", + }), + }); + assertEquals(res.status, 403); + } finally { + await h.dispose(); + } +}); + +Deno.test("without any admin the site still serves, and nothing admin-related exists", async () => { + const h = await createAppHarness({ content }); + try { + assertEquals((await h.request("/")).status, 200); + assertEquals(h.bootstrap.capabilities.get("auth.session"), undefined); + assertEquals(h.bootstrap.authz, undefined); + assertEquals((await h.request("/stub-admin/pages")).status, 404); + assertEquals((await h.request("/api/inline-edit/ws")).status, 501); + } finally { + await h.dispose(); + } +}); From 807765a06062b84423e430cc48b633943945ae8f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 14:43:53 +0100 Subject: [PATCH 02/16] feat: admin conformance suite (runAdminConformance) with the stub admin as reference Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 + README.md | 40 +++ deno.json | 3 +- src/conformance.ts | 489 +++++++++++++++++++++++++++++ src/mod.ts | 1 + tests/conformance_negative_test.ts | 184 +++++++++++ tests/conformance_stub_test.ts | 63 ++++ 7 files changed, 788 insertions(+), 1 deletion(-) create mode 100644 src/conformance.ts create mode 100644 tests/conformance_negative_test.ts create mode 100644 tests/conformance_stub_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1dcb3..8a98743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### Added +- **`runAdminConformance()`** (`@dune/testing/conformance`): the admin + conformance suite. A driver says how to build an app with an admin installed + and how to sign an editor in; the suite checks the contract with core (the + `auth.session` capability, session recognition and marker scrubbing, the + declared session cookie, mount-order independence, `onPageUpdate` from the + writer, `admin.pages` / `admin.routes` mounting, and the site serving with no + admin). Also `adminConformanceChecks()` to run the checks yourself. Its own + tests include deliberately broken admins that it must reject. The stub admin + is the reference run. - **`createAppHarness()`** (`@dune/testing/app-harness`): the in-memory harness plus the full `createDuneApp()` app, with `request(path)` going through plugin routes, the response-transform pipeline and the content catch-all. diff --git a/README.md b/README.md index 5ab63d6..d3ffb01 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,46 @@ const h = await createAppHarness({ }); ``` +### `runAdminConformance(driver)` + +What "meets the contract" means for an admin. Dune does not depend on +`@dune/plugin-admin`: any plugin that provides the `auth.session` capability is +the admin as far as core is concerned. Give the suite a driver (how to build an +app with your admin, how to sign an editor in) and it registers these tests: + +- the admin provides `auth.session` (exactly one provider, active); +- the public site serves, and anonymous visitors are not admins; +- core's response pipeline recognises an editor session and **only** a real one: + no cookie, an unrelated cookie and a forged session cookie get no auth and + have editing markers scrubbed; the editor gets `pages.update` and keeps the + markers; +- the cookie the admin declares in `sessionCookies` is the cookie its sign-in + sets (otherwise core would never consult it, and its pages could enter the + shared cache); +- an account without editing rights gets no editing chrome (`signInViewer`); +- a route in a plugin mounted before the admin can still tell who the admin is + through `auth.session`; +- editing a page through the admin writes it and fires `onPageUpdate` exactly + once (`editPage`); +- `admin.pages` and `admin.routes` contributed by another plugin are mounted + behind the login, the CSRF check and the permission (`adminPrefix`); +- with no admin at all the site serves and nothing admin-related exists. + +```ts +runAdminConformance({ + name: "my-admin", + async createApp({ content, extraPlugins, withAdmin }) { + /* build the app; register extraPlugins BEFORE your admin */ + }, + signInEditor: async (app) => "my_session=abc123", // the Cookie header value of an editor +}); +``` + +The optional hooks (`signInViewer`, `editPage`, `adminPrefix`) enable the checks +that need them. `adminConformanceChecks(driver)` returns the checks without +registering them, which is how the package tests that the suite rejects a broken +admin. The stub admin (`createStubAdmin()`) is the reference run. + ### Options | Option | Default | Description | diff --git a/deno.json b/deno.json index 2de3cca..c68de32 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,8 @@ ".": "./src/mod.ts", "./harness": "./src/harness.ts", "./app-harness": "./src/app-harness.ts", - "./stub-admin": "./src/stub-admin.ts" + "./stub-admin": "./src/stub-admin.ts", + "./conformance": "./src/conformance.ts" }, "imports": { "@std/assert": "jsr:@std/assert@^1", diff --git a/src/conformance.ts b/src/conformance.ts new file mode 100644 index 0000000..2cfb1c8 --- /dev/null +++ b/src/conformance.ts @@ -0,0 +1,489 @@ +/** + * Admin conformance suite: checks that an admin plugin meets the contract with + * core, without knowing anything else about it. + * + * Dune does not depend on `@dune/plugin-admin`: any plugin that provides the + * `auth.session` capability is "the admin" as far as core is concerned. This + * suite is what "meets the contract" means. Hand it a *driver* that says how to + * build an app with your admin installed and how to sign an editor in; it + * registers the tests. + * + * ```ts + * import { createAppHarness, createStubAdmin, runAdminConformance } from "@dune/testing"; + * + * runAdminConformance({ + * name: "my-admin", + * async createApp({ content, extraPlugins, withAdmin }) { + * const admin = createMyAdmin(); + * const h = await createAppHarness({ content, plugins: [...extraPlugins, ...(withAdmin ? [admin] : [])] }); + * return { request: h.request, bootstrap: h.bootstrap, readText: (p) => h.storage.readText(p), dispose: h.dispose }; + * }, + * signInEditor: async (app) => (await login(app, "ed")).cookie, + * }); + * ``` + * + * The suite covers what core relies on: the capabilities the admin provides, + * that core's response pipeline recognises exactly the admin's sessions (and + * keeps editing markers only for a valid editing session), that the cookie it + * declares is the cookie it sets, that a route in a plugin mounted *before* the + * admin can still tell who the admin is, and that the site serves without it. + * Optional parts run when the driver supplies the hook for them. + * + * @module + */ + +import { assertEquals, assertExists, assertStringIncludes } from "@std/assert"; +import type { BootstrapResult } from "@dune/core/bootstrap"; +import type { DunePlugin } from "@dune/core/hooks"; + +/** What the suite needs from a running app. */ +export interface ConformanceApp { + /** Send an in-process request through the whole app. */ + request(path: string, init?: RequestInit): Promise; + /** The bootstrap result: capabilities, authz, hooks. */ + bootstrap: BootstrapResult; + /** Read a stored file by its site-relative path (for example `content/02.about/default.md`). */ + readText(path: string): Promise; + /** Tear the app down. */ + dispose(): Promise; +} + +/** Options the suite passes to {@link AdminConformanceDriver.createApp}. */ +export interface ConformanceAppOptions { + /** Fixture content, keyed by path relative to `content/`. */ + content: Record; + /** + * Plugins to register **before** the admin, in this order. Registration order + * is mount order, and the suite relies on the admin mounting after them. + */ + extraPlugins: DunePlugin[]; + /** False: build the app with no admin at all (the built-in one switched off too). */ + withAdmin: boolean; +} + +/** Describes the admin under test. Implement it once per admin. */ +export interface AdminConformanceDriver { + /** Shown in every test name. */ + name: string; + /** Build an app with (or, for `withAdmin: false`, without) the admin. */ + createApp(options: ConformanceAppOptions): Promise; + /** + * Create an account allowed to edit pages (`pages.update`), sign it in, and + * return the value of the `Cookie` header that authenticates it. + */ + signInEditor(app: ConformanceApp): Promise; + /** Optional: an account that signs in but may not edit. Enables the viewer test. */ + signInViewer?(app: ConformanceApp): Promise; + /** + * Optional: edit a page through the admin's own routes, as the signed-in + * editor. Enables the check that the writer fires `onPageUpdate` once. + * `sourcePath` is the site-relative path of the content file. + */ + editPage?( + app: ConformanceApp, + cookie: string, + edit: { sourcePath: string; content: string }, + ): Promise; + /** + * Optional: the admin's URL prefix, if it mounts `admin.pages` and + * `admin.routes` contributions from other plugins. Enables those checks. + */ + adminPrefix?: string; +} + +const CONTENT = { + "01.home/default.md": "---\ntitle: Home\n---\nHello world", + "02.about/default.md": "---\ntitle: About\n---\nAbout us", +}; +const ABOUT = "/about/"; +const MARKER = "data-dune-field"; + +/** What a probe plugin records for each response core hands it. */ +interface Seen { + username?: string; + canUpdate?: boolean; +} + +/** Records `ResponseTransformContext.auth` and adds an editing marker to HTML. */ +function probe() { + const seen: Seen[] = []; + const plugin: DunePlugin = { + name: "conformance-probe", + version: "1.0.0", + hooks: {}, + async transformResponse({ response, auth }) { + seen.push( + auth + ? { + username: auth.username, + canUpdate: auth.hasPermission("pages.update"), + } + : {}, + ); + if (!(response.headers.get("Content-Type") ?? "").includes("text/html")) { + return response; + } + const body = await response.text(); + return new Response( + body.replace("", `

m

`), + { + status: response.status, + headers: response.headers, + }, + ); + }, + }; + return { plugin, seen }; +} + +async function withApp( + driver: AdminConformanceDriver, + options: Partial, + fn: (app: ConformanceApp) => Promise, +): Promise { + const app = await driver.createApp({ + content: CONTENT, + extraPlugins: [], + withAdmin: true, + ...options, + }); + try { + await fn(app); + } finally { + await app.dispose(); + } +} + +const cookieNames = (cookie: string) => + cookie.split(";").map((c) => c.trim().split("=")[0]).filter(Boolean); + +/** One conformance check: a name and the function that throws when the admin does not conform. */ +export interface ConformanceCheck { + name: string; + run(): Promise; +} + +/** + * The conformance checks for the admin that `driver` describes, without + * registering them. {@link runAdminConformance} registers them as tests; call + * this directly to run them yourself (for example to assert that a deliberately + * broken admin fails one). + */ +export function adminConformanceChecks( + driver: AdminConformanceDriver, +): ConformanceCheck[] { + const checks: ConformanceCheck[] = []; + const t = (name: string, run: () => Promise) => + checks.push({ name: `conformance [${driver.name}]: ${name}`, run }); + + t( + "provides auth.session, the capability that makes it the admin", + () => + withApp(driver, {}, (app) => { + const providers = app.bootstrap.capabilities.providers(); + const session = providers.find((p) => p.name === "auth.session"); + assertExists(session, "no plugin provides auth.session"); + assertEquals( + session.status, + "active", + "the auth.session factory did not produce a provider", + ); + assertEquals( + providers.filter((p) => p.name === "auth.session").length, + 1, + "exactly one auth.session provider", + ); + return Promise.resolve(); + }), + ); + + t( + "the public site serves, and anonymous visitors are not admins", + () => + withApp(driver, {}, async (app) => { + const home = await app.request("/"); + assertEquals(home.status, 200); + assertStringIncludes(await home.text(), "Home"); + + const auth = app.bootstrap.capabilities.get("auth.session")!; + const anonymous = await auth.authenticate( + new Request("http://localhost:8000/"), + ); + assertEquals(anonymous.authenticated, false); + }), + ); + + t( + "core's response pipeline recognises an editor session, and only a real one", + async () => { + const { plugin, seen } = probe(); + await withApp(driver, { extraPlugins: [plugin] }, async (app) => { + const cookie = await driver.signInEditor(app); + + const anonymous = await app.request(ABOUT); + const anonymousHtml = await anonymous.text(); + assertEquals(seen.at(-1), {}, "no cookie: core hands plugins no auth"); + assertEquals( + anonymousHtml.includes(MARKER), + false, + "editing markers are scrubbed for anonymous visitors", + ); + + await (await app.request(ABOUT, { headers: { cookie: "analytics=1" } })) + .text(); + assertEquals(seen.at(-1), {}, "an unrelated cookie is not a session"); + + const forged = cookieNames(cookie).map((n) => `${n}=forged-value`).join( + "; ", + ); + const forgedHtml = + await (await app.request(ABOUT, { headers: { cookie: forged } })) + .text(); + assertEquals( + seen.at(-1), + {}, + "a forged session cookie is not a session", + ); + assertEquals(forgedHtml.includes(MARKER), false); + + const editorHtml = + await (await app.request(ABOUT, { headers: { cookie } })).text(); + assertEquals( + seen.at(-1)?.canUpdate, + true, + "an editor session reaches plugins with pages.update", + ); + assertEquals(typeof seen.at(-1)?.username, "string"); + assertStringIncludes( + editorHtml, + MARKER, + "editing markers survive for a valid editing session", + ); + }); + }, + ); + + t( + "the cookie it declares is the cookie its sign-in sets", + () => + withApp(driver, {}, async (app) => { + const declared = app.bootstrap.capabilities.get("auth.session") + ?.sessionCookies; + if (!declared) return; // undeclared is allowed: core then tries every request that has cookies + const cookie = await driver.signInEditor(app); + const set = cookieNames(cookie); + assertEquals( + declared.some((name) => set.includes(name)), + true, + `sessionCookies ${ + JSON.stringify(declared) + } does not include the cookie sign-in sets (${set.join(", ")}): ` + + `core would never consult the admin for that session, and its pages could enter the shared cache`, + ); + }), + ); + + if (driver.signInViewer) { + t( + "an account without editing rights is not given editing chrome", + async () => { + const { plugin, seen } = probe(); + await withApp(driver, { extraPlugins: [plugin] }, async (app) => { + const cookie = await driver.signInViewer!(app); + const html = await (await app.request(ABOUT, { headers: { cookie } })) + .text(); + assertEquals( + seen.at(-1)?.canUpdate === true, + false, + "pages.update must not be granted", + ); + assertEquals( + html.includes(MARKER), + false, + "markers are scrubbed without pages.update", + ); + }); + }, + ); + } + + t( + "a route in a plugin mounted before the admin can still tell who the admin is", + async () => { + const who: DunePlugin = { + name: "conformance-who", + version: "1.0.0", + hooks: {}, + mount({ app, capabilities }) { + app.get("/__conformance/who", async (fc) => { + const result = await capabilities.get("auth.session")?.authenticate( + fc.req, + ); + return new Response(result?.authenticated ? "admin" : "anonymous"); + }); + }, + }; + await withApp(driver, { extraPlugins: [who] }, async (app) => { + const cookie = await driver.signInEditor(app); + assertEquals( + await (await app.request("/__conformance/who")).text(), + "anonymous", + ); + assertEquals( + await (await app.request("/__conformance/who", { + headers: { cookie }, + })).text(), + "admin", + ); + }); + }, + ); + + if (driver.editPage) { + t( + "editing a page through the admin writes it and fires onPageUpdate exactly once", + async () => { + const updates: unknown[] = []; + const listener: DunePlugin = { + name: "conformance-listener", + version: "1.0.0", + hooks: { onPageUpdate: ({ data }) => void updates.push(data) }, + }; + await withApp(driver, { extraPlugins: [listener] }, async (app) => { + const cookie = await driver.signInEditor(app); + const content = "---\ntitle: About\n---\nEdited by conformance"; + await driver.editPage!(app, cookie, { + sourcePath: "content/02.about/default.md", + content, + }); + assertStringIncludes( + await app.readText("content/02.about/default.md"), + "Edited by conformance", + ); + assertEquals( + updates.length, + 1, + "the writer fires onPageUpdate, once", + ); + }); + }, + ); + } + + if (driver.adminPrefix) { + const prefix = driver.adminPrefix; + + t( + "admin.pages from another plugin are mounted behind the admin's login", + async () => { + const contributor: DunePlugin = { + name: "conformance-pages", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.pages", { + path: "/__conformance", + label: "Conformance", + permission: "pages.read", + handler: () => new Response("conformance page"), + }); + }, + }; + await withApp(driver, { extraPlugins: [contributor] }, async (app) => { + const cookie = await driver.signInEditor(app); + const anonymous = await app.request(`${prefix}/__conformance`); + assertEquals( + [301, 302, 401, 403].includes(anonymous.status), + true, + `anonymous got ${anonymous.status}`, + ); + assertEquals( + (await anonymous.text()).includes("conformance page"), + false, + ); + const editor = await app.request(`${prefix}/__conformance`, { + headers: { cookie }, + }); + assertEquals(editor.status, 200); + assertStringIncludes(await editor.text(), "conformance page"); + }); + }, + ); + + t( + "admin.routes from another plugin run only for a signed-in admin passing the CSRF check", + async () => { + let ran = 0; + const contributor: DunePlugin = { + name: "conformance-routes", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.routes", { + method: "POST", + path: "/api/__conformance/act", + permission: "pages.update", + handler: () => { + ran++; + return new Response("done"); + }, + }); + }, + }; + await withApp(driver, { extraPlugins: [contributor] }, async (app) => { + const cookie = await driver.signInEditor(app); + const url = `${prefix}/api/__conformance/act`; + const post = (headers: Record<string, string>) => + app.request(url, { method: "POST", headers }); + + const anonymous = await post({ origin: "http://localhost:8000" }); + assertEquals( + [301, 302, 401, 403].includes(anonymous.status), + true, + `anonymous got ${anonymous.status}`, + ); + const crossOrigin = await post({ + cookie, + origin: "https://evil.example", + }); + assertEquals( + crossOrigin.status, + 403, + "a cross-origin POST is refused", + ); + assertEquals(ran, 0, "the handler did not run for either"); + + const ok = await post({ cookie, origin: "http://localhost:8000" }); + assertEquals(ok.status, 200); + assertEquals(await ok.text(), "done"); + assertEquals(ran, 1); + }); + }, + ); + } + + t( + "without the admin the site serves and nothing admin-related exists", + () => + withApp(driver, { withAdmin: false }, async (app) => { + assertEquals((await app.request("/")).status, 200); + assertEquals(app.bootstrap.capabilities.get("auth.session"), undefined); + assertEquals(app.bootstrap.authz, undefined); + assertEquals((await app.request("/api/inline-edit/ws")).status, 501); + }), + ); + + return checks; +} + +/** Register the conformance tests for the admin that `driver` describes. */ +export function runAdminConformance(driver: AdminConformanceDriver): void { + for (const check of adminConformanceChecks(driver)) { + Deno.test({ + name: check.name, + fn: check.run, + sanitizeOps: false, + sanitizeResources: false, + }); + } +} diff --git a/src/mod.ts b/src/mod.ts index 188c037..dc56379 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -37,3 +37,4 @@ export * from "./harness.ts"; // createTestHarness, TestHarness, TestHarnessOptions, FixtureContent export * from "./app-harness.ts"; // createAppHarness, AppHarness, AppHarnessOptions export * from "./stub-admin.ts"; // createStubAdmin, StubAdmin, StubAdminOptions, StubAdminUser +export * from "./conformance.ts"; // runAdminConformance, AdminConformanceDriver, ConformanceApp, ConformanceAppOptions diff --git a/tests/conformance_negative_test.ts b/tests/conformance_negative_test.ts new file mode 100644 index 0000000..d683a49 --- /dev/null +++ b/tests/conformance_negative_test.ts @@ -0,0 +1,184 @@ +/** + * The conformance suite is only worth something if it fails for an admin that + * breaks the contract. Each case builds the stub admin, breaks one thing, and + * asserts that the matching check (and not the whole suite) rejects it. + */ + +import { assertRejects } from "@std/assert"; +import type { DunePlugin } from "@dune/core/hooks"; +import { + adminConformanceChecks, + createAppHarness, + createStubAdmin, +} from "../src/mod.ts"; +import type { AdminConformanceDriver, ConformanceApp } from "../src/mod.ts"; + +type Breaker = ( + admin: DunePlugin, + helpers: { cookieName: string }, +) => DunePlugin; + +/** A driver over the stub admin, with `breaker` applied to the admin plugin. */ +function driverWith( + name: string, + breaker?: Breaker, + withAuth = true, + afterSignIn?: (app: ConformanceApp, cookie: string) => Promise<void>, +): AdminConformanceDriver { + const prefixes = new WeakMap<ConformanceApp, string>(); + const login = async (app: ConformanceApp, username: string) => { + const res = await app.request(`${prefixes.get(app)}/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password: "pw" }), + }); + const cookie = res.headers.get("set-cookie")?.split(";")[0]; + if (!cookie) throw new Error(`login failed: ${res.status}`); + return cookie; + }; + return { + name, + async createApp({ content, extraPlugins, withAdmin }) { + const stub = createStubAdmin({ + users: [{ username: "ed", password: "pw" }], + }); + const admin = breaker + ? breaker(stub.plugin, { cookieName: stub.cookieName }) + : stub.plugin; + const h = await createAppHarness({ + content, + plugins: [...extraPlugins, ...(withAdmin && withAuth ? [admin] : [])], + }); + const app: ConformanceApp = { + request: h.request, + bootstrap: h.bootstrap, + readText: (p) => h.storage.readText(p), + dispose: h.dispose, + }; + prefixes.set(app, stub.prefix); + return app; + }, + signInEditor: async (app) => { + const cookie = await login(app, "ed"); + await afterSignIn?.(app, cookie); + return cookie; + }, + }; +} + +const find = (driver: AdminConformanceDriver, fragment: string) => { + const check = adminConformanceChecks(driver).find((c) => + c.name.includes(fragment) + ); + if (!check) throw new Error(`no check matching "${fragment}"`); + return check; +}; + +/** Run the stub's own setup, then replace `auth.session` with `override`. */ +const replaceSession = + (override: (cookieName: string) => unknown): Breaker => + (admin, { cookieName }) => ({ + ...admin, + setup(api) { + admin.setup!(api); + api.capabilities.provide( + "auth.session", + () => override(cookieName) as never, + ); + }, + }); + +Deno.test("conformance catches an admin that provides no auth.session", async () => { + const bare: Breaker = (admin) => ({ ...admin, setup: () => {} }); + await assertRejects(() => + find(driverWith("no-session", bare), "provides auth.session").run() + ); +}); + +Deno.test("conformance catches an admin that declares a cookie its sign-in does not set", async () => { + const wrongCookie = replaceSession(() => ({ + sessionCookies: ["some_other_cookie"], + authenticate: () => Promise.resolve({ authenticated: false }), + })); + await assertRejects( + () => + find(driverWith("wrong-cookie", wrongCookie), "the cookie it declares") + .run(), + Error, + "does not include the cookie sign-in sets", + ); +}); + +Deno.test("conformance catches an admin that accepts a forged session as the real editor", async () => { + // Authenticates properly, but any request with a cookie header gets the last + // editor it saw. (Core's authz still decides pages.update, so the gullible + // identity has to be a real editor for the problem to show.) + const gullible: Breaker = (admin) => ({ + ...admin, + setup(api) { + let real: { + authenticate(req: Request): Promise<{ authenticated: boolean }>; + }; + admin.setup!({ + ...api, + capabilities: { + ...api.capabilities, + provide: (name: string, factory: unknown) => { + if (name === "auth.session") { + real = (factory as (ctx: unknown) => typeof real)({}); + } else { + (api.capabilities.provide as unknown as ( + n: string, + f: unknown, + ) => void)(name, factory); + } + }, + } as never, + }); + let last: unknown; + api.capabilities.provide("auth.session", () => ({ + authenticate: async (req: Request) => { + const r = await real.authenticate(req); + if (r.authenticated) { + last = r; + return r; + } + return req.headers.has("cookie") && last ? last : r; + }, + } as never)); + }, + }); + const driver = driverWith( + "gullible", + gullible, + true, + async (app, cookie) => { + await (await app.request("/about/", { headers: { cookie } })).text(); // teaches it the editor + }, + ); + await assertRejects( + () => find(driver, "recognises an editor session").run(), + Error, + "is not a session", + ); +}); + +Deno.test("conformance catches an admin that never recognises its own session", async () => { + const deaf = replaceSession(() => ({ + authenticate: () => Promise.resolve({ authenticated: false }), + })); + await assertRejects( + () => find(driverWith("deaf", deaf), "recognises an editor session").run(), + Error, + "reaches plugins with pages.update", + ); +}); + +Deno.test("conformance catches an admin whose route cannot tell who the admin is", async () => { + const deaf = replaceSession(() => ({ + authenticate: () => Promise.resolve({ authenticated: false }), + })); + await assertRejects(() => + find(driverWith("deaf", deaf), "mounted before the admin").run() + ); +}); diff --git a/tests/conformance_stub_test.ts b/tests/conformance_stub_test.ts new file mode 100644 index 0000000..71e9035 --- /dev/null +++ b/tests/conformance_stub_test.ts @@ -0,0 +1,63 @@ +/** + * The reference run of the admin conformance suite: the stub admin, which is + * built only on core's contract, must pass it. + */ + +import { + createAppHarness, + createStubAdmin, + runAdminConformance, +} from "../src/mod.ts"; +import type { ConformanceApp } from "../src/mod.ts"; +import type { StubAdmin } from "../src/mod.ts"; + +const admins = new WeakMap<ConformanceApp, StubAdmin>(); + +async function login(app: ConformanceApp, username: string): Promise<string> { + const admin = admins.get(app)!; + const res = await app.request(`${admin.prefix}/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password: "pw" }), + }); + const cookie = res.headers.get("set-cookie")?.split(";")[0]; + if (!cookie) { + throw new Error(`stub login failed for ${username}: ${res.status}`); + } + return cookie; +} + +runAdminConformance({ + name: "stub admin", + async createApp({ content, extraPlugins, withAdmin }) { + const admin = createStubAdmin({ + users: [ + { username: "ed", password: "pw", role: "admin" }, + { username: "viewer", password: "pw", role: "member" }, + ], + }); + const h = await createAppHarness({ + content, + plugins: [...extraPlugins, ...(withAdmin ? [admin.plugin] : [])], + }); + const app: ConformanceApp = { + request: h.request, + bootstrap: h.bootstrap, + readText: (path) => h.storage.readText(path), + dispose: h.dispose, + }; + admins.set(app, admin); + return app; + }, + signInEditor: (app) => login(app, "ed"), + signInViewer: (app) => login(app, "viewer"), + async editPage(app, cookie, { sourcePath, content }) { + const admin = admins.get(app)!; + const res = await app.request(`${admin.prefix}/pages/edit`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ sourcePath, content }), + }); + if (res.status !== 200) throw new Error(`stub edit failed: ${res.status}`); + }, +}); From 19cb005d8f65733a46f4420086c1221bc75762bd Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Thu, 5 Nov 2026 14:45:34 +0100 Subject: [PATCH 03/16] fix(conformance): tolerate an onPageUpdate fired after the response Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- src/conformance.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/conformance.ts b/src/conformance.ts index 2cfb1c8..99be120 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -360,6 +360,12 @@ export function adminConformanceChecks( await app.readText("content/02.about/default.md"), "Edited by conformance", ); + // An admin may fire the event after it has answered, so give it a + // moment to arrive, then a moment more to show it does not fire twice. + for (let i = 0; i < 40 && updates.length === 0; i++) { + await new Promise((r) => setTimeout(r, 25)); + } + await new Promise((r) => setTimeout(r, 75)); assertEquals( updates.length, 1, From fd7ba6c2f4b4ab132dc69003a87c7fc7ea6e4cd5 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Thu, 5 Nov 2026 20:49:11 +0100 Subject: [PATCH 04/16] feat(conformance): check that the writer fires onPageCreate and onPageDelete (#97) Optional createPage/deletePage driver hooks. The stub admin gains create and delete routes; its events now carry content-relative paths like core's. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 11 +-- README.md | 12 ++-- src/conformance.ts | 112 +++++++++++++++++++++++++++-- src/stub-admin.ts | 65 ++++++++++++++--- tests/conformance_negative_test.ts | 27 +++++++ tests/conformance_stub_test.ts | 22 ++++++ tests/stub_admin_test.ts | 2 +- 7 files changed, 226 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a98743..3b75194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,12 @@ conformance suite. A driver says how to build an app with an admin installed and how to sign an editor in; the suite checks the contract with core (the `auth.session` capability, session recognition and marker scrubbing, the - declared session cookie, mount-order independence, `onPageUpdate` from the - writer, `admin.pages` / `admin.routes` mounting, and the site serving with no - admin). Also `adminConformanceChecks()` to run the checks yourself. Its own - tests include deliberately broken admins that it must reject. The stub admin - is the reference run. + declared session cookie, mount-order independence, `onPageUpdate` / + `onPageCreate` / `onPageDelete` from the writer, `admin.pages` / + `admin.routes` mounting, and the site serving with no admin). Also + `adminConformanceChecks()` to run the checks yourself. Its own tests include + deliberately broken admins that it must reject. The stub admin is the + reference run. - **`createAppHarness()`** (`@dune/testing/app-harness`): the in-memory harness plus the full `createDuneApp()` app, with `request(path)` going through plugin routes, the response-transform pipeline and the content catch-all. diff --git a/README.md b/README.md index d3ffb01..d0eb78c 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ app with your admin, how to sign an editor in) and it registers these tests: through `auth.session`; - editing a page through the admin writes it and fires `onPageUpdate` exactly once (`editPage`); +- creating a page fires `onPageCreate` exactly once and deleting one fires + `onPageDelete` exactly once, each with the path relative to the content + directory (`createPage`, `deletePage`); - `admin.pages` and `admin.routes` contributed by another plugin are mounted behind the login, the CSRF check and the permission (`adminPrefix`); - with no admin at all the site serves and nothing admin-related exists. @@ -130,10 +133,11 @@ runAdminConformance({ }); ``` -The optional hooks (`signInViewer`, `editPage`, `adminPrefix`) enable the checks -that need them. `adminConformanceChecks(driver)` returns the checks without -registering them, which is how the package tests that the suite rejects a broken -admin. The stub admin (`createStubAdmin()`) is the reference run. +The optional hooks (`signInViewer`, `editPage`, `createPage`, `deletePage`, +`adminPrefix`) enable the checks that need them. +`adminConformanceChecks(driver)` returns the checks without registering them, +which is how the package tests that the suite rejects a broken admin. The stub +admin (`createStubAdmin()`) is the reference run. ### Options diff --git a/src/conformance.ts b/src/conformance.ts index 99be120..a9d2032 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -84,6 +84,25 @@ export interface AdminConformanceDriver { cookie: string, edit: { sourcePath: string; content: string }, ): Promise<void>; + /** + * Optional: create a page through the admin's own routes. `sourcePath` is the + * site-relative path of the content file to create. Enables the check that + * the writer fires `onPageCreate` once. + */ + createPage?( + app: ConformanceApp, + cookie: string, + page: { sourcePath: string; title: string }, + ): Promise<void>; + /** + * Optional: delete an existing page through the admin's own routes. + * Enables the check that the writer fires `onPageDelete` once. + */ + deletePage?( + app: ConformanceApp, + cookie: string, + page: { sourcePath: string }, + ): Promise<void>; /** * Optional: the admin's URL prefix, if it mounts `admin.pages` and * `admin.routes` contributions from other plugins. Enables those checks. @@ -98,6 +117,17 @@ const CONTENT = { const ABOUT = "/about/"; const MARKER = "data-dune-field"; +/** + * An admin may fire a page event after it has answered, so give the first one + * a moment to arrive, then a moment more to show it does not fire twice. + */ +async function settle(events: unknown[]): Promise<void> { + for (let i = 0; i < 40 && events.length === 0; i++) { + await new Promise((r) => setTimeout(r, 25)); + } + await new Promise((r) => setTimeout(r, 75)); +} + /** What a probe plugin records for each response core hands it. */ interface Seen { username?: string; @@ -360,12 +390,7 @@ export function adminConformanceChecks( await app.readText("content/02.about/default.md"), "Edited by conformance", ); - // An admin may fire the event after it has answered, so give it a - // moment to arrive, then a moment more to show it does not fire twice. - for (let i = 0; i < 40 && updates.length === 0; i++) { - await new Promise((r) => setTimeout(r, 25)); - } - await new Promise((r) => setTimeout(r, 75)); + await settle(updates); assertEquals( updates.length, 1, @@ -376,6 +401,81 @@ export function adminConformanceChecks( ); } + if (driver.createPage) { + t( + "creating a page through the admin writes it and fires onPageCreate exactly once", + async () => { + const created: unknown[] = []; + const listener: DunePlugin = { + name: "conformance-listener", + version: "1.0.0", + hooks: { onPageCreate: ({ data }) => void created.push(data) }, + }; + await withApp(driver, { extraPlugins: [listener] }, async (app) => { + const cookie = await driver.signInEditor(app); + await driver.createPage!(app, cookie, { + sourcePath: "content/03.fresh/default.md", + title: "Fresh", + }); + assertStringIncludes( + await app.readText("content/03.fresh/default.md"), + "Fresh", + ); + await settle(created); + assertEquals( + created.length, + 1, + "the writer fires onPageCreate, once", + ); + assertEquals( + (created[0] as { sourcePath?: string }).sourcePath, + "03.fresh/default.md", + "the event carries the path relative to the content directory", + ); + }); + }, + ); + } + + if (driver.deletePage) { + t( + "deleting a page through the admin removes it and fires onPageDelete exactly once", + async () => { + const deleted: unknown[] = []; + const listener: DunePlugin = { + name: "conformance-listener", + version: "1.0.0", + hooks: { onPageDelete: ({ data }) => void deleted.push(data) }, + }; + await withApp(driver, { extraPlugins: [listener] }, async (app) => { + const cookie = await driver.signInEditor(app); + await driver.deletePage!(app, cookie, { + sourcePath: "content/02.about/default.md", + }); + await app.readText("content/02.about/default.md").then( + () => { + throw new Error( + "the page file is still on disk after the delete", + ); + }, + () => {}, + ); + await settle(deleted); + assertEquals( + deleted.length, + 1, + "the writer fires onPageDelete, once", + ); + assertEquals( + (deleted[0] as { sourcePath?: string }).sourcePath, + "02.about/default.md", + "the event carries the path relative to the content directory", + ); + }); + }, + ); + } + if (driver.adminPrefix) { const prefix = driver.adminPrefix; diff --git a/src/stub-admin.ts b/src/stub-admin.ts index 8d32346..9c3dbc1 100644 --- a/src/stub-admin.ts +++ b/src/stub-admin.ts @@ -197,8 +197,19 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { }); }); - // The stub is a writer, so it fires the matching event itself. - app.post(`${prefix}/pages/edit`, async (fc) => { + // The stub is a writer, so it fires the matching event itself. Events + // carry the path relative to the content directory, as core reports it. + const contentDir = bootstrap.config.system.content.dir; + const relative = (p: string) => + p.startsWith(`${contentDir}/`) ? p.slice(contentDir.length + 1) : p; + + /** Authenticated, allowed to update pages, and a well-formed body; else the error response. */ + const guarded = async ( + fc: { req: Request }, + needs: Array<"content" | "title">, + ): Promise< + Response | { sourcePath: string; content: string; title: string } + > => { const who = await authenticate(fc.req); if (!who.authenticated || !who.user) { return json({ error: "Unauthorized" }, 401); @@ -213,15 +224,51 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { : false; if (!allowed) return json({ error: "Forbidden" }, 403); const body = await fc.req.json().catch(() => ({})); - if ( - typeof body.sourcePath !== "string" || - typeof body.content !== "string" - ) { - return json({ error: "sourcePath and content are required" }, 400); + if (typeof body.sourcePath !== "string") { + return json({ error: "sourcePath is required" }, 400); } - await bootstrap.storage.write(body.sourcePath, body.content); - await bootstrap.hooks.fire("onPageUpdate", { + for (const key of needs) { + if (typeof body[key] !== "string") { + return json({ error: `${key} is required` }, 400); + } + } + return { sourcePath: body.sourcePath, + content: body.content ?? "", + title: body.title ?? "", + }; + }; + + app.post(`${prefix}/pages/edit`, async (fc) => { + const g = await guarded(fc, ["content"]); + if (g instanceof Response) return g; + await bootstrap.storage.write(g.sourcePath, g.content); + await bootstrap.hooks.fire("onPageUpdate", { + sourcePath: relative(g.sourcePath), + }); + return json({ ok: true }); + }); + + app.post(`${prefix}/pages/create`, async (fc) => { + const g = await guarded(fc, ["title"]); + if (g instanceof Response) return g; + await bootstrap.storage.write( + g.sourcePath, + `---\ntitle: ${g.title}\n---\n${g.content}`, + ); + await bootstrap.hooks.fire("onPageCreate", { + sourcePath: relative(g.sourcePath), + title: g.title, + }); + return json({ ok: true }); + }); + + app.post(`${prefix}/pages/delete`, async (fc) => { + const g = await guarded(fc, []); + if (g instanceof Response) return g; + await bootstrap.storage.delete(g.sourcePath); + await bootstrap.hooks.fire("onPageDelete", { + sourcePath: relative(g.sourcePath), }); return json({ ok: true }); }); diff --git a/tests/conformance_negative_test.ts b/tests/conformance_negative_test.ts index d683a49..1de0a68 100644 --- a/tests/conformance_negative_test.ts +++ b/tests/conformance_negative_test.ts @@ -182,3 +182,30 @@ Deno.test("conformance catches an admin whose route cannot tell who the admin is find(driverWith("deaf", deaf), "mounted before the admin").run() ); }); + +Deno.test("conformance catches an admin that writes pages without announcing them", async () => { + // The writes land on disk but no event is fired: search indexes, webhooks and + // audit plugins would never hear of them. + const silent: AdminConformanceDriver = { + ...driverWith("silent"), + createPage: async (app, _cookie, { sourcePath, title }) => { + await app.bootstrap.storage.write( + sourcePath, + `---\ntitle: ${title}\n---\n`, + ); + }, + deletePage: async (app, _cookie, { sourcePath }) => { + await app.bootstrap.storage.delete(sourcePath); + }, + }; + await assertRejects( + () => find(silent, "creating a page").run(), + Error, + "fires onPageCreate, once", + ); + await assertRejects( + () => find(silent, "deleting a page").run(), + Error, + "fires onPageDelete, once", + ); +}); diff --git a/tests/conformance_stub_test.ts b/tests/conformance_stub_test.ts index 71e9035..1314cf6 100644 --- a/tests/conformance_stub_test.ts +++ b/tests/conformance_stub_test.ts @@ -60,4 +60,26 @@ runAdminConformance({ }); if (res.status !== 200) throw new Error(`stub edit failed: ${res.status}`); }, + async createPage(app, cookie, { sourcePath, title }) { + const admin = admins.get(app)!; + const res = await app.request(`${admin.prefix}/pages/create`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ sourcePath, title }), + }); + if (res.status !== 200) { + throw new Error(`stub create failed: ${res.status}`); + } + }, + async deletePage(app, cookie, { sourcePath }) { + const admin = admins.get(app)!; + const res = await app.request(`${admin.prefix}/pages/delete`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ sourcePath }), + }); + if (res.status !== 200) { + throw new Error(`stub delete failed: ${res.status}`); + } + }, }); diff --git a/tests/stub_admin_test.ts b/tests/stub_admin_test.ts index 7605d32..d74b089 100644 --- a/tests/stub_admin_test.ts +++ b/tests/stub_admin_test.ts @@ -237,7 +237,7 @@ Deno.test("stub admin: it edits a page through storage and fires onPageUpdate it ); assertEquals( updates, - [{ sourcePath: "content/02.about/default.md" }], + [{ sourcePath: "02.about/default.md" }], "the writer fires the event, exactly once", ); From 8f7f0268de5848b9272c338eef5e894ea7f23406 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Thu, 5 Nov 2026 21:21:10 +0100 Subject: [PATCH 05/16] feat(stub-admin): mount() reads MountApi.runtime (#97) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- src/stub-admin.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/stub-admin.ts b/src/stub-admin.ts index 9c3dbc1..f79b2b2 100644 --- a/src/stub-admin.ts +++ b/src/stub-admin.ts @@ -158,7 +158,7 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { })); }, - async mount({ app, bootstrap }) { + async mount({ app, runtime }) { await state!.ready; const { users, passwords } = state!; const sessions = state!.sessions!; @@ -189,7 +189,7 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { const who = await authenticate(fc.req); if (!who.authenticated) return json({ error: "Unauthorized" }, 401); return json({ - pages: bootstrap.engine.pages.map((p) => ({ + pages: runtime.engine.pages.map((p) => ({ route: p.route, title: p.title, sourcePath: p.sourcePath, @@ -199,7 +199,7 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { // The stub is a writer, so it fires the matching event itself. Events // carry the path relative to the content directory, as core reports it. - const contentDir = bootstrap.config.system.content.dir; + const contentDir = runtime.config.system.content.dir; const relative = (p: string) => p.startsWith(`${contentDir}/`) ? p.slice(contentDir.length + 1) : p; @@ -214,8 +214,8 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { if (!who.authenticated || !who.user) { return json({ error: "Unauthorized" }, 401); } - const allowed = bootstrap.authz - ? await bootstrap.authz.check({ + const allowed = runtime.authz + ? await runtime.authz.check({ who: { type: "user", id: who.user.id }, // deno-lint-ignore no-explicit-any canThey: "pages.update" as any, @@ -242,8 +242,8 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { app.post(`${prefix}/pages/edit`, async (fc) => { const g = await guarded(fc, ["content"]); if (g instanceof Response) return g; - await bootstrap.storage.write(g.sourcePath, g.content); - await bootstrap.hooks.fire("onPageUpdate", { + await runtime.storage.write(g.sourcePath, g.content); + await runtime.hooks.fire("onPageUpdate", { sourcePath: relative(g.sourcePath), }); return json({ ok: true }); @@ -252,11 +252,11 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { app.post(`${prefix}/pages/create`, async (fc) => { const g = await guarded(fc, ["title"]); if (g instanceof Response) return g; - await bootstrap.storage.write( + await runtime.storage.write( g.sourcePath, `---\ntitle: ${g.title}\n---\n${g.content}`, ); - await bootstrap.hooks.fire("onPageCreate", { + await runtime.hooks.fire("onPageCreate", { sourcePath: relative(g.sourcePath), title: g.title, }); @@ -266,8 +266,8 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { app.post(`${prefix}/pages/delete`, async (fc) => { const g = await guarded(fc, []); if (g instanceof Response) return g; - await bootstrap.storage.delete(g.sourcePath); - await bootstrap.hooks.fire("onPageDelete", { + await runtime.storage.delete(g.sourcePath); + await runtime.hooks.fire("onPageDelete", { sourcePath: relative(g.sourcePath), }); return json({ ok: true }); From 3a06838192a6dbcec7f237ce96f0d47645bcadc1 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Fri, 6 Nov 2026 23:28:43 +0100 Subject: [PATCH 06/16] feat(conformance)!: read the admin's prefix from the admin.prefix slot The driver's adminPrefix option is gone: core no longer assumes /admin, so an admin must provide the admin.prefix capability, and the suite reads the prefix from it. adminPrefix?: string becomes hostsContributions?: boolean (it only enables the admin.pages / admin.routes checks). New checks: the admin provides admin.prefix, core reserves it against plugin routes, and with no admin /admin is an ordinary path. The stub admin provides admin.prefix. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 11 ++++ README.md | 11 +++- src/conformance.ts | 98 ++++++++++++++++++++++++++++-- src/stub-admin.ts | 1 + tests/conformance_negative_test.ts | 53 ++++++++++++++++ 5 files changed, 165 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b75194..0c6f907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Changed — BREAKING + +- **The conformance driver has no `adminPrefix`.** The admin's URL prefix comes + from the `admin.prefix` capability slot, which an admin must now provide (core + no longer assumes `/admin`). `adminPrefix?: string` is replaced by + `hostsContributions?: boolean`, which only enables the `admin.pages` / + `admin.routes` checks. New checks: the admin provides `admin.prefix` (one + provider, a usable prefix), core reserves that prefix against plugin routes, + and with no admin `/admin` is an ordinary plugin path. The stub admin provides + `admin.prefix` (`/stub-admin` by default). Needs `@dune/core` 0.36 (dune#123). + ### Added - **`runAdminConformance()`** (`@dune/testing/conformance`): the admin diff --git a/README.md b/README.md index d0eb78c..eb5ec1c 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,13 @@ app with your admin, how to sign an editor in) and it registers these tests: - creating a page fires `onPageCreate` exactly once and deleting one fires `onPageDelete` exactly once, each with the path relative to the content directory (`createPage`, `deletePage`); +- the admin provides `admin.prefix` (where it is mounted: one provider, a path + starting with `/` and with no trailing slash), and core reserves that prefix + against plugin routes; - `admin.pages` and `admin.routes` contributed by another plugin are mounted - behind the login, the CSRF check and the permission (`adminPrefix`); -- with no admin at all the site serves and nothing admin-related exists. + behind the login, the CSRF check and the permission (`hostsContributions`); +- with no admin at all the site serves, nothing admin-related exists, and + `/admin` is an ordinary path for plugins. ```ts runAdminConformance({ @@ -134,7 +138,8 @@ runAdminConformance({ ``` The optional hooks (`signInViewer`, `editPage`, `createPage`, `deletePage`, -`adminPrefix`) enable the checks that need them. +`hostsContributions`) enable the checks that need them. The admin's prefix is +not a driver option: the suite reads it from the `admin.prefix` slot. `adminConformanceChecks(driver)` returns the checks without registering them, which is how the package tests that the suite rejects a broken admin. The stub admin (`createStubAdmin()`) is the reference run. diff --git a/src/conformance.ts b/src/conformance.ts index a9d2032..56a0a68 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -104,10 +104,12 @@ export interface AdminConformanceDriver { page: { sourcePath: string }, ): Promise<void>; /** - * Optional: the admin's URL prefix, if it mounts `admin.pages` and - * `admin.routes` contributions from other plugins. Enables those checks. + * Optional: true if the admin mounts `admin.pages` and `admin.routes` + * contributions from other plugins. Enables those checks. The admin's URL + * prefix is not part of the driver: the suite reads it from the + * `admin.prefix` slot the admin must provide. */ - adminPrefix?: string; + hostsContributions?: boolean; } const CONTENT = { @@ -166,6 +168,16 @@ function probe() { return { plugin, seen }; } +/** Build an app just to read the prefix the admin provides. */ +async function withPrefix(driver: AdminConformanceDriver): Promise<string> { + let prefix = ""; + await withApp(driver, {}, (app) => { + prefix = adminPrefixOf(app); + return Promise.resolve(); + }); + return prefix; +} + async function withApp( driver: AdminConformanceDriver, options: Partial<ConformanceAppOptions>, @@ -184,6 +196,13 @@ async function withApp( } } +/** The prefix the admin provides through `admin.prefix`; throws if it provides none. */ +function adminPrefixOf(app: ConformanceApp): string { + const slot = app.bootstrap.capabilities.get("admin.prefix"); + assertExists(slot, "no plugin provides admin.prefix"); + return slot.prefix; +} + const cookieNames = (cookie: string) => cookie.split(";").map((c) => c.trim().split("=")[0]).filter(Boolean); @@ -227,6 +246,48 @@ export function adminConformanceChecks( }), ); + t( + "provides admin.prefix: a path prefix core can reserve, from exactly one provider", + () => + withApp(driver, {}, (app) => { + const providers = app.bootstrap.capabilities.providers(); + const slot = providers.filter((p) => p.name === "admin.prefix"); + assertEquals(slot.length, 1, "exactly one admin.prefix provider"); + assertEquals(slot[0].status, "active"); + const prefix = adminPrefixOf(app); + assertEquals( + prefix.length > 1 && prefix.startsWith("/") && !prefix.endsWith("/"), + true, + `"${prefix}" must start with "/" and have no trailing slash`, + ); + return Promise.resolve(); + }), + ); + + t( + "core reserves the admin prefix: a plugin public route under it is refused", + async () => { + const prefix = await withPrefix(driver); + const squatter: DunePlugin = { + name: "conformance-squatter", + version: "1.0.0", + hooks: {}, + publicRoutes: [{ + path: `${prefix}/__conformance-squat`, + handler: () => new Response("squatted"), + }], + }; + await withApp(driver, { extraPlugins: [squatter] }, async (app) => { + const res = await app.request(`${prefix}/__conformance-squat`); + assertEquals( + (await res.text()).includes("squatted"), + false, + "a plugin route answered inside the admin's prefix", + ); + }); + }, + ); + t( "the public site serves, and anonymous visitors are not admins", () => @@ -476,9 +537,7 @@ export function adminConformanceChecks( ); } - if (driver.adminPrefix) { - const prefix = driver.adminPrefix; - + if (driver.hostsContributions) { t( "admin.pages from another plugin are mounted behind the admin's login", async () => { @@ -496,6 +555,7 @@ export function adminConformanceChecks( }, }; await withApp(driver, { extraPlugins: [contributor] }, async (app) => { + const prefix = adminPrefixOf(app); const cookie = await driver.signInEditor(app); const anonymous = await app.request(`${prefix}/__conformance`); assertEquals( @@ -537,6 +597,7 @@ export function adminConformanceChecks( }, }; await withApp(driver, { extraPlugins: [contributor] }, async (app) => { + const prefix = adminPrefixOf(app); const cookie = await driver.signInEditor(app); const url = `${prefix}/api/__conformance/act`; const post = (headers: Record<string, string>) => @@ -574,11 +635,36 @@ export function adminConformanceChecks( withApp(driver, { withAdmin: false }, async (app) => { assertEquals((await app.request("/")).status, 200); assertEquals(app.bootstrap.capabilities.get("auth.session"), undefined); + assertEquals(app.bootstrap.capabilities.get("admin.prefix"), undefined); assertEquals(app.bootstrap.authz, undefined); assertEquals((await app.request("/api/inline-edit/ws")).status, 501); }), ); + t( + "without the admin nothing is reserved: /admin is an ordinary path for plugins", + () => { + const open: DunePlugin = { + name: "conformance-open", + version: "1.0.0", + hooks: {}, + publicRoutes: [{ + path: "/admin/__conformance-open", + handler: () => new Response("open"), + }], + }; + return withApp( + driver, + { withAdmin: false, extraPlugins: [open] }, + async (app) => { + const res = await app.request("/admin/__conformance-open"); + assertEquals(res.status, 200); + assertEquals(await res.text(), "open"); + }, + ); + }, + ); + return checks; } diff --git a/src/stub-admin.ts b/src/stub-admin.ts index f79b2b2..fe70b81 100644 --- a/src/stub-admin.ts +++ b/src/stub-admin.ts @@ -146,6 +146,7 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { })(); state = { users, sessions: null, passwords: services.passwords, ready }; + capabilities.provide("admin.prefix", () => ({ prefix })); capabilities.provide("auth.session", () => ({ ...(declareCookie ? { sessionCookies: [cookieName] } : {}), authenticate, diff --git a/tests/conformance_negative_test.ts b/tests/conformance_negative_test.ts index 1de0a68..3431719 100644 --- a/tests/conformance_negative_test.ts +++ b/tests/conformance_negative_test.ts @@ -95,6 +95,59 @@ Deno.test("conformance catches an admin that provides no auth.session", async () ); }); +/** Run the stub's setup, passing every `provide` through `rewrite` first. */ +const rewriteProvides = ( + rewrite: (name: string, factory: unknown) => [string, unknown] | null, +): Breaker => +(admin) => ({ + ...admin, + setup(api) { + admin.setup!({ + ...api, + capabilities: { + ...api.capabilities, + provide: (name: string, factory: unknown) => { + const out = rewrite(name, factory); + if (out) { + (api.capabilities.provide as unknown as ( + n: string, + f: unknown, + ) => void)(out[0], out[1]); + } + }, + } as never, + }); + }, +}); + +Deno.test("conformance catches an admin that provides no admin.prefix", async () => { + const noPrefix = rewriteProvides((name, factory) => + name === "admin.prefix" ? null : [name, factory] + ); + await assertRejects( + () => + find(driverWith("no-prefix", noPrefix), "provides admin.prefix").run(), + Error, + "exactly one admin.prefix provider", + ); +}); + +Deno.test("conformance catches an admin whose prefix core cannot reserve", async () => { + for (const bad of ["/stub-admin/", "stub-admin", "/"]) { + const broken = rewriteProvides((name, factory) => + name === "admin.prefix" + ? [name, () => ({ prefix: bad })] + : [name, factory] + ); + await assertRejects( + () => + find(driverWith("bad-prefix", broken), "provides admin.prefix").run(), + Error, + "must start with", + ); + } +}); + Deno.test("conformance catches an admin that declares a cookie its sign-in does not set", async () => { const wrongCookie = replaceSession(() => ({ sessionCookies: ["some_other_cookie"], From 3c3c5db800f29c59f950cdb673ca02eb0376f335 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Fri, 6 Nov 2026 23:34:26 +0100 Subject: [PATCH 07/16] chore: version 2.0.0 (the conformance driver lost adminPrefix) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 2 ++ deno.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6f907..0147e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +Version 2.0.0 (the conformance driver changed incompatibly). + ### Changed — BREAKING - **The conformance driver has no `adminPrefix`.** The admin's URL prefix comes diff --git a/deno.json b/deno.json index c68de32..d22eaa1 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@dune/testing", - "version": "1.0.3", + "version": "2.0.0", "license": "MIT", "exports": { ".": "./src/mod.ts", From 7000678649a1f7758b5f8f7dd011db4a69fd15d1 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Sat, 7 Nov 2026 01:10:14 +0100 Subject: [PATCH 08/16] feat(harness)!: createTestHarness has no disableAdmin option Core no longer loads an admin by itself, so there is nothing to disable; a test that wants an admin lists it in plugins. Part of 2.0.0. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 3 +++ README.md | 13 ++++++------- src/app-harness.ts | 2 +- src/conformance.ts | 2 +- src/harness.ts | 14 ++------------ 5 files changed, 13 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0147e98..09b564a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Version 2.0.0 (the conformance driver changed incompatibly). provider, a usable prefix), core reserves that prefix against plugin routes, and with no admin `/admin` is an ordinary plugin path. The stub admin provides `admin.prefix` (`/stub-admin` by default). Needs `@dune/core` 0.36 (dune#123). +- **`createTestHarness({ disableAdmin })` is gone.** Core no longer loads an + admin by itself, so there is nothing to disable; pass an admin in `plugins` to + test with one. ### Added diff --git a/README.md b/README.md index eb5ec1c..d089845 100644 --- a/README.md +++ b/README.md @@ -146,13 +146,12 @@ admin (`createStubAdmin()`) is the reference run. ### Options -| Option | Default | Description | -| -------------- | ------------------ | -------------------------------------------------------------------- | -| `content` | `{}` | Fixture content keyed by path relative to `content/` | -| `files` | `{}` | Extra files at arbitrary paths (config overrides, flex data, etc.) | -| `plugins` | `[]` | Plugin instances to register before bootstrap | -| `siteTitle` | `"Dune Test Site"` | Site title override | -| `disableAdmin` | `true` | Disable the admin plugin (heavy; skip unless testing admin behavior) | +| Option | Default | Description | +| ----------- | ------------------ | ------------------------------------------------------------------ | +| `content` | `{}` | Fixture content keyed by path relative to `content/` | +| `files` | `{}` | Extra files at arbitrary paths (config overrides, flex data, etc.) | +| `plugins` | `[]` | Plugin instances to register before bootstrap | +| `siteTitle` | `"Dune Test Site"` | Site title override | ## License diff --git a/src/app-harness.ts b/src/app-harness.ts index 1fc5be8..e25a7ee 100644 --- a/src/app-harness.ts +++ b/src/app-harness.ts @@ -40,7 +40,7 @@ export interface AppHarness extends TestHarness { /** * Options for {@link createAppHarness}: those of {@link createTestHarness}, with - * the built-in admin disabled unless `disableAdmin: false`. + * no admin unless the test passes one in `plugins`. */ export type AppHarnessOptions = TestHarnessOptions; diff --git a/src/conformance.ts b/src/conformance.ts index 56a0a68..c770cac 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -57,7 +57,7 @@ export interface ConformanceAppOptions { * is mount order, and the suite relies on the admin mounting after them. */ extraPlugins: DunePlugin[]; - /** False: build the app with no admin at all (the built-in one switched off too). */ + /** False: build the app with no admin at all. */ withAdmin: boolean; } diff --git a/src/harness.ts b/src/harness.ts index 866b681..7ebbe26 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -79,11 +79,6 @@ export interface TestHarnessOptions { * Site title override. Defaults to `"Dune Test Site"`. */ siteTitle?: string; - /** - * When true, disables the built-in admin plugin. - * Defaults to true (admin is heavy; disable unless testing admin behavior). - */ - disableAdmin?: boolean; } /** A fully bootstrapped in-memory Dune instance for testing. */ @@ -153,7 +148,6 @@ export async function createTestHarness( files = {}, plugins = [], siteTitle = "Dune Test Site", - disableAdmin = true, } = options; // ── 1. In-memory storage ──────────────────────────────────────────────────── @@ -189,12 +183,8 @@ export async function createTestHarness( plugins, // registered before any hooks fire buildSearch: true, dev: true, - // Disable admin by default — it tries to create filesystem directories - // (audit log, session store) that don't exist in the test environment. - configOverrides: disableAdmin - // deno-lint-ignore no-explicit-any - ? { admin: { enabled: false } as any } - : undefined, + // No admin: core loads only the plugins passed here, so a test that wants + // one lists it in `plugins`. }); // ── 3. In-process API handler ──────────────────────────────────────────────── From 72ce00370dca0ee37bfad3d0a5e449353c4834fd Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Sat, 7 Nov 2026 16:21:08 +0100 Subject: [PATCH 09/16] fix: the stub admin grants on app:site Follows @dune/core's rename of the permission resource (duneorg/dune#130). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- src/stub-admin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stub-admin.ts b/src/stub-admin.ts index fe70b81..258ee9a 100644 --- a/src/stub-admin.ts +++ b/src/stub-admin.ts @@ -220,7 +220,7 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { who: { type: "user", id: who.user.id }, // deno-lint-ignore no-explicit-any canThey: "pages.update" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }) : false; if (!allowed) return json({ error: "Forbidden" }, 403); From 695b1d81670afa6a73eb1a94986b406813aabfa2 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Sat, 7 Nov 2026 19:33:34 +0100 Subject: [PATCH 10/16] chore: use the editor.session and editor.inline slot names (duneorg/dune#125) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 4 ++-- README.md | 8 ++++---- src/conformance.ts | 22 +++++++++++----------- src/stub-admin.ts | 4 ++-- tests/conformance_negative_test.ts | 12 ++++++------ tests/stub_admin_test.ts | 10 +++++----- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b564a..32b7382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Version 2.0.0 (the conformance driver changed incompatibly). - **`runAdminConformance()`** (`@dune/testing/conformance`): the admin conformance suite. A driver says how to build an app with an admin installed and how to sign an editor in; the suite checks the contract with core (the - `auth.session` capability, session recognition and marker scrubbing, the + `editor.session` capability, session recognition and marker scrubbing, the declared session cookie, mount-order independence, `onPageUpdate` / `onPageCreate` / `onPageDelete` from the writer, `admin.pages` / `admin.routes` mounting, and the site serving with no admin). Also @@ -36,7 +36,7 @@ Version 2.0.0 (the conformance driver changed incompatibly). - **`createStubAdmin()`** (`@dune/testing/stub-admin`): a minimal replacement admin built only on core's injected services, capabilities and mount API. It proves Dune does not depend on `@dune/plugin-admin`: its tests cover login and - sessions, `auth.session` recognised by core's transform pipeline (and only its + sessions, `editor.session` recognised by core's transform pipeline (and only its own cookie), marker scrubbing, a page edit that fires `onPageUpdate`, a role without edit rights, and a site with no admin at all. Needs `@dune/core` 0.36 (duneorg/dune#97); version bump and the `^0.36` pin are release work. diff --git a/README.md b/README.md index d089845..6e4cbba 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Folder pages are served at their canonical path with a trailing slash A minimal replacement admin, built only on the services, capabilities and mount API core hands every plugin (it imports nothing from `@dune/plugin-admin`). It keeps users and sessions through `services.users` and `services.sessions`, -provides the `auth.session` and `scheduler.executor` capabilities, seeds authz +provides the `editor.session` and `scheduler.executor` capabilities, seeds authz tuples through `services.authz`, and offers its own `POST /stub-admin/login`, `GET /stub-admin/pages` and `POST /stub-admin/pages/edit` (which, as the writer, fires `onPageUpdate`). Use it to check that your plugin works with an admin that @@ -98,11 +98,11 @@ const h = await createAppHarness({ ### `runAdminConformance(driver)` What "meets the contract" means for an admin. Dune does not depend on -`@dune/plugin-admin`: any plugin that provides the `auth.session` capability is +`@dune/plugin-admin`: any plugin that provides the `editor.session` capability is the admin as far as core is concerned. Give the suite a driver (how to build an app with your admin, how to sign an editor in) and it registers these tests: -- the admin provides `auth.session` (exactly one provider, active); +- the admin provides `editor.session` (exactly one provider, active); - the public site serves, and anonymous visitors are not admins; - core's response pipeline recognises an editor session and **only** a real one: no cookie, an unrelated cookie and a forged session cookie get no auth and @@ -113,7 +113,7 @@ app with your admin, how to sign an editor in) and it registers these tests: shared cache); - an account without editing rights gets no editing chrome (`signInViewer`); - a route in a plugin mounted before the admin can still tell who the admin is - through `auth.session`; + through `editor.session`; - editing a page through the admin writes it and fires `onPageUpdate` exactly once (`editPage`); - creating a page fires `onPageCreate` exactly once and deleting one fires diff --git a/src/conformance.ts b/src/conformance.ts index c770cac..95f7f2d 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -3,7 +3,7 @@ * core, without knowing anything else about it. * * Dune does not depend on `@dune/plugin-admin`: any plugin that provides the - * `auth.session` capability is "the admin" as far as core is concerned. This + * `editor.session` capability is "the admin" as far as core is concerned. This * suite is what "meets the contract" means. Hand it a *driver* that says how to * build an app with your admin installed and how to sign an editor in; it * registers the tests. @@ -226,21 +226,21 @@ export function adminConformanceChecks( checks.push({ name: `conformance [${driver.name}]: ${name}`, run }); t( - "provides auth.session, the capability that makes it the admin", + "provides editor.session, the capability that makes it the admin", () => withApp(driver, {}, (app) => { const providers = app.bootstrap.capabilities.providers(); - const session = providers.find((p) => p.name === "auth.session"); - assertExists(session, "no plugin provides auth.session"); + const session = providers.find((p) => p.name === "editor.session"); + assertExists(session, "no plugin provides editor.session"); assertEquals( session.status, "active", - "the auth.session factory did not produce a provider", + "the editor.session factory did not produce a provider", ); assertEquals( - providers.filter((p) => p.name === "auth.session").length, + providers.filter((p) => p.name === "editor.session").length, 1, - "exactly one auth.session provider", + "exactly one editor.session provider", ); return Promise.resolve(); }), @@ -296,7 +296,7 @@ export function adminConformanceChecks( assertEquals(home.status, 200); assertStringIncludes(await home.text(), "<title>Home"); - const auth = app.bootstrap.capabilities.get("auth.session")!; + const auth = app.bootstrap.capabilities.get("editor.session")!; const anonymous = await auth.authenticate( new Request("http://localhost:8000/"), ); @@ -358,7 +358,7 @@ export function adminConformanceChecks( "the cookie it declares is the cookie its sign-in sets", () => withApp(driver, {}, async (app) => { - const declared = app.bootstrap.capabilities.get("auth.session") + const declared = app.bootstrap.capabilities.get("editor.session") ?.sessionCookies; if (!declared) return; // undeclared is allowed: core then tries every request that has cookies const cookie = await driver.signInEditor(app); @@ -407,7 +407,7 @@ export function adminConformanceChecks( hooks: {}, mount({ app, capabilities }) { app.get("/__conformance/who", async (fc) => { - const result = await capabilities.get("auth.session")?.authenticate( + const result = await capabilities.get("editor.session")?.authenticate( fc.req, ); return new Response(result?.authenticated ? "admin" : "anonymous"); @@ -634,7 +634,7 @@ export function adminConformanceChecks( () => withApp(driver, { withAdmin: false }, async (app) => { assertEquals((await app.request("/")).status, 200); - assertEquals(app.bootstrap.capabilities.get("auth.session"), undefined); + assertEquals(app.bootstrap.capabilities.get("editor.session"), undefined); assertEquals(app.bootstrap.capabilities.get("admin.prefix"), undefined); assertEquals(app.bootstrap.authz, undefined); assertEquals((await app.request("/api/inline-edit/ws")).status, 501); diff --git a/src/stub-admin.ts b/src/stub-admin.ts index 258ee9a..772c51f 100644 --- a/src/stub-admin.ts +++ b/src/stub-admin.ts @@ -4,7 +4,7 @@ * * It implements just enough of an admin to exercise the plugin contract: it * authenticates users and keeps sessions through core's injected services, - * provides the `auth.session` and `scheduler.executor` capabilities, seeds + * provides the `editor.session` and `scheduler.executor` capabilities, seeds * authorisation tuples through `services.authz`, and offers a login, a page * listing and a page edit route of its own. It imports nothing from * `@dune/plugin-admin`; everything it needs arrives through `PluginApi` and @@ -147,7 +147,7 @@ export function createStubAdmin(options: StubAdminOptions = {}): StubAdmin { state = { users, sessions: null, passwords: services.passwords, ready }; capabilities.provide("admin.prefix", () => ({ prefix })); - capabilities.provide("auth.session", () => ({ + capabilities.provide("editor.session", () => ({ ...(declareCookie ? { sessionCookies: [cookieName] } : {}), authenticate, })); diff --git a/tests/conformance_negative_test.ts b/tests/conformance_negative_test.ts index 3431719..a8cdab5 100644 --- a/tests/conformance_negative_test.ts +++ b/tests/conformance_negative_test.ts @@ -74,7 +74,7 @@ const find = (driver: AdminConformanceDriver, fragment: string) => { return check; }; -/** Run the stub's own setup, then replace `auth.session` with `override`. */ +/** Run the stub's own setup, then replace `editor.session` with `override`. */ const replaceSession = (override: (cookieName: string) => unknown): Breaker => (admin, { cookieName }) => ({ @@ -82,16 +82,16 @@ const replaceSession = setup(api) { admin.setup!(api); api.capabilities.provide( - "auth.session", + "editor.session", () => override(cookieName) as never, ); }, }); -Deno.test("conformance catches an admin that provides no auth.session", async () => { +Deno.test("conformance catches an admin that provides no editor.session", async () => { const bare: Breaker = (admin) => ({ ...admin, setup: () => {} }); await assertRejects(() => - find(driverWith("no-session", bare), "provides auth.session").run() + find(driverWith("no-session", bare), "provides editor.session").run() ); }); @@ -177,7 +177,7 @@ Deno.test("conformance catches an admin that accepts a forged session as the rea capabilities: { ...api.capabilities, provide: (name: string, factory: unknown) => { - if (name === "auth.session") { + if (name === "editor.session") { real = (factory as (ctx: unknown) => typeof real)({}); } else { (api.capabilities.provide as unknown as ( @@ -189,7 +189,7 @@ Deno.test("conformance catches an admin that accepts a forged session as the rea } as never, }); let last: unknown; - api.capabilities.provide("auth.session", () => ({ + api.capabilities.provide("editor.session", () => ({ authenticate: async (req: Request) => { const r = await real.authenticate(req); if (r.authenticated) { diff --git a/tests/stub_admin_test.ts b/tests/stub_admin_test.ts index d74b089..6c4d4f4 100644 --- a/tests/stub_admin_test.ts +++ b/tests/stub_admin_test.ts @@ -74,12 +74,12 @@ Deno.test("stub admin: the public site serves with it installed, and core sees w const providers = Object.fromEntries( h.bootstrap.capabilities.providers().map((p) => [p.name, p]), ); - assertEquals(providers["auth.session"].provider, "stub-admin"); - assertEquals(providers["auth.session"].status, "active"); + assertEquals(providers["editor.session"].provider, "stub-admin"); + assertEquals(providers["editor.session"].status, "active"); assertEquals(providers["scheduler.executor"].status, "active"); assertExists( h.bootstrap.authz, - "core creates authz for any auth.session provider, with admin.enabled false", + "core creates authz for any editor.session provider, with admin.enabled false", ); } finally { await h.dispose(); @@ -186,7 +186,7 @@ Deno.test("stub admin: a provider that does not declare its cookie is still foun }); try { assertEquals( - h.bootstrap.capabilities.get("auth.session")?.sessionCookies, + h.bootstrap.capabilities.get("editor.session")?.sessionCookies, undefined, ); const { cookie } = await login(h, admin.prefix, "ed", "pw"); @@ -275,7 +275,7 @@ Deno.test("without any admin the site still serves, and nothing admin-related ex const h = await createAppHarness({ content }); try { assertEquals((await h.request("/")).status, 200); - assertEquals(h.bootstrap.capabilities.get("auth.session"), undefined); + assertEquals(h.bootstrap.capabilities.get("editor.session"), undefined); assertEquals(h.bootstrap.authz, undefined); assertEquals((await h.request("/stub-admin/pages")).status, 404); assertEquals((await h.request("/api/inline-edit/ws")).status, 501); From 14a0a7886bc6fbbbc9614c188346f5ce6d9a4a64 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Sat, 7 Nov 2026 18:10:43 +0100 Subject: [PATCH 11/16] feat!: admin.pages / admin.routes are the admin plugin's extension points, not core's (#127) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 4 ++++ src/conformance.ts | 20 +++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b7382..39d4ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Version 2.0.0 (the conformance driver changed incompatibly). ### Changed — BREAKING +- **`admin.pages` / `admin.routes` are no longer core's keys (#127).** They are the extension + points `@dune/plugin-admin` declares, so `hostsContributions` now means "this admin hosts the + admin plugin's extension points" and the suite reaches them through an untyped view of the + registry instead of depending on one admin's types. - **The conformance driver has no `adminPrefix`.** The admin's URL prefix comes from the `admin.prefix` capability slot, which an admin must now provide (core no longer assumes `/admin`). `adminPrefix?: string` is replaced by diff --git a/src/conformance.ts b/src/conformance.ts index 95f7f2d..382852f 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -37,6 +37,17 @@ import type { BootstrapResult } from "@dune/core/bootstrap"; import type { DunePlugin } from "@dune/core/hooks"; /** What the suite needs from a running app. */ + +/** + * The registry is keyed by string at runtime; `admin.pages` and `admin.routes` + * are declared by whichever admin plugin is installed, not by core. The suite + * checks the protocol, so it calls them through this untyped view instead of + * depending on one admin's types. + */ +function untyped(registrar: unknown): { contribute(point: string, item: unknown): void } { + return registrar as { contribute(point: string, item: unknown): void }; +} + export interface ConformanceApp { /** Send an in-process request through the whole app. */ request(path: string, init?: RequestInit): Promise<Response>; @@ -105,7 +116,10 @@ export interface AdminConformanceDriver { ): Promise<void>; /** * Optional: true if the admin mounts `admin.pages` and `admin.routes` - * contributions from other plugins. Enables those checks. The admin's URL + * contributions from other plugins, the extension points + * `@dune/plugin-admin` declares (they are the admin's convention, not part of + * core's contract, so an alternative admin chooses whether to host them). + * Enables those checks. The admin's URL * prefix is not part of the driver: the suite reads it from the * `admin.prefix` slot the admin must provide. */ @@ -546,7 +560,7 @@ export function adminConformanceChecks( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.pages", { + untyped(api.capabilities).contribute("admin.pages", { path: "/__conformance", label: "Conformance", permission: "pages.read", @@ -585,7 +599,7 @@ export function adminConformanceChecks( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.routes", { + untyped(api.capabilities).contribute("admin.routes", { method: "POST", path: "/api/__conformance/act", permission: "pages.update", From 4527f8b5428ccb665a3caf1da102a5772cad06dd Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Sun, 8 Nov 2026 11:18:59 +0100 Subject: [PATCH 12/16] feat!: export extension points as typed capability keys, not module augmentation (duneorg/dune#127) JSR rejects 'declare module' in a published package. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- src/conformance.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/conformance.ts b/src/conformance.ts index 382852f..f2d08bf 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -35,18 +35,18 @@ import { assertEquals, assertExists, assertStringIncludes } from "@std/assert"; import type { BootstrapResult } from "@dune/core/bootstrap"; import type { DunePlugin } from "@dune/core/hooks"; +import { type CapabilityKey, capabilityKey } from "@dune/core/plugin-api"; /** What the suite needs from a running app. */ /** - * The registry is keyed by string at runtime; `admin.pages` and `admin.routes` - * are declared by whichever admin plugin is installed, not by core. The suite - * checks the protocol, so it calls them through this untyped view instead of - * depending on one admin's types. + * `admin.pages` and `admin.routes` are declared by whichever admin plugin is + * installed, not by core. The suite checks the protocol, so it recreates the + * keys from their documented names (one name is one capability) instead of + * depending on one admin's package. */ -function untyped(registrar: unknown): { contribute(point: string, item: unknown): void } { - return registrar as { contribute(point: string, item: unknown): void }; -} +const ADMIN_PAGES: CapabilityKey<unknown> = capabilityKey("admin.pages"); +const ADMIN_ROUTES: CapabilityKey<unknown> = capabilityKey("admin.routes"); export interface ConformanceApp { /** Send an in-process request through the whole app. */ @@ -560,7 +560,7 @@ export function adminConformanceChecks( version: "1.0.0", hooks: {}, setup(api) { - untyped(api.capabilities).contribute("admin.pages", { + api.capabilities.contribute(ADMIN_PAGES, { path: "/__conformance", label: "Conformance", permission: "pages.read", @@ -599,7 +599,7 @@ export function adminConformanceChecks( version: "1.0.0", hooks: {}, setup(api) { - untyped(api.capabilities).contribute("admin.routes", { + api.capabilities.contribute(ADMIN_ROUTES, { method: "POST", path: "/api/__conformance/act", permission: "pages.update", From 42ddd7cfef800f7d34c31f2a159b482bff3c5ce8 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Tue, 10 Nov 2026 16:35:38 +0100 Subject: [PATCH 13/16] style: deno fmt (CHANGELOG, README, conformance) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 8 ++++---- README.md | 6 +++--- src/conformance.ts | 12 ++++++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b7382..80d416d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,10 +36,10 @@ Version 2.0.0 (the conformance driver changed incompatibly). - **`createStubAdmin()`** (`@dune/testing/stub-admin`): a minimal replacement admin built only on core's injected services, capabilities and mount API. It proves Dune does not depend on `@dune/plugin-admin`: its tests cover login and - sessions, `editor.session` recognised by core's transform pipeline (and only its - own cookie), marker scrubbing, a page edit that fires `onPageUpdate`, a role - without edit rights, and a site with no admin at all. Needs `@dune/core` 0.36 - (duneorg/dune#97); version bump and the `^0.36` pin are release work. + sessions, `editor.session` recognised by core's transform pipeline (and only + its own cookie), marker scrubbing, a page edit that fires `onPageUpdate`, a + role without edit rights, and a site with no admin at all. Needs `@dune/core` + 0.36 (duneorg/dune#97); version bump and the `^0.36` pin are release work. ## [1.0.3] — 2026-10-28 diff --git a/README.md b/README.md index 6e4cbba..c27578a 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,9 @@ const h = await createAppHarness({ ### `runAdminConformance(driver)` What "meets the contract" means for an admin. Dune does not depend on -`@dune/plugin-admin`: any plugin that provides the `editor.session` capability is -the admin as far as core is concerned. Give the suite a driver (how to build an -app with your admin, how to sign an editor in) and it registers these tests: +`@dune/plugin-admin`: any plugin that provides the `editor.session` capability +is the admin as far as core is concerned. Give the suite a driver (how to build +an app with your admin, how to sign an editor in) and it registers these tests: - the admin provides `editor.session` (exactly one provider, active); - the public site serves, and anonymous visitors are not admins; diff --git a/src/conformance.ts b/src/conformance.ts index 95f7f2d..1c25b16 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -407,9 +407,10 @@ export function adminConformanceChecks( hooks: {}, mount({ app, capabilities }) { app.get("/__conformance/who", async (fc) => { - const result = await capabilities.get("editor.session")?.authenticate( - fc.req, - ); + const result = await capabilities.get("editor.session") + ?.authenticate( + fc.req, + ); return new Response(result?.authenticated ? "admin" : "anonymous"); }); }, @@ -634,7 +635,10 @@ export function adminConformanceChecks( () => withApp(driver, { withAdmin: false }, async (app) => { assertEquals((await app.request("/")).status, 200); - assertEquals(app.bootstrap.capabilities.get("editor.session"), undefined); + assertEquals( + app.bootstrap.capabilities.get("editor.session"), + undefined, + ); assertEquals(app.bootstrap.capabilities.get("admin.prefix"), undefined); assertEquals(app.bootstrap.authz, undefined); assertEquals((await app.request("/api/inline-edit/ws")).status, 501); From d343527259ab11f0ef2672f762d102ec69bb9cd9 Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Tue, 10 Nov 2026 16:35:41 +0100 Subject: [PATCH 14/16] style: deno fmt the changelog Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9146b1..646b173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ Version 2.0.0 (the conformance driver changed incompatibly). ### Changed — BREAKING -- **`admin.pages` / `admin.routes` are no longer core's keys (#127).** They are the extension - points `@dune/plugin-admin` declares, so `hostsContributions` now means "this admin hosts the - admin plugin's extension points" and the suite reaches them through an untyped view of the - registry instead of depending on one admin's types. +- **`admin.pages` / `admin.routes` are no longer core's keys (#127).** They are + the extension points `@dune/plugin-admin` declares, so `hostsContributions` + now means "this admin hosts the admin plugin's extension points" and the suite + reaches them through an untyped view of the registry instead of depending on + one admin's types. - **The conformance driver has no `adminPrefix`.** The admin's URL prefix comes from the `admin.prefix` capability slot, which an admin must now provide (core no longer assumes `/admin`). `adminPrefix?: string` is replaced by From ed7d5721ce29f73ee4b97e920c0878f0a08c283f Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Tue, 10 Nov 2026 22:50:27 +0100 Subject: [PATCH 15/16] chore: require @dune/core ^0.36 0.36.0 is on JSR; the harness, the stub admin and the conformance suite are written against its contract. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- deno.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/deno.json b/deno.json index c68de32..a14384e 100644 --- a/deno.json +++ b/deno.json @@ -12,17 +12,17 @@ "imports": { "@std/assert": "jsr:@std/assert@^1", "@std/yaml": "jsr:@std/yaml@^1", - "@dune/core": "jsr:@dune/core@^0.35", - "@dune/core/storage": "jsr:@dune/core@^0.35/storage", - "@dune/core/api": "jsr:@dune/core@^0.35/api", - "@dune/core/bootstrap": "jsr:@dune/core@^0.35/bootstrap", - "@dune/core/engine": "jsr:@dune/core@^0.35/engine", - "@dune/core/hooks": "jsr:@dune/core@^0.35/hooks", - "@dune/core/search": "jsr:@dune/core@^0.35/search", - "@dune/core/config": "jsr:@dune/core@^0.35/config", - "@dune/core/fresh-app": "jsr:@dune/core@^0.35/fresh-app", - "@dune/core/workflow": "jsr:@dune/core@^0.35/workflow", - "@dune/core/plugin-api": "jsr:@dune/core@^0.35/plugin-api" + "@dune/core": "jsr:@dune/core@^0.36", + "@dune/core/storage": "jsr:@dune/core@^0.36/storage", + "@dune/core/api": "jsr:@dune/core@^0.36/api", + "@dune/core/bootstrap": "jsr:@dune/core@^0.36/bootstrap", + "@dune/core/engine": "jsr:@dune/core@^0.36/engine", + "@dune/core/hooks": "jsr:@dune/core@^0.36/hooks", + "@dune/core/search": "jsr:@dune/core@^0.36/search", + "@dune/core/config": "jsr:@dune/core@^0.36/config", + "@dune/core/fresh-app": "jsr:@dune/core@^0.36/fresh-app", + "@dune/core/workflow": "jsr:@dune/core@^0.36/workflow", + "@dune/core/plugin-api": "jsr:@dune/core@^0.36/plugin-api" }, "minimumDependencyAge": { "age": "P1D", From 1217d78114fc78e574df0c10f075ecfe8a16effb Mon Sep 17 00:00:00 2001 From: zumbrunn <chris@zumbrunn.com> Date: Tue, 10 Nov 2026 22:52:39 +0100 Subject: [PATCH 16/16] docs: date the 2.0.0 changelog entry as the release Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 646b173..772d002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## [Unreleased] -Version 2.0.0 (the conformance driver changed incompatibly). +## [2.0.0] — 2026-11-10 + +The conformance driver changed incompatibly. ### Changed — BREAKING