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:
363
packages/core/tests/unit/astro/content-routes-authz.test.ts
Normal file
363
packages/core/tests/unit/astro/content-routes-authz.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 = ");
|
||||
});
|
||||
});
|
||||
82
packages/core/tests/unit/astro/manifest-route.test.ts
Normal file
82
packages/core/tests/unit/astro/manifest-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
289
packages/core/tests/unit/astro/middleware-prerender.test.ts
Normal file
289
packages/core/tests/unit/astro/middleware-prerender.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
179
packages/core/tests/unit/astro/middleware-redirect.test.ts
Normal file
179
packages/core/tests/unit/astro/middleware-redirect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
packages/core/tests/unit/astro/routes.test.ts
Normal file
61
packages/core/tests/unit/astro/routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
157
packages/core/tests/unit/astro/signup-rate-limit.test.ts
Normal file
157
packages/core/tests/unit/astro/signup-rate-limit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
113
packages/core/tests/unit/astro/virtual-modules-sandbox.test.ts
Normal file
113
packages/core/tests/unit/astro/virtual-modules-sandbox.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
140
packages/core/tests/unit/astro/vite-config.test.ts
Normal file
140
packages/core/tests/unit/astro/vite-config.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user