diff --git a/CHANGELOG.md b/CHANGELOG.md index 9073430..3b75194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [Unreleased] + +### 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` / + `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. +- **`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..d0eb78c 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,87 @@ 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()], +}); +``` + +### `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`); +- 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. + +```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`, `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 | Option | Default | Description | diff --git a/deno.json b/deno.json index 01bd8ec..a14384e 100644 --- a/deno.json +++ b/deno.json @@ -4,19 +4,25 @@ "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", + "./conformance": "./src/conformance.ts" }, "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": "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", 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/conformance.ts b/src/conformance.ts new file mode 100644 index 0000000..a9d2032 --- /dev/null +++ b/src/conformance.ts @@ -0,0 +1,595 @@ +/** + * 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: 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; + /** + * 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; + /** + * 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"; + +/** + * 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 { + 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; + 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", + ); + await settle(updates); + assertEquals( + updates.length, + 1, + "the writer fires onPageUpdate, once", + ); + }); + }, + ); + } + + 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; + + 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 c7d6880..dc56379 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -35,3 +35,6 @@ // 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 +export * from "./conformance.ts"; // runAdminConformance, AdminConformanceDriver, ConformanceApp, ConformanceAppOptions diff --git a/src/stub-admin.ts b/src/stub-admin.ts new file mode 100644 index 0000000..f79b2b2 --- /dev/null +++ b/src/stub-admin.ts @@ -0,0 +1,279 @@ +/** + * 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<PluginServices["users"]["createLocalStore"]>; +type SessionManager = Awaited< + ReturnType<PluginServices["sessions"]["createManager"]> +>; + +/** 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<void>; + } | 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, runtime }) { + 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: runtime.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. Events + // carry the path relative to the content directory, as core reports it. + const contentDir = runtime.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); + } + 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, + 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") { + return json({ error: "sourcePath is required" }, 400); + } + 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 runtime.storage.write(g.sourcePath, g.content); + await runtime.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 runtime.storage.write( + g.sourcePath, + `---\ntitle: ${g.title}\n---\n${g.content}`, + ); + await runtime.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 runtime.storage.delete(g.sourcePath); + await runtime.hooks.fire("onPageDelete", { + sourcePath: relative(g.sourcePath), + }); + return json({ ok: true }); + }); + }, + }; + + return { plugin, executedActions, cookieName, prefix }; +} diff --git a/tests/conformance_negative_test.ts b/tests/conformance_negative_test.ts new file mode 100644 index 0000000..1de0a68 --- /dev/null +++ b/tests/conformance_negative_test.ts @@ -0,0 +1,211 @@ +/** + * 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() + ); +}); + +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 new file mode 100644 index 0000000..1314cf6 --- /dev/null +++ b/tests/conformance_stub_test.ts @@ -0,0 +1,85 @@ +/** + * 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}`); + }, + 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 new file mode 100644 index 0000000..d74b089 --- /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("</body>", `<p data-dune-field="probe">m</p></body>`), + { + status: response.status, + headers: response.headers, + }, + ); + }, + }; + return { plugin, seen }; +} + +async function login( + h: Awaited<ReturnType<typeof createAppHarness>>, + 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(), + "<title>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: "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(); + } +});