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,279 @@
import { Role } from "@emdash-cms/auth";
import { describe, it, expect, vi } from "vitest";
import { PUT as updateContent } from "../../../src/astro/routes/api/content/[collection]/[id].js";
import { POST as createContent } from "../../../src/astro/routes/api/content/[collection]/index.js";
/**
* Regression tests for the `publishedAt` / `createdAt` permission gate.
*
* The gate must trigger on *any* explicit presence of these fields —
* including `null` (explicit clear) — not just on non-null values. Checking
* only `!= null` would let a regular AUTHOR clear `published_at` on any item
* they can edit, bypassing `content:publish_any`.
*/
describe("content route — publishedAt / createdAt permission gate", () => {
const makeUser = (role: (typeof Role)[keyof typeof Role]) => ({
id: "user-1",
role,
});
const makeCache = () => ({ enabled: false, invalidate: vi.fn() });
describe("POST /_emdash/api/content/{collection}", () => {
it("returns 403 when an AUTHOR tries to set publishedAt", async () => {
const request = new Request("http://localhost/_emdash/api/content/post", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { title: "Hi" },
publishedAt: "2019-03-15T10:30:00.000Z",
}),
});
const response = await createContent({
params: { collection: "post" },
request,
locals: {
emdash: {
handleContentCreate: vi.fn(),
handleContentGet: vi.fn(),
},
user: makeUser(Role.AUTHOR),
},
cache: makeCache(),
} as Parameters<typeof createContent>[0]);
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({
error: { code: "FORBIDDEN" },
});
});
it("returns 403 when an AUTHOR tries to clear publishedAt via null", async () => {
const request = new Request("http://localhost/_emdash/api/content/post", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: { title: "Hi" }, publishedAt: null }),
});
const response = await createContent({
params: { collection: "post" },
request,
locals: {
emdash: {
handleContentCreate: vi.fn(),
handleContentGet: vi.fn(),
},
user: makeUser(Role.AUTHOR),
},
cache: makeCache(),
} as Parameters<typeof createContent>[0]);
expect(response.status).toBe(403);
});
it("returns 403 when an AUTHOR tries to set createdAt", async () => {
const request = new Request("http://localhost/_emdash/api/content/post", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { title: "Hi" },
createdAt: "2019-03-15T10:30:00.000Z",
}),
});
const response = await createContent({
params: { collection: "post" },
request,
locals: {
emdash: {
handleContentCreate: vi.fn(),
handleContentGet: vi.fn(),
},
user: makeUser(Role.AUTHOR),
},
cache: makeCache(),
} as Parameters<typeof createContent>[0]);
expect(response.status).toBe(403);
});
it("lets EDITOR set publishedAt", async () => {
const handleContentCreate = vi.fn().mockResolvedValue({
success: true,
data: {
item: { id: "c1", publishedAt: "2019-03-15T10:30:00.000Z" },
_rev: "rev1",
},
});
const request = new Request("http://localhost/_emdash/api/content/post", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { title: "Hi" },
publishedAt: "2019-03-15T10:30:00.000Z",
}),
});
const response = await createContent({
params: { collection: "post" },
request,
locals: {
emdash: { handleContentCreate, handleContentGet: vi.fn() },
user: makeUser(Role.EDITOR),
},
cache: makeCache(),
} as Parameters<typeof createContent>[0]);
expect(response.status).toBe(201);
expect(handleContentCreate).toHaveBeenCalledWith(
"post",
expect.objectContaining({ publishedAt: "2019-03-15T10:30:00.000Z" }),
);
});
it("lets AUTHOR create without date overrides", async () => {
const handleContentCreate = vi.fn().mockResolvedValue({
success: true,
data: { item: { id: "c1" }, _rev: "rev1" },
});
const request = new Request("http://localhost/_emdash/api/content/post", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: { title: "Hi" } }),
});
const response = await createContent({
params: { collection: "post" },
request,
locals: {
emdash: { handleContentCreate, handleContentGet: vi.fn() },
user: makeUser(Role.AUTHOR),
},
cache: makeCache(),
} as Parameters<typeof createContent>[0]);
expect(response.status).toBe(201);
expect(handleContentCreate).toHaveBeenCalled();
});
});
describe("PUT /_emdash/api/content/{collection}/{id}", () => {
const ownedItem = {
success: true,
data: { item: { id: "c1", authorId: "user-1" }, _rev: "rev1" },
};
it("returns 403 when an AUTHOR tries to clear publishedAt via null on their own post", async () => {
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
const handleContentUpdate = vi.fn();
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publishedAt: null }),
});
const response = await updateContent({
params: { collection: "post", id: "c1" },
request,
locals: {
emdash: { handleContentUpdate, handleContentGet },
user: makeUser(Role.AUTHOR),
},
cache: makeCache(),
} as Parameters<typeof updateContent>[0]);
expect(response.status).toBe(403);
expect(handleContentUpdate).not.toHaveBeenCalled();
});
it("returns 403 when an AUTHOR tries to set publishedAt on their own post", async () => {
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
const handleContentUpdate = vi.fn();
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publishedAt: "2019-03-15T10:30:00.000Z" }),
});
const response = await updateContent({
params: { collection: "post", id: "c1" },
request,
locals: {
emdash: { handleContentUpdate, handleContentGet },
user: makeUser(Role.AUTHOR),
},
cache: makeCache(),
} as Parameters<typeof updateContent>[0]);
expect(response.status).toBe(403);
expect(handleContentUpdate).not.toHaveBeenCalled();
});
it("lets EDITOR set publishedAt", async () => {
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
const handleContentUpdate = vi.fn().mockResolvedValue({
success: true,
data: {
item: { id: "c1", publishedAt: "2019-03-15T10:30:00.000Z" },
_rev: "rev2",
},
});
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publishedAt: "2019-03-15T10:30:00.000Z" }),
});
const response = await updateContent({
params: { collection: "post", id: "c1" },
request,
locals: {
emdash: { handleContentUpdate, handleContentGet },
user: makeUser(Role.EDITOR),
},
cache: makeCache(),
} as Parameters<typeof updateContent>[0]);
expect(response.status).toBe(200);
expect(handleContentUpdate).toHaveBeenCalledWith(
"post",
"c1",
expect.objectContaining({ publishedAt: "2019-03-15T10:30:00.000Z" }),
);
});
it("lets AUTHOR update their own post without date overrides", async () => {
const handleContentGet = vi.fn().mockResolvedValue(ownedItem);
const handleContentUpdate = vi.fn().mockResolvedValue({
success: true,
data: { item: { id: "c1" }, _rev: "rev2" },
});
const request = new Request("http://localhost/_emdash/api/content/post/c1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: { title: "Edited" } }),
});
const response = await updateContent({
params: { collection: "post", id: "c1" },
request,
locals: {
emdash: { handleContentUpdate, handleContentGet },
user: makeUser(Role.AUTHOR),
},
cache: makeCache(),
} as Parameters<typeof updateContent>[0]);
expect(response.status).toBe(200);
expect(handleContentUpdate).toHaveBeenCalled();
});
});
});