From 37eb1d541342eb5642560f1cd734a21b2565869b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:47:53 +0100 Subject: [PATCH 1/3] fix(admin): every route that writes a page fires its event (#97) Restore, staging publish, translate x2, dev apply, reorder (new onPageMove) and collab auto-save wrote pages and fired nothing. They now share announcePageEvent(), which fires the hook and the content webhooks. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 ++ mod.ts | 1 + src/admin/page-events.ts | 39 +++++ src/admin/routes/api/dev/apply.ts | 10 ++ src/admin/routes/api/history/[...rest].ts | 2 + src/admin/routes/api/i18n/translate-page.ts | 2 + src/admin/routes/api/pages/reorder.ts | 7 + src/admin/routes/api/pages/translate.ts | 2 + .../routes/api/staging/[...path]/publish.ts | 2 + src/collab/manager.ts | 9 +- src/collab/types.ts | 5 + tests/admin/page_events_test.ts | 143 ++++++++++++++++++ 12 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/admin/page-events.ts create mode 100644 tests/admin/page_events_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cafa1..745a31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +### Fixed + +- **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core + does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins + missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate + routes (`onPageCreate`), the dev apply endpoint (create / update / delete), reorder (new `onPageMove`, + which lists every renamed folder) and collab auto-save (`onPageUpdate`, only when the save changed the file). + The routes share `announcePageEvent()`, which fires the hook and the configured content webhooks, so + `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). + `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. + ### Added - **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks diff --git a/mod.ts b/mod.ts index c51b1f6..b4e7943 100644 --- a/mod.ts +++ b/mod.ts @@ -254,6 +254,7 @@ export function createAdminPlugin( history: bootstrap.history, contentDir: config.system.content.dir, security: services.security, + hooks: bootstrap.hooks, }); // Core accepts and stores public form submissions; the admin only diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts new file mode 100644 index 0000000..31e27b3 --- /dev/null +++ b/src/admin/page-events.ts @@ -0,0 +1,39 @@ +/** + * Announce a page write to plugins and content webhooks. + * + * Whoever writes a page fires its event: core never does it for the writer, and + * search indexes, webhooks and audit plugins only learn of a write through it. + * Every admin route that creates, rewrites, deletes or moves a page calls this + * after the file is on disk. Fire-and-forget: a failing handler or an + * unreachable webhook never fails the request. + * + * @module + */ + +import type { FreshContext } from "fresh"; +import type { AdminState } from "./types.ts"; +import { fireContentWebhooks } from "./webhooks.ts"; + +/** Paths are relative to the content directory. */ +export type PageEvent = + | { event: "onPageCreate"; payload: { sourcePath: string; title: string } } + | { event: "onPageUpdate"; payload: { sourcePath: string } } + | { event: "onPageDelete"; payload: { sourcePath: string } } + | { event: "onPageMove"; payload: { moves: Array<{ from: string; to: string }> } }; + +/** Fire a page event to hook handlers and to the configured content webhooks. */ +export function announcePageEvent(ctx: FreshContext, e: PageEvent): void { + const { hooks, config, services } = ctx.state.adminContext; + if (hooks) { + hooks.fire(e.event, e.payload).catch((err: unknown) => + console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) + ); + } + fireContentWebhooks( + config.admin?.webhooks ?? [], + e.event, + e.payload, + config.admin?.runtime_dir ?? ".dune/admin", + services.security, + ); +} diff --git a/src/admin/routes/api/dev/apply.ts b/src/admin/routes/api/dev/apply.ts index 61455a2..777db57 100644 --- a/src/admin/routes/api/dev/apply.ts +++ b/src/admin/routes/api/dev/apply.ts @@ -41,6 +41,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../../../types.ts"; import { json, requirePermission, requireTsxWrite, requireOwnedPage, csrfCheck, validatePagePath } from "../_utils.ts"; import { ValidationError } from "../../../http.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import { join } from "@std/path"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; @@ -482,10 +483,19 @@ export const handler = { try { if (change.op === "delete") { await Deno.remove(absPath); + announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: change.path ?? "" } }); results.push({ op: change.op, path: change.path, status: "deleted", errors: [] }); } else { const existed = await Deno.stat(absPath).then(() => true).catch(() => false); await applyWrite(contentDir, change as Change & { op: "write" | "frontmatter"; path: string }); + const sourcePath = change.path ?? ""; + const title = change.op === "frontmatter" ? change.patch?.title : undefined; + announcePageEvent( + ctx, + existed + ? { event: "onPageUpdate", payload: { sourcePath } } + : { event: "onPageCreate", payload: { sourcePath, title: typeof title === "string" ? title : "" } }, + ); results.push({ op: change.op, path: change.path, diff --git a/src/admin/routes/api/history/[...rest].ts b/src/admin/routes/api/history/[...rest].ts index d2810f6..44508b7 100644 --- a/src/admin/routes/api/history/[...rest].ts +++ b/src/admin/routes/api/history/[...rest].ts @@ -13,6 +13,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -108,6 +109,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fullContent)); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ restored: true, revision: revNum }); } catch (err) { diff --git a/src/admin/routes/api/i18n/translate-page.ts b/src/admin/routes/api/i18n/translate-page.ts index f46bb96..8c5ad2d 100644 --- a/src/admin/routes/api/i18n/translate-page.ts +++ b/src/admin/routes/api/i18n/translate-page.ts @@ -2,6 +2,7 @@ import type { AdminState } from "../../../types.ts"; import { json, serverError, csrfCheck, requirePermission, requireTsxWrite, requireOwnedPage, validatePagePath } from "../_utils.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; function splitFrontmatter(content: string): { fm: string; body: string } { @@ -95,6 +96,7 @@ export const handler = { } await storage.write(`${contentDir}/${targetPath}`, new TextEncoder().encode(translatedFm + translatedBody)); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); engine.rebuild().catch((err: unknown) => console.error("[dune] MT translate-page rebuild error:", err)); return json({ ok: true, targetPath }); diff --git a/src/admin/routes/api/pages/reorder.ts b/src/admin/routes/api/pages/reorder.ts index fbb8ad8..a2a3107 100644 --- a/src/admin/routes/api/pages/reorder.ts +++ b/src/admin/routes/api/pages/reorder.ts @@ -3,6 +3,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireEditorOrAdmin, json, serverError, validatePagePath, csrfCheck } from "../_utils.ts"; import { dirname, basename } from "@std/path"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -83,6 +84,12 @@ export const handler = { } await engine.rebuild(); + // Content-relative folders that were renamed; every page below `from` moved below `to`. + const rel = (p: string) => p.startsWith(`${contentDir}/`) ? p.slice(contentDir.length + 1) : p; + announcePageEvent(ctx, { + event: "onPageMove", + payload: { moves: renames.map((r) => ({ from: rel(r.oldDir), to: rel(r.newDir) })) }, + }); return json({ reordered: true }); } catch (err) { return serverError(err); diff --git a/src/admin/routes/api/pages/translate.ts b/src/admin/routes/api/pages/translate.ts index 6b6d7fd..b1c6d93 100644 --- a/src/admin/routes/api/pages/translate.ts +++ b/src/admin/routes/api/pages/translate.ts @@ -4,6 +4,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { dirname, basename } from "@std/path"; import { parseContentFilename } from "@dune/core/content/path-utils"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -48,6 +49,7 @@ export const handler = { const sourceBytes = await storage.read(`${contentDir}/${pageIndex.sourcePath}`); await storage.write(`${contentDir}/${targetPath}`, sourceBytes); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); await engine.rebuild(); return json({ created: true, path: targetPath }); diff --git a/src/admin/routes/api/staging/[...path]/publish.ts b/src/admin/routes/api/staging/[...path]/publish.ts index 1d55baa..19a5660 100644 --- a/src/admin/routes/api/staging/[...path]/publish.ts +++ b/src/admin/routes/api/staging/[...path]/publish.ts @@ -7,6 +7,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../../page-events.ts"; import type { FreshContext } from "fresh"; async function maybeGitCommit( @@ -68,6 +69,7 @@ export const handler = { await maybeGitCommit(filePath, pagePath, authResult.user?.name, config.admin?.git_commit); await staging.discard(pagePath); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ published: true, sourcePath: pagePath }); } catch (err) { diff --git a/src/collab/manager.ts b/src/collab/manager.ts index b454a63..0e309dc 100644 --- a/src/collab/manager.ts +++ b/src/collab/manager.ts @@ -111,7 +111,7 @@ function takeToken(rate: ConnectionRate): boolean { } export function createCollabManager(options: CollabManagerOptions): CollabManager { - const { storage, engine, history, contentDir, security } = options; + const { storage, engine, history, contentDir, security, hooks } = options; // docId → CollabSessionState const sessions = new Map(); @@ -194,6 +194,13 @@ export function createCollabManager(options: CollabManagerOptions): CollabManage return; } + // Whoever writes a page fires its event. Skip a save that changed nothing. + if (hooks && updated !== raw) { + hooks.fire("onPageUpdate", { sourcePath: session.docId }).catch((err: unknown) => + console.warn(`[collab] onPageUpdate hook failed for ${session.docId}:`, err) + ); + } + if (history && session.lastEditor) { const fm = parseFrontmatter(raw, security.parseUserYaml); history.record({ diff --git a/src/collab/types.ts b/src/collab/types.ts index f6fe8a2..ceef965 100644 --- a/src/collab/types.ts +++ b/src/collab/types.ts @@ -110,6 +110,11 @@ export interface CollabManagerOptions { contentDir: string; /** Core's security service (safe YAML parsing of frontmatter). */ security: Pick; + /** + * Hook registry. An auto-save that changes the file fires `onPageUpdate` + * (the save is already debounced, so this is not per keystroke). + */ + hooks?: Pick; } /** The CollabManager public interface exposed to the admin server. */ diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts new file mode 100644 index 0000000..0accdbd --- /dev/null +++ b/tests/admin/page_events_test.ts @@ -0,0 +1,143 @@ +/** + * Every admin route that writes a page announces it: `onPageCreate`, + * `onPageUpdate`, `onPageDelete` and `onPageMove` reach plugins, on a real site + * through the real routes. Whoever writes a page fires its event; core does not + * do it for them, so a route that forgets is invisible to search indexes, + * webhooks and audit plugins. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import type { DunePlugin } from "@dune/core/hooks"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +type Seen = Array<{ event: string; data: unknown }>; + +async function withSite( + run: (api: { + seen: Seen; + history: Awaited>["history"]; + call: (method: string, path: string, body?: unknown) => Promise; + }) => Promise, +): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_admin_events_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Events"\nurl: "http://localhost:8000"\n'); + for (const dir of ["01.alpha", "02.beta"]) { + await Deno.mkdir(join(root, "content", dir), { recursive: true }); + await Deno.writeTextFile(join(root, "content", dir, "default.md"), `---\ntitle: ${dir}\n---\n\nbody\n`); + } + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + + const seen: Seen = []; + const record = (event: string) => ({ data }: { data: unknown }) => { + seen.push({ event, data }); + }; + const recorder: DunePlugin = { + name: "recorder", + version: "1.0.0", + hooks: { + onPageCreate: record("onPageCreate"), + onPageUpdate: record("onPageUpdate"), + onPageDelete: record("onPageDelete"), + onPageMove: record("onPageMove"), + } as DunePlugin["hooks"], + }; + const ctx = await bootstrap(root, { plugins: [recorder] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); + + const login = await req("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username: "ed", password: PASSWORD }).toString(), + }); + const cookie = (login.headers.get("set-cookie") ?? "").match(/dune_session=[^;]+/)?.[0]; + if (!cookie) throw new Error(`login failed: ${login.status}`); + + await run({ + seen, + history: ctx.history, + call: async (method, path, body) => { + const res = await req(path, { + method, + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + // Hooks are fire-and-forget; let them settle. + await new Promise((r) => setTimeout(r, 25)); + return res; + }, + }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test("admin page routes announce create, update, move and delete", async () => { + await withSite(async ({ seen, call }) => { + let res = await call("POST", "/admin/api/pages", { path: "blog", title: "Hello" }); + assertEquals(res.ok, true); + assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello" } }]); + + res = await call("PUT", "/admin/api/pages/blog/default.md", { content: "changed" }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md" } }]); + + // Move 02.beta before 01.alpha: both folders are renumbered. + res = await call("POST", "/admin/api/pages/reorder", { + sourcePath: "02.beta/default.md", + targetPath: "01.alpha/default.md", + position: "before", + }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ + event: "onPageMove", + data: { + moves: [ + { from: "02.beta", to: "01.beta" }, + { from: "01.alpha", to: "02.alpha" }, + ], + }, + }]); + + res = await call("DELETE", "/admin/api/pages/blog/default.md"); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md" } }]); + }); +}); + +Deno.test("history restore announces onPageUpdate", async () => { + await withSite(async ({ seen, history, call }) => { + await history.record({ + sourcePath: "01.alpha/default.md", + content: "an earlier version", + frontmatter: { title: "01.alpha" }, + author: "ed", + message: "seed", + }); + const revisions = await history.getHistory("01.alpha/default.md"); + assertEquals(revisions.length, 1); + + const res = await call("POST", `/admin/api/history/01.alpha/default.md/${revisions[0].number}/restore`); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md" } }]); + }); +}); From 8044a71782e8ad7bd661edf256eea24dfc9c6253 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:49:26 +0100 Subject: [PATCH 2/3] test(admin): conformance driver covers page create and delete (#97) Co-Authored-By: Claude Sonnet 5.5 --- tests/admin/conformance_test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts index dccc990..1b264c0 100644 --- a/tests/admin/conformance_test.ts +++ b/tests/admin/conformance_test.ts @@ -95,4 +95,24 @@ runAdminConformance({ }); if (res.status !== 200) throw new Error(`plugin-admin edit failed: ${res.status} ${await res.text()}`); }, + + async createPage(app, cookie, { sourcePath, title }) { + // The route takes the page folder and adds default.md itself. + const folder = sourcePath.replace(/^content\//, "").replace(/\/default\.md$/, ""); + const res = await app.request("/admin/api/pages", { + method: "POST", + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ path: folder, title }), + }); + if (!res.ok) throw new Error(`plugin-admin create failed: ${res.status} ${await res.text()}`); + }, + + async deletePage(app, cookie, { sourcePath }) { + const relative = sourcePath.replace(/^content\//, ""); + const res = await app.request(`/admin/api/pages/${relative}`, { + method: "DELETE", + headers: { cookie, origin: ORIGIN }, + }); + if (res.status !== 200) throw new Error(`plugin-admin delete failed: ${res.status} ${await res.text()}`); + }, }); From b4a95b1c027859832e1121bb6171e6d65271b4c7 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:51:42 +0100 Subject: [PATCH 3/3] fix(admin): register wildcard sub-routes before the bare wildcard (#18) The manifest generator put /api/staging/:path* ahead of /api/staging/:path*/publish, so publish was unreachable (the upsert handler ran and 500ed). Same for the page comments routes. Routes with more segments after a shared wildcard now sort first. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 +++ scripts/generate-admin-manifest.ts | 8 ++- src/admin/manifest.gen.ts | 24 ++++---- tests/admin/manifest_test.ts | 15 +++++ tests/admin/staging_publish_test.ts | 89 +++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 tests/admin/staging_publish_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 745a31b..d6093e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ follows [Semantic Versioning](https://semver.org). ### Fixed +- **Staging publish and the page-comment routes were unreachable.** The manifest generator registered a bare + wildcard (`/api/staging/:path*`) before routes that extend it (`/api/staging/:path*/publish`), so the + wildcard swallowed them: publishing a staged draft ran the upsert handler and 500ed on an empty body. The + same shadowing hit `/api/pages/:path*/comments`, `.../comments/:id` and `.../comments/:id/resolve`. The + generator now registers the route with more segments after a shared wildcard first. + `tests/admin/staging_publish_test.ts` publishes a staged draft over HTTP and checks the page file. + - **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate diff --git a/scripts/generate-admin-manifest.ts b/scripts/generate-admin-manifest.ts index 61fda6f..73cde8d 100644 --- a/scripts/generate-admin-manifest.ts +++ b/scripts/generate-admin-manifest.ts @@ -57,7 +57,13 @@ function comparePatterns(a: string, b: string): number { const y = sb[i]; if (x === undefined) return -1; if (y === undefined) return 1; - if (x === y) continue; + if (x === y) { + // A shared wildcard swallows everything after it, so the pattern with + // more segments following it (`/:path*/publish`) is the narrower one + // and must be registered first, or the bare `/:path*` shadows it. + if (x.endsWith("*") && sa.length !== sb.length) return sb.length - sa.length; + continue; + } const rank = (s: string) => s.endsWith("*") ? 2 : s.startsWith(":") ? 1 : 0; const rx = rank(x); const ry = rank(y); diff --git a/src/admin/manifest.gen.ts b/src/admin/manifest.gen.ts index 5c81817..13d86fa 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -40,10 +40,10 @@ import * as r33 from "./routes/api/page-source.ts"; import * as r34 from "./routes/api/pages/index.ts"; import * as r35 from "./routes/api/pages/reorder.ts"; import * as r36 from "./routes/api/pages/translate.ts"; -import * as r37 from "./routes/api/pages/[...path]/index.ts"; -import * as r38 from "./routes/api/pages/[...path]/comments/index.ts"; -import * as r39 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; -import * as r40 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r37 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r38 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; +import * as r39 from "./routes/api/pages/[...path]/comments/index.ts"; +import * as r40 from "./routes/api/pages/[...path]/index.ts"; import * as r41 from "./routes/api/plugins/index.ts"; import * as r42 from "./routes/api/plugins/install.ts"; import * as r43 from "./routes/api/plugins/[name]/config.ts"; @@ -54,8 +54,8 @@ import * as r47 from "./routes/api/render-markdown.ts"; import * as r48 from "./routes/api/search/analytics.ts"; import * as r49 from "./routes/api/search/engines.ts"; import * as r50 from "./routes/api/sections.ts"; -import * as r51 from "./routes/api/staging/[...path]/index.ts"; -import * as r52 from "./routes/api/staging/[...path]/publish.ts"; +import * as r51 from "./routes/api/staging/[...path]/publish.ts"; +import * as r52 from "./routes/api/staging/[...path]/index.ts"; import * as r53 from "./routes/api/theme-preview.ts"; import * as r54 from "./routes/api/themes/install.ts"; import * as r55 from "./routes/api/users/index.ts"; @@ -154,10 +154,10 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/pages", mod: r34 }, { pattern: "/api/pages/reorder", mod: r35 }, { pattern: "/api/pages/translate", mod: r36 }, - { pattern: "/api/pages/:path*", mod: r37 }, - { pattern: "/api/pages/:path*/comments", mod: r38 }, - { pattern: "/api/pages/:path*/comments/:id", mod: r39 }, - { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r40 }, + { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r37 }, + { pattern: "/api/pages/:path*/comments/:id", mod: r38 }, + { pattern: "/api/pages/:path*/comments", mod: r39 }, + { pattern: "/api/pages/:path*", mod: r40 }, { pattern: "/api/plugins", mod: r41 }, { pattern: "/api/plugins/install", mod: r42 }, { pattern: "/api/plugins/:name/config", mod: r43 }, @@ -168,8 +168,8 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/search/analytics", mod: r48 }, { pattern: "/api/search/engines", mod: r49 }, { pattern: "/api/sections", mod: r50 }, - { pattern: "/api/staging/:path*", mod: r51 }, - { pattern: "/api/staging/:path*/publish", mod: r52 }, + { pattern: "/api/staging/:path*/publish", mod: r51 }, + { pattern: "/api/staging/:path*", mod: r52 }, { pattern: "/api/theme-preview", mod: r53 }, { pattern: "/api/themes/install", mod: r54 }, { pattern: "/api/users", mod: r55 }, diff --git a/tests/admin/manifest_test.ts b/tests/admin/manifest_test.ts index 833044c..7f2d49c 100644 --- a/tests/admin/manifest_test.ts +++ b/tests/admin/manifest_test.ts @@ -64,6 +64,21 @@ Deno.test("staging and workflow status/scheduled routes are wildcard, not single } }); +Deno.test("admin manifest registers a wildcard's longer sub-routes before the bare wildcard", () => { + // `/x/:path*` also matches `/x/a/b/publish`, so it has to come after + // `/x/:path*/publish` or the sub-route is unreachable. + const patterns = adminRoutes.map((r) => r.pattern); + const order: Array<[string, string]> = [ + ["/api/staging/:path*/publish", "/api/staging/:path*"], + ["/api/pages/:path*/comments", "/api/pages/:path*"], + ["/api/pages/:path*/comments/:id", "/api/pages/:path*/comments"], + ["/api/pages/:path*/comments/:id/resolve", "/api/pages/:path*/comments/:id"], + ]; + for (const [first, second] of order) { + assertEquals(patterns.indexOf(first) < patterns.indexOf(second), true, `${first} must precede ${second}`); + } +}); + Deno.test("admin manifest orders static segments before dynamic ones", () => { const patterns = adminRoutes.map((r) => r.pattern); // /api/pages/reorder (static) must be registered before /api/pages/:path* diff --git a/tests/admin/staging_publish_test.ts b/tests/admin/staging_publish_test.ts new file mode 100644 index 0000000..881f42b --- /dev/null +++ b/tests/admin/staging_publish_test.ts @@ -0,0 +1,89 @@ +/** + * Publishing a staged draft goes through HTTP: the staging publish route has to + * win over the bare staging wildcard (upsert) route, whose greedy wildcard also + * matches paths ending in /publish. It writes the draft over the page file and + * announces `onPageUpdate`. + */ + +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { join } from "@std/path"; +import type { DunePlugin } from "@dune/core/hooks"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +Deno.test("a staged draft is published through POST /api/staging/:path*/publish", async () => { + const root = await Deno.makeTempDir({ prefix: "dune_admin_staging_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Staging"\nurl: "http://localhost:8000"\n'); + const pageFile = join(root, "content", "01.alpha", "default.md"); + await Deno.mkdir(join(root, "content", "01.alpha"), { recursive: true }); + await Deno.writeTextFile(pageFile, "---\ntitle: Live\n---\n\nlive body\n"); + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + + const updates: unknown[] = []; + const recorder: DunePlugin = { + name: "recorder", + version: "1.0.0", + hooks: { onPageUpdate: ({ data }: { data: unknown }) => void updates.push(data) } as DunePlugin["hooks"], + }; + const ctx = await bootstrap(root, { plugins: [recorder] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); + + const login = await req("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username: "ed", password: PASSWORD }).toString(), + }); + const cookie = (login.headers.get("set-cookie") ?? "").match(/dune_session=[^;]+/)?.[0]; + if (!cookie) throw new Error(`login failed: ${login.status}`); + const call = (method: string, path: string, body?: unknown) => + req(path, { + method, + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const staged = await call("POST", "/admin/api/staging/01.alpha/default.md", { + content: "staged body", + frontmatter: { title: "Staged" }, + }); + assertEquals(staged.status, 200); + await staged.body?.cancel(); + // Staging a draft does not touch the live page. + assertStringIncludes(await Deno.readTextFile(pageFile), "live body"); + + const published = await call("POST", "/admin/api/staging/01.alpha/default.md/publish"); + assertEquals(published.status, 200); + assertEquals(await published.json(), { published: true, sourcePath: "01.alpha/default.md" }); + + const written = await Deno.readTextFile(pageFile); + assertStringIncludes(written, "title: Staged"); + assertStringIncludes(written, "staged body"); + + await new Promise((r) => setTimeout(r, 25)); + assertEquals(updates, [{ sourcePath: "01.alpha/default.md" }]); + + // The draft is consumed. + const after = await call("GET", "/admin/api/staging/01.alpha/default.md"); + assertEquals(await after.json(), { draft: null }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +});