Files
emdash-cms--emdash/packages/core/tests/unit/astro/request-context-toolbar-mode.test.ts
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

182 lines
7.5 KiB
TypeScript

/**
* Tests for the `toolbar` config modes (Discussion #1742).
*
* - `"server"` (default): current behavior — the toolbar is injected
* server-side for authenticated editors.
* - `"client"`: public HTML is identical for everyone (bootstrap script,
* cache headers untouched). The full toolbar is only server-rendered for
* requests carrying the `_edit` param (editors only — everyone else is
* redirected to the canonical URL) or an active edit-mode/preview signal.
* - `false`: no toolbar, no bootstrap.
*
* The middleware reads the mode from `virtual:emdash/config` at module scope,
* so each case loads a fresh module instance via `vi.resetModules()`.
*/
import { describe, it, expect, vi } from "vitest";
type Middleware = (context: unknown, next: () => Promise<Response>) => Promise<Response> | Response;
async function loadMiddleware(toolbar: unknown): Promise<Middleware> {
vi.resetModules();
vi.doMock("astro:middleware", () => ({
defineMiddleware: (handler: unknown) => handler,
}));
vi.doMock("virtual:emdash/config", () => ({ default: { toolbar } }));
const mod = await import("../../../src/astro/middleware/request-context.js");
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- test harness casts the middleware to a plain function
return mod.default as unknown as Middleware;
}
function buildContext(opts: {
pathname?: string;
search?: string;
user?: { id: string; role: number } | null;
editCookie?: boolean;
}) {
const url = new URL(`https://example.com${opts.pathname ?? "/blog"}${opts.search ?? ""}`);
return {
request: new Request(url),
url,
cache: { set: vi.fn() },
cookies: {
get: vi.fn((name: string) =>
name === "emdash-edit-mode" && opts.editCookie ? { value: "true" } : undefined,
),
set: vi.fn(),
},
locals: { user: opts.user ?? null },
};
}
const htmlResponse = () =>
new Response("<html><body>hello</body></html>", {
headers: { "content-type": "text/html" },
});
const EDITOR = { id: "u1", role: 30 };
describe("toolbar: server (default)", () => {
it("injects the toolbar for editors and leaves anonymous HTML untouched", async () => {
const onRequest = await loadMiddleware(undefined);
const editorRes = await onRequest(buildContext({ user: EDITOR }), async () => htmlResponse());
expect(await editorRes.text()).toContain('id="emdash-toolbar"');
expect(editorRes.headers.get("Cache-Control")).toBe("private, no-store");
const anonRes = await onRequest(buildContext({}), async () => htmlResponse());
const anonHtml = await anonRes.text();
expect(anonHtml).not.toContain("emdash-toolbar");
expect(anonRes.headers.get("Cache-Control")).toBeNull();
});
it("ignores the _edit param entirely", async () => {
const onRequest = await loadMiddleware(undefined);
const res = await onRequest(buildContext({ search: "?_edit=1" }), async () => htmlResponse());
expect(res.status).toBe(200);
});
});
describe("toolbar: client", () => {
it("injects the identical bootstrap into anonymous HTML without touching cache headers", async () => {
const onRequest = await loadMiddleware("client");
const context = buildContext({});
const res = await onRequest(context, async () => htmlResponse());
const html = await res.text();
expect(html).toContain("emdash-toolbar-bootstrap");
expect(html).not.toContain('id="emdash-toolbar"');
expect(res.headers.get("Cache-Control")).toBeNull();
// The response stays shareable — no route-cache opt-out.
expect(context.cache.set).not.toHaveBeenCalled();
});
it("serves editors the same bootstrap variant when no _edit param is present", async () => {
const onRequest = await loadMiddleware("client");
const editorRes = await onRequest(buildContext({ user: EDITOR }), async () => htmlResponse());
const anonRes = await onRequest(buildContext({}), async () => htmlResponse());
// Byte-identical to the anonymous variant — this is what keeps shared
// caches serving one entry for everyone.
expect(await editorRes.text()).toBe(await anonRes.text());
expect(editorRes.headers.get("Cache-Control")).toBeNull();
});
it("renders the full server toolbar for editors on _edit requests, uncacheable", async () => {
const onRequest = await loadMiddleware("client");
const context = buildContext({ user: EDITOR, search: "?_edit=1" });
const res = await onRequest(context, async () => htmlResponse());
const html = await res.text();
expect(html).toContain('id="emdash-toolbar"');
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
// _edit responses must never enter the shared route cache.
expect(context.cache.set).toHaveBeenCalledWith(false);
});
it("redirects non-editors on _edit URLs to the canonical URL without rendering", async () => {
const onRequest = await loadMiddleware("client");
const next = vi.fn(async () => htmlResponse());
for (const user of [null, { id: "u2", role: 20 }]) {
const context = buildContext({ user, search: "?_edit=1&page=2", pathname: "/blog" });
const res = await onRequest(context, next);
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe("/blog?page=2");
// The redirect must not be stored either — a cached 302 would bounce
// editors back to the canonical URL. Route-cache opt-out covers the
// Workers Cache; the header covers header-following caches.
expect(context.cache.set).toHaveBeenCalledWith(false);
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
}
expect(next).not.toHaveBeenCalled();
});
it("still injects the server toolbar for editors in active edit mode (cookie)", async () => {
const onRequest = await loadMiddleware("client");
const res = await onRequest(buildContext({ user: EDITOR, editCookie: true }), async () =>
htmlResponse(),
);
expect(await res.text()).toContain('id="emdash-toolbar"');
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
});
it("serves the bootstrap variant for a stale edit cookie without a session", async () => {
const onRequest = await loadMiddleware("client");
const res = await onRequest(buildContext({ editCookie: true }), async () => htmlResponse());
const html = await res.text();
expect(html).toContain("emdash-toolbar-bootstrap");
expect(html).not.toContain('id="emdash-toolbar"');
});
});
describe("toolbar bootstrap script", () => {
it("generates syntactically valid JavaScript", async () => {
const { renderToolbarBootstrap } =
await import("../../../src/visual-editing/toolbar-bootstrap.js");
const html = renderToolbarBootstrap();
// Index-based extraction (not regex): this parses our own generated
// string with known fixed tags, not untrusted HTML.
const open = html.indexOf("<script>");
const close = html.lastIndexOf("</script>");
expect(open).toBeGreaterThanOrEqual(0);
expect(close).toBeGreaterThan(open);
const script = html.slice(open + "<script>".length, close);
expect(script.trim()).toBeTruthy();
// Throws SyntaxError if the template literal produced broken JS.
// eslint-disable-next-line typescript/no-implied-eval -- deliberate parse-only syntax check of generated script, never invoked
expect(() => new Function(script)).not.toThrow();
});
});
describe("toolbar: false", () => {
it("renders neither the toolbar nor the bootstrap for anyone", async () => {
const onRequest = await loadMiddleware(false);
for (const user of [null, EDITOR]) {
const res = await onRequest(buildContext({ user }), async () => htmlResponse());
const html = await res.text();
expect(html).not.toContain("emdash-toolbar");
expect(res.headers.get("Cache-Control")).toBeNull();
}
});
});