Files
emdash-patch-imageupload/packages/core/tests/unit/astro/content-routes-authz.test.ts
kunthawat 2d1be52177 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
2026-05-03 10:44:54 +07:00

364 lines
12 KiB
TypeScript

/**
* 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);
});
});