Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
/**
* Content read endpoint authorization.
*
* content:read is granted to SUBSCRIBER so member-only published content can
* be read via the admin API. Drafts, scheduled, trashed items, and editor
* views (revisions, compare, preview-url) are gated on content:read_drafts
* (CONTRIBUTOR+):
*
* - GET /content/:c forces status=published for SUBSCRIBER, ignoring any
* caller-supplied status filter.
* - GET /content/:c/:id returns 404 to SUBSCRIBER for non-published items
* (404 to avoid leaking existence via status code).
* - /compare, /revisions, /trash, /preview-url require content:read_drafts.
* - /translations filters non-published locales out for SUBSCRIBER.
*/
import { Role, type RoleLevel } from "@emdash-cms/auth";
import type { APIContext } from "astro";
import { describe, it, expect, vi } from "vitest";
import { GET as getItem } from "../../../src/astro/routes/api/content/[collection]/[id].js";
import { GET as getCompare } from "../../../src/astro/routes/api/content/[collection]/[id]/compare.js";
import { POST as postPreviewUrl } from "../../../src/astro/routes/api/content/[collection]/[id]/preview-url.js";
import { GET as getRevisions } from "../../../src/astro/routes/api/content/[collection]/[id]/revisions.js";
import { GET as getTranslations } from "../../../src/astro/routes/api/content/[collection]/[id]/translations.js";
import { GET as getList } from "../../../src/astro/routes/api/content/[collection]/index.js";
import { GET as getTrash } from "../../../src/astro/routes/api/content/[collection]/trash.js";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
interface StubUser {
id: string;
role: RoleLevel;
}
const subscriber: StubUser = { id: "u-sub", role: Role.SUBSCRIBER };
const contributor: StubUser = { id: "u-con", role: Role.CONTRIBUTOR };
const editor: StubUser = { id: "u-edit", role: Role.EDITOR };
interface StubItem {
id: string;
type: string;
slug: string | null;
status: string;
data: Record<string, unknown>;
authorId: string | null;
primaryBylineId: string | null;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
scheduledAt: string | null;
liveRevisionId: string | null;
draftRevisionId: string | null;
version: number;
locale: string | null;
translationGroup: string | null;
}
function makeItem(partial: Partial<StubItem> & { id: string; status: string }): StubItem {
return {
type: "post",
slug: partial.id,
data: {},
authorId: null,
primaryBylineId: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
publishedAt: partial.status === "published" ? "2026-01-01T00:00:00Z" : null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
version: 1,
locale: null,
translationGroup: null,
...partial,
};
}
function buildEmdash(
opts: {
listItems?: StubItem[];
getItem?: StubItem | null;
translations?: Array<{
id: string;
status: string;
locale: string | null;
slug: string | null;
updatedAt: string;
}>;
trashItems?: StubItem[];
revisions?: Array<{ id: string }>;
compare?: { hasChanges: boolean; live: unknown; draft: unknown };
} = {},
) {
const handleContentList = vi.fn(async (_collection: string, params: { status?: string }) => {
const items = params.status
? (opts.listItems ?? []).filter((i) => i.status === params.status)
: (opts.listItems ?? []);
return { success: true as const, data: { items, nextCursor: undefined } };
});
const handleContentGet = vi.fn(async (_collection: string, _id: string) => {
if (!opts.getItem) {
return { success: false as const, error: { code: "NOT_FOUND", message: "not found" } };
}
return { success: true as const, data: { item: opts.getItem, _rev: "rev1" } };
});
const handleContentTranslations = vi.fn(async () => ({
success: true as const,
data: { translationGroup: "tg-1", translations: opts.translations ?? [] },
}));
const handleContentListTrashed = vi.fn(async () => ({
success: true as const,
data: { items: opts.trashItems ?? [], nextCursor: undefined },
}));
const handleRevisionList = vi.fn(async () => ({
success: true as const,
data: { items: opts.revisions ?? [] },
}));
const handleContentCompare = vi.fn(async () => ({
success: true as const,
data: opts.compare ?? { hasChanges: false, live: null, draft: null },
}));
return {
handleContentList,
handleContentGet,
handleContentTranslations,
handleContentListTrashed,
handleRevisionList,
handleContentCompare,
};
}
function ctx(opts: {
user: StubUser | null;
emdash: ReturnType<typeof buildEmdash>;
params?: Record<string, string>;
url?: string;
request?: Request;
}): APIContext {
const url = new URL(opts.url ?? "http://localhost/");
return {
params: opts.params ?? { collection: "post" },
url,
request: opts.request ?? new Request(url),
locals: {
user: opts.user,
emdash: opts.emdash,
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub for tests
} as unknown as APIContext;
}
// ---------------------------------------------------------------------------
// LIST endpoint
// ---------------------------------------------------------------------------
describe("GET /content/:collection — subscriber drafts leak", () => {
const items = [
makeItem({ id: "draft-1", status: "draft" }),
makeItem({ id: "pub-1", status: "published" }),
makeItem({ id: "sched-1", status: "scheduled" }),
];
it("forces status=published filter for SUBSCRIBER", async () => {
const emdash = buildEmdash({ listItems: items });
const res = await getList(ctx({ user: subscriber, emdash }));
expect(res.status).toBe(200);
expect(emdash.handleContentList).toHaveBeenCalledWith(
"post",
expect.objectContaining({ status: "published" }),
);
const body = (await res.json()) as { data: { items: StubItem[] } };
expect(body.data.items.map((i) => i.id)).toEqual(["pub-1"]);
});
it("rejects subscriber attempt to override status filter to draft", async () => {
const emdash = buildEmdash({ listItems: items });
const res = await getList(
ctx({
user: subscriber,
emdash,
url: "http://localhost/?status=draft",
}),
);
expect(res.status).toBe(200);
// The route must not honour ?status=draft for SUBSCRIBER — should still
// be forced to published.
expect(emdash.handleContentList).toHaveBeenCalledWith(
"post",
expect.objectContaining({ status: "published" }),
);
const body = (await res.json()) as { data: { items: StubItem[] } };
expect(body.data.items.every((i) => i.status === "published")).toBe(true);
});
it("returns full set for CONTRIBUTOR (has read_drafts)", async () => {
const emdash = buildEmdash({ listItems: items });
const res = await getList(ctx({ user: contributor, emdash }));
expect(res.status).toBe(200);
// status param is undefined (caller-controlled), not forced
const call = emdash.handleContentList.mock.calls[0]?.[1];
expect(call?.status).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// GET single item
// ---------------------------------------------------------------------------
describe("GET /content/:collection/:id — subscriber drafts leak", () => {
it("returns 404 to SUBSCRIBER fetching a draft", async () => {
const emdash = buildEmdash({ getItem: makeItem({ id: "p1", status: "draft" }) });
const res = await getItem(
ctx({ user: subscriber, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(404);
});
it("returns 404 to SUBSCRIBER fetching a scheduled item", async () => {
const emdash = buildEmdash({ getItem: makeItem({ id: "p1", status: "scheduled" }) });
const res = await getItem(
ctx({ user: subscriber, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(404);
});
it("allows SUBSCRIBER to fetch a published item", async () => {
const emdash = buildEmdash({ getItem: makeItem({ id: "p1", status: "published" }) });
const res = await getItem(
ctx({ user: subscriber, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(200);
});
it("allows CONTRIBUTOR to fetch a draft", async () => {
const emdash = buildEmdash({ getItem: makeItem({ id: "p1", status: "draft" }) });
const res = await getItem(
ctx({ user: contributor, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Editor-only views — must require content:read_drafts
// ---------------------------------------------------------------------------
describe("editor-only content views require content:read_drafts", () => {
it("denies SUBSCRIBER on /compare", async () => {
const emdash = buildEmdash({ compare: { hasChanges: false, live: null, draft: null } });
const res = await getCompare(
ctx({ user: subscriber, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(403);
expect(emdash.handleContentCompare).not.toHaveBeenCalled();
});
it("allows CONTRIBUTOR on /compare", async () => {
const emdash = buildEmdash({ compare: { hasChanges: false, live: null, draft: null } });
const res = await getCompare(
ctx({ user: contributor, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(200);
});
it("denies SUBSCRIBER on /revisions", async () => {
const emdash = buildEmdash({ revisions: [] });
const res = await getRevisions(
ctx({ user: subscriber, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(403);
expect(emdash.handleRevisionList).not.toHaveBeenCalled();
});
it("denies SUBSCRIBER on /trash", async () => {
const emdash = buildEmdash({ trashItems: [] });
const res = await getTrash(ctx({ user: subscriber, emdash }));
expect(res.status).toBe(403);
expect(emdash.handleContentListTrashed).not.toHaveBeenCalled();
});
it("denies SUBSCRIBER on /preview-url POST", async () => {
const emdash = buildEmdash({ getItem: makeItem({ id: "p1", status: "published" }) });
const url = "http://localhost/";
const res = await postPreviewUrl(
ctx({
user: subscriber,
emdash,
params: { collection: "post", id: "p1" },
url,
request: new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
}),
);
expect(res.status).toBe(403);
expect(emdash.handleContentGet).not.toHaveBeenCalled();
});
it("allows EDITOR on /trash", async () => {
const emdash = buildEmdash({ trashItems: [] });
const res = await getTrash(ctx({ user: editor, emdash }));
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Translations endpoint — must status-filter for SUBSCRIBER
// ---------------------------------------------------------------------------
describe("GET /content/:collection/:id/translations", () => {
const translations = [
{
id: "t-en",
locale: "en",
slug: "p1",
status: "published",
updatedAt: "2026-01-01T00:00:00Z",
},
{ id: "t-fr", locale: "fr", slug: "p1", status: "draft", updatedAt: "2026-01-01T00:00:00Z" },
{
id: "t-de",
locale: "de",
slug: "p1",
status: "scheduled",
updatedAt: "2026-01-01T00:00:00Z",
},
];
it("filters non-published translations for SUBSCRIBER", async () => {
const emdash = buildEmdash({ translations });
const res = await getTranslations(
ctx({ user: subscriber, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { translations: Array<{ id: string; status: string }> };
};
expect(body.data.translations.map((t) => t.id)).toEqual(["t-en"]);
});
it("returns all translations for CONTRIBUTOR", async () => {
const emdash = buildEmdash({ translations });
const res = await getTranslations(
ctx({ user: contributor, emdash, params: { collection: "post", id: "p1" } }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { translations: Array<{ id: string; status: string }> };
};
expect(body.data.translations).toHaveLength(3);
});
});

View File

@@ -0,0 +1,143 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
generateConfigModule,
generateDialectModule,
generateSeedModule,
} from "../../../../src/astro/integration/virtual-modules.js";
describe("generateConfigModule", () => {
it("round-trips the serialisable config shape via default export", () => {
const source = generateConfigModule({
siteUrl: "https://example.com",
trustedProxyHeaders: ["x-real-ip", "fly-client-ip"],
maxUploadSize: 52_428_800,
});
// The virtual module is `export default <JSON>` — eval by stripping
// the prefix and parsing.
const prefix = "export default ";
expect(source.startsWith(prefix)).toBe(true);
const json = source.slice(prefix.length).replace(/;$/, "");
const parsed = JSON.parse(json);
expect(parsed.trustedProxyHeaders).toEqual(["x-real-ip", "fly-client-ip"]);
expect(parsed.siteUrl).toBe("https://example.com");
});
});
describe("generateDialectModule", () => {
it("emits undefined createDialect and null stub when no entrypoint is configured", () => {
const out = generateDialectModule({ supportsRequestScope: false });
expect(out).toContain("export const createDialect = undefined");
expect(out).toContain("export const createRequestScopedDb = (_opts) => null");
});
it("emits a null stub for adapters that don't support request scoping", () => {
const out = generateDialectModule({
entrypoint: "some-adapter/dialect",
type: "sqlite",
supportsRequestScope: false,
});
expect(out).toContain(`import { createDialect as _createDialect } from "some-adapter/dialect"`);
expect(out).toContain("export const createRequestScopedDb = (_opts) => null");
expect(out).not.toContain(`export { createRequestScopedDb } from`);
});
it("re-exports createRequestScopedDb from the adapter when supportsRequestScope is true", () => {
const out = generateDialectModule({
entrypoint: "@emdash-cms/cloudflare/db/d1",
type: "sqlite",
supportsRequestScope: true,
});
expect(out).toContain(`export { createRequestScopedDb } from "@emdash-cms/cloudflare/db/d1"`);
expect(out).not.toContain("= () => null");
expect(out).not.toContain("= (_opts) => null");
});
it("threads the dialect type through", () => {
const out = generateDialectModule({
entrypoint: "emdash/db/postgres",
type: "postgres",
supportsRequestScope: false,
});
expect(out).toContain(`export const dialectType = "postgres"`);
});
});
describe("generateSeedModule", () => {
let projectRoot: string;
beforeEach(() => {
projectRoot = mkdtempSync(join(tmpdir(), "emdash-seed-test-"));
});
afterEach(() => {
rmSync(projectRoot, { recursive: true, force: true });
});
const sampleSeed = (name: string) => ({
version: "1",
meta: { name },
collections: [],
});
it("prefers .emdash/seed.json over package.json#emdash.seed and seed/seed.json", () => {
mkdirSync(join(projectRoot, ".emdash"));
writeFileSync(
join(projectRoot, ".emdash", "seed.json"),
JSON.stringify(sampleSeed("dot-emdash")),
);
writeFileSync(
join(projectRoot, "package.json"),
JSON.stringify({ name: "x", emdash: { seed: "custom-seed.json" } }),
);
writeFileSync(join(projectRoot, "custom-seed.json"), JSON.stringify(sampleSeed("pkg-pointer")));
mkdirSync(join(projectRoot, "seed"));
writeFileSync(
join(projectRoot, "seed", "seed.json"),
JSON.stringify(sampleSeed("conventional")),
);
const out = generateSeedModule(projectRoot);
expect(out).toContain(`"name":"dot-emdash"`);
expect(out).toContain("export const seed = userSeed;");
});
it("uses package.json#emdash.seed when .emdash/seed.json is absent", () => {
writeFileSync(
join(projectRoot, "package.json"),
JSON.stringify({ name: "x", emdash: { seed: "seed/seed.json" } }),
);
mkdirSync(join(projectRoot, "seed"));
writeFileSync(join(projectRoot, "seed", "seed.json"), JSON.stringify(sampleSeed("via-pkg")));
const out = generateSeedModule(projectRoot);
expect(out).toContain(`"name":"via-pkg"`);
});
it("falls back to seed/seed.json when no pointer is configured", () => {
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "x" }));
mkdirSync(join(projectRoot, "seed"));
writeFileSync(
join(projectRoot, "seed", "seed.json"),
JSON.stringify(sampleSeed("conventional-fallback")),
);
const out = generateSeedModule(projectRoot);
expect(out).toContain(`"name":"conventional-fallback"`);
expect(out).toContain("export const seed = userSeed;");
});
it("falls through to the default seed when no user seed is found", () => {
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "x" }));
const out = generateSeedModule(projectRoot);
expect(out).toContain("export const userSeed = null;");
expect(out).toContain("export const seed = ");
});
});

View File

@@ -0,0 +1,82 @@
/**
* Manifest route admin branding.
*
* The admin branding (logo, siteName, favicon) configured via the EmDash
* integration must be reflected in `/_emdash/api/manifest` so the React SPA
* can render the custom logo and site name. The route reads the branding
* from the per-request config on `locals.emdash.config.admin` (the same
* source `admin.astro` uses), not from a build-time global.
*
* Regression test for issue #835.
*/
import type { APIContext } from "astro";
import { describe, expect, it } from "vitest";
import { GET as getManifest } from "../../../src/astro/routes/api/manifest.js";
interface ManifestEnvelope {
data: {
admin?: { logo?: string; siteName?: string; favicon?: string };
authMode: string;
signupEnabled?: boolean;
collections?: Record<string, unknown>;
plugins?: Record<string, unknown>;
taxonomies?: unknown[];
version?: string;
};
}
function makeContext(
adminBranding?: { logo?: string; siteName?: string; favicon?: string },
manifest?: unknown,
): Parameters<typeof getManifest>[0] {
const locals = {
emdash: adminBranding
? {
// db is intentionally undefined so the signup-enabled query is skipped.
config: { admin: adminBranding },
getManifest: async () => manifest ?? null,
}
: undefined,
};
return { locals } as unknown as APIContext;
}
describe("manifest route admin branding", () => {
it("returns admin branding from locals.emdash.config.admin", async () => {
const branding = {
logo: "/logo.png",
siteName: "My Site",
favicon: "/favicon.ico",
};
const response = await getManifest(makeContext(branding));
expect(response.status).toBe(200);
const body = (await response.json()) as ManifestEnvelope;
expect(body.data.admin).toEqual(branding);
});
it("omits the admin field when no branding is configured", async () => {
const response = await getManifest(makeContext());
expect(response.status).toBe(200);
const body = (await response.json()) as ManifestEnvelope;
expect(body.data.admin).toBeUndefined();
});
it("returns admin branding even when getManifest() resolves to a built manifest", async () => {
const branding = { logo: "/brand.svg", siteName: "Brandname" };
const ctx = makeContext(branding, {
version: "test",
hash: "test",
collections: {},
plugins: {},
taxonomies: [],
});
const response = await getManifest(ctx);
const body = (await response.json()) as ManifestEnvelope;
expect(body.data.admin).toEqual(branding);
});
});

View File

@@ -0,0 +1,289 @@
import { beforeEach, describe, it, expect, vi } from "vitest";
vi.mock("astro:middleware", () => ({
defineMiddleware: (handler: unknown) => handler,
}));
// vi.mock factories are hoisted above normal `const` declarations; use
// vi.hoisted so the marker object is available both to the mock factory and
// to assertions below.
const { DB_CONFIG_MARKER } = vi.hoisted(() => ({
DB_CONFIG_MARKER: { binding: "DB", session: "auto" },
}));
vi.mock(
"virtual:emdash/config",
() => ({
default: {
database: { config: DB_CONFIG_MARKER },
auth: { mode: "none" },
},
}),
{ virtual: true },
);
vi.mock(
"virtual:emdash/dialect",
() => ({
createDialect: vi.fn(),
createRequestScopedDb: vi.fn().mockReturnValue(null),
}),
{ virtual: true },
);
vi.mock("virtual:emdash/media-providers", () => ({ mediaProviders: [] }), { virtual: true });
vi.mock("virtual:emdash/plugins", () => ({ plugins: [] }), { virtual: true });
vi.mock(
"virtual:emdash/sandbox-runner",
() => ({
createSandboxRunner: null,
sandboxEnabled: false,
}),
{ virtual: true },
);
vi.mock("virtual:emdash/sandboxed-plugins", () => ({ sandboxedPlugins: [] }), { virtual: true });
vi.mock("virtual:emdash/storage", () => ({ createStorage: null }), { virtual: true });
vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true });
vi.mock("../../../src/loader.js", () => ({
getDb: vi.fn(async () => ({
selectFrom: () => ({
selectAll: () => ({
limit: () => ({
execute: async () => [],
}),
}),
}),
})),
}));
import { createRequestScopedDb } from "virtual:emdash/dialect";
import onRequest from "../../../src/astro/middleware.js";
import { getRequestContext } from "../../../src/request-context.js";
describe("astro middleware prerendered routes", () => {
beforeEach(() => {
vi.mocked(createRequestScopedDb).mockReset().mockReturnValue(null);
});
it("does not access context.session on prerendered public runtime routes", async () => {
const cookies = {
get: vi.fn(() => undefined),
};
const context: Record<string, unknown> = {
request: new Request("https://example.com/robots.txt"),
url: new URL("https://example.com/robots.txt"),
cookies,
locals: {},
redirect: vi.fn(),
isPrerendered: true,
};
Object.defineProperty(context, "session", {
get() {
throw new Error("context.session should not be accessed during prerender");
},
});
const response = await onRequest(
context as Parameters<typeof onRequest>[0],
async () => new Response("ok"),
);
expect(response.status).toBe(200);
});
it("does not access context.session when prerendering public pages", async () => {
const cookies = {
get: vi.fn(() => undefined),
};
const redirect = vi.fn(
(location: string) => new Response(null, { status: 302, headers: { Location: location } }),
);
const context: Record<string, unknown> = {
request: new Request("https://example.com/"),
url: new URL("https://example.com/"),
cookies,
locals: {},
redirect,
isPrerendered: true,
};
Object.defineProperty(context, "session", {
get() {
throw new Error("context.session should not be accessed during prerender");
},
});
const response = await onRequest(
context as Parameters<typeof onRequest>[0],
async () => new Response("ok"),
);
expect(response.status).toBe(200);
expect(redirect).not.toHaveBeenCalled();
});
});
describe("astro middleware anonymous session reads", () => {
beforeEach(() => {
vi.mocked(createRequestScopedDb).mockReset().mockReturnValue(null);
});
it("does not read the Astro session when no astro-session cookie is present", async () => {
// Regression test for #733: on Cloudflare Workers the Astro session
// backend is KV, so calling session.get() on every anonymous public
// request produces a flood of KV read misses. The middleware must
// skip the session lookup entirely when no astro-session cookie is set.
const cookies = {
get: vi.fn((name: string) => {
if (name === "astro-session") return undefined;
return undefined;
}),
set: vi.fn(),
};
const sessionGet = vi.fn(async () => null);
const astroSession = { get: sessionGet };
const context: Record<string, unknown> = {
request: new Request("https://example.com/"),
url: new URL("https://example.com/"),
cookies,
locals: {},
redirect: vi.fn(),
isPrerendered: false,
session: astroSession,
};
const response = await onRequest(
context as Parameters<typeof onRequest>[0],
async () => new Response("ok"),
);
expect(response.status).toBe(200);
expect(sessionGet).not.toHaveBeenCalled();
});
it("reads the Astro session when an astro-session cookie is present", async () => {
const cookies = {
get: vi.fn((name: string) => {
if (name === "astro-session") return { value: "abc123" };
return undefined;
}),
set: vi.fn(),
};
const sessionGet = vi.fn(async () => null);
const astroSession = { get: sessionGet };
const context: Record<string, unknown> = {
request: new Request("https://example.com/", {
headers: { cookie: "astro-session=abc123" },
}),
url: new URL("https://example.com/"),
cookies,
locals: {},
redirect: vi.fn(),
isPrerendered: false,
session: astroSession,
};
const response = await onRequest(
context as Parameters<typeof onRequest>[0],
async () => new Response("ok"),
);
expect(response.status).toBe(200);
expect(sessionGet).toHaveBeenCalledWith("user");
});
});
describe("astro middleware request-scoped db", () => {
beforeEach(() => {
vi.mocked(createRequestScopedDb).mockReset().mockReturnValue(null);
});
it("asks the adapter for a scoped db on anonymous public pages and exposes it via ALS", async () => {
const commit = vi.fn();
const scopedDb = { _marker: "scoped" };
vi.mocked(createRequestScopedDb).mockReturnValue({
db: scopedDb as never,
commit,
});
const cookies = {
get: vi.fn(() => undefined),
set: vi.fn(),
};
const astroSession = {
get: vi.fn(async () => null),
};
const context: Record<string, unknown> = {
request: new Request("https://example.com/"),
url: new URL("https://example.com/"),
cookies,
locals: {},
redirect: vi.fn(),
isPrerendered: false,
session: astroSession,
};
let dbSeenByNext: unknown;
const response = await onRequest(context as Parameters<typeof onRequest>[0], async () => {
dbSeenByNext = getRequestContext()?.db;
return new Response("ok");
});
expect(response.status).toBe(200);
expect(createRequestScopedDb).toHaveBeenCalledTimes(1);
const opts = vi.mocked(createRequestScopedDb).mock.calls[0]?.[0];
// Opts shape matches the RequestScopedDbOpts contract declared in
// virtual-modules.d.ts. The `config` field name must match exactly —
// it's what the D1 adapter reads; a rename silently breaks D1 sessions.
expect(opts).toMatchObject({
config: DB_CONFIG_MARKER,
isAuthenticated: false,
isWrite: false,
cookies,
});
expect(dbSeenByNext).toBe(scopedDb);
expect(commit).toHaveBeenCalledTimes(1);
// ALS must be fully torn down after the middleware returns; otherwise
// a refactor to enterWith() could silently leak request state into
// other async work on the same worker.
expect(getRequestContext()).toBeUndefined();
});
it("forces isWrite true for POST requests on public pages", async () => {
const commit = vi.fn();
vi.mocked(createRequestScopedDb).mockReturnValue({
db: { _marker: "scoped" } as never,
commit,
});
const cookies = { get: vi.fn(() => undefined), set: vi.fn() };
const astroSession = { get: vi.fn(async () => null) };
const context: Record<string, unknown> = {
request: new Request("https://example.com/", { method: "POST" }),
url: new URL("https://example.com/"),
cookies,
locals: {},
redirect: vi.fn(),
isPrerendered: false,
session: astroSession,
};
await onRequest(context as Parameters<typeof onRequest>[0], async () => new Response("ok"));
const opts = vi.mocked(createRequestScopedDb).mock.calls[0]?.[0];
expect(opts).toMatchObject({
config: DB_CONFIG_MARKER,
isAuthenticated: false,
isWrite: true,
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* Regression tests for issue #808: redirect middleware silently no-oped for
* unauthenticated public visitors because `locals.emdash.db` is intentionally
* absent on the public-visitor branch of runtime init. The fix routes the
* lookup through `getDb()` (ALS-aware, falls back to singleton).
*/
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("astro:middleware", () => ({
defineMiddleware: (handler: unknown) => handler,
}));
const { getDbMock } = vi.hoisted(() => ({
getDbMock: vi.fn(),
}));
vi.mock("../../../src/loader.js", () => ({
getDb: getDbMock,
}));
import { onRequest } from "../../../src/astro/middleware/redirect.js";
import { RedirectRepository } from "../../../src/database/repositories/redirect.js";
import type { Database } from "../../../src/database/types.js";
import { invalidateRedirectCache } from "../../../src/redirects/cache.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
type MiddlewareContext = Parameters<typeof onRequest>[0];
interface BuildContextOpts {
pathname: string;
emdashDb?: unknown;
}
function buildContext({ pathname, emdashDb }: BuildContextOpts): {
context: MiddlewareContext;
redirect: ReturnType<typeof vi.fn>;
} {
const redirect = vi.fn(
(location: string, status: number) =>
new Response(null, { status, headers: { Location: location } }),
);
const url = new URL(`https://example.com${pathname}`);
const locals = emdashDb !== undefined ? { emdash: { db: emdashDb } } : {};
const ctx = {
url,
request: new Request(url.toString()),
locals,
redirect,
};
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal Astro-shaped object for the middleware under test
return { context: ctx as unknown as MiddlewareContext, redirect };
}
describe("redirect middleware — issue #808", () => {
let db: Kysely<Database>;
beforeEach(async () => {
invalidateRedirectCache();
db = await setupTestDatabase();
const repo = new RedirectRepository(db);
await repo.create({ source: "/old", destination: "/new", type: 301 });
await repo.create({
source: "/legacy/[slug]",
destination: "/posts/[slug]",
type: 301,
isPattern: true,
});
getDbMock.mockReset();
getDbMock.mockResolvedValue(db);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
async function runMiddleware(
context: MiddlewareContext,
next: () => Promise<Response>,
): Promise<Response> {
const result = await onRequest(context, next);
if (!(result instanceof Response)) {
throw new Error("Middleware returned void; expected a Response");
}
return result;
}
it("fires for an unauthenticated visitor on a public path (no locals.emdash.db)", async () => {
const { context, redirect } = buildContext({ pathname: "/old" });
const next = vi.fn(async () => new Response("not found", { status: 404 }));
const response = await runMiddleware(context, next);
expect(getDbMock).toHaveBeenCalledTimes(1);
expect(redirect).toHaveBeenCalledWith("/new", 301);
expect(response.status).toBe(301);
expect(response.headers.get("Location")).toBe("/new");
expect(next).not.toHaveBeenCalled();
});
it("fires pattern matches for unauthenticated visitors", async () => {
const { context, redirect } = buildContext({ pathname: "/legacy/hello" });
const next = vi.fn(async () => new Response("not found", { status: 404 }));
const response = await runMiddleware(context, next);
expect(redirect).toHaveBeenCalledWith("/posts/hello", 301);
expect(response.status).toBe(301);
});
it("still uses locals.emdash.db when present (authenticated/edit-mode/preview path)", async () => {
const { context, redirect } = buildContext({ pathname: "/old", emdashDb: db });
const next = vi.fn(async () => new Response("not found", { status: 404 }));
const response = await runMiddleware(context, next);
// When locals.emdash.db is provided, getDb() must not be called.
expect(getDbMock).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/new", 301);
expect(response.status).toBe(301);
});
it("skips silently when no database is available at all", async () => {
getDbMock.mockRejectedValueOnce(new Error("EmDash database not configured"));
const { context, redirect } = buildContext({ pathname: "/old" });
const next = vi.fn(async () => new Response("ok"));
const response = await runMiddleware(context, next);
expect(redirect).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
expect(response.status).toBe(200);
});
it("warms the redirect cache from one query and reuses it across requests", async () => {
const findAllEnabled = vi.spyOn(RedirectRepository.prototype, "findAllEnabled");
// First request: cache cold, should issue exactly one query.
const first = buildContext({ pathname: "/old" });
const next1 = vi.fn(async () => new Response("not found", { status: 404 }));
const r1 = await runMiddleware(first.context, next1);
expect(r1.status).toBe(301);
expect(findAllEnabled).toHaveBeenCalledTimes(1);
// Second request (exact match): cache warm, no further queries.
const second = buildContext({ pathname: "/old" });
const next2 = vi.fn(async () => new Response("not found", { status: 404 }));
const r2 = await runMiddleware(second.context, next2);
expect(r2.status).toBe(301);
expect(findAllEnabled).toHaveBeenCalledTimes(1);
// Third request (pattern match): still warm, no further queries.
const third = buildContext({ pathname: "/legacy/hello" });
const next3 = vi.fn(async () => new Response("not found", { status: 404 }));
const r3 = await runMiddleware(third.context, next3);
expect(r3.status).toBe(301);
expect(third.redirect).toHaveBeenCalledWith("/posts/hello", 301);
expect(findAllEnabled).toHaveBeenCalledTimes(1);
// Fourth request (no match): still warm, but next() runs and a 404 is logged.
const fourth = buildContext({ pathname: "/nope" });
const next4 = vi.fn(async () => new Response("not found", { status: 404 }));
await runMiddleware(fourth.context, next4);
expect(findAllEnabled).toHaveBeenCalledTimes(1);
findAllEnabled.mockRestore();
});
it("does not intercept /_emdash routes", async () => {
const { context, redirect } = buildContext({ pathname: "/_emdash/admin" });
const next = vi.fn(async () => new Response("ok"));
await runMiddleware(context, next);
expect(getDbMock).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import { injectCoreRoutes } from "../../../src/astro/integration/routes.js";
import { GET as getMediaFile } from "../../../src/astro/routes/api/media/file/[...key].js";
function mockMediaContext(key: string | undefined) {
const download = vi.fn().mockResolvedValue({
body: new Uint8Array([1, 2, 3]),
contentType: "image/png",
size: 3,
});
return {
context: {
params: { key },
locals: {
emdash: {
storage: { download },
},
},
} as Parameters<typeof getMediaFile>[0],
download,
};
}
describe("core media route injection", () => {
it("uses a catch-all media file route so storage keys can contain slashes", () => {
const routes: Array<{ pattern: string; entrypoint: string }> = [];
injectCoreRoutes((route) => {
routes.push({
...route,
entrypoint: route.entrypoint.replaceAll("\\", "/"),
});
});
expect(routes).toContainEqual(
expect.objectContaining({
pattern: "/_emdash/api/media/file/[...key]",
entrypoint: expect.stringContaining("api/media/file/[...key].ts"),
}),
);
});
});
describe("media file catch-all route", () => {
it("passes slash-containing keys through to storage.download", async () => {
const { context, download } = mockMediaContext("nested/path/file.png");
const response = await getMediaFile(context);
expect(response.status).toBe(200);
expect(download).toHaveBeenCalledWith("nested/path/file.png");
});
it("returns not found when the catch-all key is missing", async () => {
const { context, download } = mockMediaContext(undefined);
const response = await getMediaFile(context);
expect(response.status).toBe(404);
expect(download).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,157 @@
/**
* Rate-limit enforcement on POST /_emdash/api/auth/signup/request.
*
* The signup request route must be rate-limited per IP, mirroring the
* existing protection on magic-link/send. Without a limit, a caller on
* Cloudflare can trigger unlimited signup verification emails for any
* allowed domain.
*
* Tests drive the route handler directly with a real in-memory SQLite
* database (so checkRateLimit actually persists) and a stubbed email
* pipeline to observe send counts.
*/
import { Role } from "@emdash-cms/auth";
import type { AuthAdapter } from "@emdash-cms/auth";
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
import type { APIContext } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { POST as signupRequest } from "../../../src/astro/routes/api/auth/signup/request.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
// Simulate a Cloudflare request so getClientIp returns a value. Without the
// `cf` marker, the rate limiter short-circuits with null-IP and nothing is
// enforced.
function cfRequest(url: string, body: unknown): Request {
const req = new Request(url, {
method: "POST",
headers: {
"content-type": "application/json",
"cf-connecting-ip": "198.51.100.7",
},
body: JSON.stringify(body),
});
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- test harness
(req as unknown as { cf: Record<string, unknown> }).cf = { country: "US" };
return req;
}
interface StubEmail {
send: ReturnType<typeof vi.fn>;
isAvailable: () => boolean;
}
function buildEmail(): StubEmail {
return {
send: vi.fn().mockResolvedValue(undefined),
isAvailable: () => true,
};
}
function ctx(opts: {
db: Kysely<Database>;
email: StubEmail;
body: { email: string };
}): APIContext {
const url = "http://localhost/_emdash/api/auth/signup/request";
return {
request: cfRequest(url, opts.body),
locals: {
emdash: {
db: opts.db,
email: opts.email,
},
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub for tests
} as unknown as APIContext;
}
describe("POST /auth/signup/request rate limiting", () => {
let db: Kysely<Database>;
let adapter: AuthAdapter;
beforeEach(async () => {
db = await setupTestDatabase();
adapter = createKyselyAdapter(db);
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("sends email on the first request from an IP", async () => {
const email = buildEmail();
const res = await signupRequest(ctx({ db, email, body: { email: "a@allowed.com" } }));
expect(res.status).toBe(200);
expect(email.send).toHaveBeenCalledTimes(1);
});
it("stops sending emails after the per-IP limit is exceeded", async () => {
const email = buildEmail();
// Use 4 distinct addresses so each one would normally send — if the
// limit is absent, the stub is called 4 times. With the fix, it's 3.
for (const local of ["a", "b", "c", "d"]) {
await signupRequest(ctx({ db, email, body: { email: `${local}@allowed.com` } }));
}
// Matches magic-link/send: 3 requests per 5 minutes per IP.
expect(email.send).toHaveBeenCalledTimes(3);
});
it("always returns 200 to avoid revealing the rate limit", async () => {
const email = buildEmail();
const responses = [];
for (const local of ["a", "b", "c", "d", "e"]) {
responses.push(
await signupRequest(ctx({ db, email, body: { email: `${local}@allowed.com` } })),
);
}
// All responses are 200 with the generic success envelope. The rate
// limit is invisible to the caller (which also keeps signup
// indistinguishable from disallowed-domain).
expect(responses.every((r) => r.status === 200)).toBe(true);
});
it("tracks the limit per IP, not globally", async () => {
const email = buildEmail();
const url = "http://localhost/_emdash/api/auth/signup/request";
function req(ip: string, addr: string): Request {
const r = new Request(url, {
method: "POST",
headers: {
"content-type": "application/json",
"cf-connecting-ip": ip,
},
body: JSON.stringify({ email: addr }),
});
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- test harness
(r as unknown as { cf: Record<string, unknown> }).cf = { country: "US" };
return r;
}
function makeCtx(request: Request): APIContext {
return {
request,
locals: { emdash: { db, email } },
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as APIContext;
}
// Exhaust IP A
for (const local of ["a", "b", "c", "d"]) {
await signupRequest(makeCtx(req("198.51.100.7", `${local}@allowed.com`)));
}
expect(email.send).toHaveBeenCalledTimes(3);
// IP B still gets through
await signupRequest(makeCtx(req("198.51.100.8", "x@allowed.com")));
expect(email.send).toHaveBeenCalledTimes(4);
});
});

View File

@@ -0,0 +1,113 @@
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js";
import { generateSandboxedPluginsModule } from "../../../src/astro/integration/virtual-modules.js";
function descriptor(overrides: Partial<PluginDescriptor> = {}): PluginDescriptor {
return {
id: "test-plugin",
version: "1.0.0",
entrypoint: "@test/plugin/sandbox",
format: "standard",
capabilities: [],
allowedHosts: [],
storage: {},
adminPages: [],
adminWidgets: [],
...overrides,
};
}
describe("generateSandboxedPluginsModule", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "emdash-vm-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function setupFakeProject(exportPath: string, content: string) {
// Create a fake project root with package.json
await writeFile(join(tmpDir, "package.json"), JSON.stringify({ name: "test-project" }));
// Create the plugin package inside node_modules
const pluginDir = join(tmpDir, "node_modules", "@test", "plugin");
await mkdir(pluginDir, { recursive: true });
// Determine the directory for the export file
const fileParts = exportPath.split("/");
if (fileParts.length > 1) {
const dir = join(pluginDir, ...fileParts.slice(0, -1));
await mkdir(dir, { recursive: true });
}
await writeFile(join(pluginDir, exportPath), content);
await writeFile(
join(pluginDir, "package.json"),
JSON.stringify({
name: "@test/plugin",
exports: { "./sandbox": `./${exportPath}` },
}),
);
}
it("returns empty module when no plugins configured", () => {
const result = generateSandboxedPluginsModule([], tmpDir);
expect(result).toContain("export const sandboxedPlugins = []");
});
it("embeds pre-built JavaScript successfully", async () => {
await setupFakeProject("dist/sandbox-entry.mjs", "export default { hooks: {} };");
const result = generateSandboxedPluginsModule(
[descriptor({ entrypoint: "@test/plugin/sandbox" })],
tmpDir,
);
expect(result).toContain("sandboxedPlugins");
expect(result).toContain("test-plugin");
expect(result).toContain("export default { hooks: {} };");
});
it("throws for .ts source files", async () => {
await setupFakeProject("src/sandbox-entry.ts", "export default {};");
expect(() =>
generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir),
).toThrow(/unbuilt source/);
});
it("throws for .tsx source files", async () => {
await setupFakeProject("src/sandbox-entry.tsx", "export default {};");
expect(() =>
generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir),
).toThrow(/unbuilt source/);
});
it("throws for .mts source files", async () => {
await setupFakeProject("src/sandbox-entry.mts", "export default {};");
expect(() =>
generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir),
).toThrow(/unbuilt source/);
});
it("includes plugin id in error message", async () => {
await setupFakeProject("src/sandbox-entry.ts", "export default {};");
expect(() =>
generateSandboxedPluginsModule(
[descriptor({ id: "my-broken-plugin", entrypoint: "@test/plugin/sandbox" })],
tmpDir,
),
).toThrow(/my-broken-plugin/);
});
});

View File

@@ -0,0 +1,140 @@
import { basename } from "node:path";
import type { AstroConfig } from "astro";
import { describe, expect, it } from "vitest";
import { createViteConfig } from "../../../src/astro/integration/vite-config.js";
describe("createViteConfig admin aliasing", () => {
const monorepoDemoRoot = new URL("../../../../../demos/simple/", import.meta.url);
const externalProjectRoot = new URL("file:///workspace/emdash-site/");
const siblingProjectRoot = new URL("../../../../../../emdash-site/", import.meta.url);
const adminSourcePattern = /[/\\]packages[/\\]admin[/\\]src$/;
const adminDistPattern = /[/\\]packages[/\\]admin[/\\]dist$/;
function buildConfig(root: URL, command: "dev" | "build" | "preview" | "sync" = "dev") {
return createViteConfig(
{
serializableConfig: {},
resolvedConfig: {} as never,
pluginDescriptors: [],
astroConfig: {
root,
adapter: { name: "@astrojs/node" },
} as AstroConfig,
},
command,
);
}
function getAdminAliasReplacement(config: ReturnType<typeof createViteConfig>) {
const aliases = Array.isArray(config.resolve?.alias) ? config.resolve.alias : [];
const adminAlias = aliases.find(
(alias) =>
typeof alias === "object" &&
alias !== null &&
"find" in alias &&
alias.find === "@emdash-cms/admin" &&
"replacement" in alias,
);
if (!adminAlias || typeof adminAlias.replacement !== "string") {
throw new Error("Missing @emdash-cms/admin alias");
}
return adminAlias.replacement;
}
it("uses raw admin source for local monorepo dev", () => {
const config = buildConfig(monorepoDemoRoot);
const replacement = getAdminAliasReplacement(config);
expect(basename(replacement)).toBe("src");
expect(replacement).toMatch(adminSourcePattern);
});
it("uses built admin dist for external app dev", () => {
const config = buildConfig(externalProjectRoot);
const replacement = getAdminAliasReplacement(config);
expect(basename(replacement)).toBe("dist");
expect(replacement).toMatch(adminDistPattern);
});
it("uses built admin dist for sibling paths with a matching prefix", () => {
const config = buildConfig(siblingProjectRoot);
const replacement = getAdminAliasReplacement(config);
expect(basename(replacement)).toBe("dist");
expect(replacement).toMatch(adminDistPattern);
});
it("uses built admin dist outside dev", () => {
const config = buildConfig(monorepoDemoRoot, "build");
const replacement = getAdminAliasReplacement(config);
expect(basename(replacement)).toBe("dist");
expect(replacement).toMatch(adminDistPattern);
});
});
describe("createViteConfig use-sync-external-store shim aliasing", () => {
const externalProjectRoot = new URL("file:///workspace/emdash-site/");
function buildConfig(adapter: string) {
return createViteConfig(
{
serializableConfig: {},
resolvedConfig: {} as never,
pluginDescriptors: [],
astroConfig: {
root: externalProjectRoot,
adapter: { name: adapter },
} as AstroConfig,
},
"dev",
);
}
function getAlias(config: ReturnType<typeof createViteConfig>, find: string) {
const aliases = Array.isArray(config.resolve?.alias) ? config.resolve.alias : [];
return aliases.find(
(alias) =>
typeof alias === "object" && alias !== null && "find" in alias && alias.find === find,
);
}
// Regression: with pnpm + React 18+, @tiptap/react pulls in
// `use-sync-external-store/shim` (CJS). Vite can't pre-bundle from the
// virtual store, so browsers get raw CJS and InlinePortableTextEditor
// fails to hydrate. The aliases redirect the shim to the main package,
// which delegates to React's built-in hook on React >=18.
for (const adapter of ["@astrojs/node", "@astrojs/cloudflare"] as const) {
it(`redirects use-sync-external-store/shim to the main package on ${adapter}`, () => {
const config = buildConfig(adapter);
const indexAlias = getAlias(config, "use-sync-external-store/shim/index.js");
const shimAlias = getAlias(config, "use-sync-external-store/shim");
expect(indexAlias).toMatchObject({ replacement: "use-sync-external-store" });
expect(shimAlias).toMatchObject({ replacement: "use-sync-external-store" });
});
it(`lists the more-specific shim alias before the directory alias on ${adapter}`, () => {
const config = buildConfig(adapter);
const aliases = Array.isArray(config.resolve?.alias) ? config.resolve.alias : [];
const findIndex = (find: string) =>
aliases.findIndex(
(alias) =>
typeof alias === "object" && alias !== null && "find" in alias && alias.find === find,
);
const indexIdx = findIndex("use-sync-external-store/shim/index.js");
const shimIdx = findIndex("use-sync-external-store/shim");
expect(indexIdx).toBeGreaterThanOrEqual(0);
expect(shimIdx).toBeGreaterThan(indexIdx);
});
}
});