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,331 @@
/**
* MCP ownership / authorization integration tests.
*
* The MCP server's `extractContentAuthorId()` returns "" (empty string)
* for content with null authorId — mirroring the REST handler. Then
* `canActOnOwn(user, "", own, any)` defers to the "any" permission so
* EDITOR+ can edit seed-imported content while CONTRIBUTOR/AUTHOR are
* denied with a clean permission error.
*
* These tests cover every permutation of role × ownership × null-author.
*/
import { Role } from "@emdash-cms/auth";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContentRepository } from "../../../src/database/repositories/content.js";
import type { Database } from "../../../src/database/types.js";
import { connectMcpHarness, extractText, type McpHarness } from "../../utils/mcp-runtime.js";
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
const ADMIN_ID = "user_admin";
const EDITOR_ID = "user_editor";
const AUTHOR_ID = "user_author";
const CONTRIBUTOR_ID = "user_contributor";
const NULL_AUTHOR_ERROR = /no.*authorId|content has no authorId/i;
describe("MCP ownership — null authorId (bug #1)", () => {
let db: Kysely<Database>;
let harness: McpHarness;
async function seedItemWithAuthor(authorId: string | null): Promise<string> {
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Seeded Post" },
slug: `seeded-${Math.random().toString(36).slice(2, 8)}`,
status: "published",
...(authorId !== null ? { authorId } : {}),
});
return item.id;
}
async function connect(role: keyof typeof userIdByRole): Promise<void> {
harness = await connectMcpHarness({
db,
userId: userIdByRole[role],
userRole: roleByName[role],
});
}
const userIdByRole = {
admin: ADMIN_ID,
editor: EDITOR_ID,
author: AUTHOR_ID,
contributor: CONTRIBUTOR_ID,
} as const;
const roleByName = {
admin: Role.ADMIN,
editor: Role.EDITOR,
author: Role.AUTHOR,
contributor: Role.CONTRIBUTOR,
} as const;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
// ----- content_update -----
describe("content_update", () => {
it("ADMIN can update content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("admin");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated by admin" } },
});
// Currently fails with NULL_AUTHOR_ERROR. After fix: succeeds.
expect(result.isError, extractText(result)).toBeFalsy();
});
it("EDITOR can update content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("editor");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated by editor" } },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot update content with null authorId (no ownership claim)", async () => {
const id = await seedItemWithAuthor(null);
await connect("author");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Should fail" } },
});
// AUTHOR has only content:edit_own — without an authorId match,
// they have no "own" claim and lack content:edit_any.
expect(result.isError).toBe(true);
// Negative: NOT the null-author internal error.
expect(extractText(result)).not.toMatch(NULL_AUTHOR_ERROR);
// Positive: clean permission error with the structured code.
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
it("CONTRIBUTOR cannot update content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("contributor");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Should fail" } },
});
expect(result.isError).toBe(true);
expect(extractText(result)).not.toMatch(NULL_AUTHOR_ERROR);
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
});
// ----- content_delete -----
describe("content_delete (trash)", () => {
it("ADMIN can trash content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("admin");
const result = await harness.client.callTool({
name: "content_delete",
arguments: { collection: "post", id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot trash content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("author");
const result = await harness.client.callTool({
name: "content_delete",
arguments: { collection: "post", id },
});
expect(result.isError).toBe(true);
expect(extractText(result)).not.toMatch(NULL_AUTHOR_ERROR);
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
});
// ----- content_publish / content_unpublish -----
describe("publish / unpublish", () => {
it("ADMIN can publish content with null authorId", async () => {
// Create as draft so publish is meaningful
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Draft" },
slug: "draft-null-author",
status: "draft",
});
await connect("admin");
const result = await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id: item.id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("ADMIN can unpublish content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("admin");
const result = await harness.client.callTool({
name: "content_unpublish",
arguments: { collection: "post", id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ----- content_schedule -----
describe("content_schedule", () => {
it("ADMIN can schedule content with null authorId", async () => {
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Sched draft" },
slug: "sched-null-author",
status: "draft",
});
await connect("admin");
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const result = await harness.client.callTool({
name: "content_schedule",
arguments: { collection: "post", id: item.id, scheduledAt: future },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ----- content_restore (from trash) -----
describe("content_restore", () => {
it("ADMIN can restore trashed content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
// Trash via repo to bypass MCP (which we're testing)
const repo = new ContentRepository(db);
await repo.delete("post", id);
await connect("admin");
const result = await harness.client.callTool({
name: "content_restore",
arguments: { collection: "post", id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ----- Sanity checks: ownership behavior unchanged for non-null cases -----
describe("regression guard — ownership still enforced when authorId is set", () => {
it("AUTHOR can update their own content (authorId matches)", async () => {
const id = await seedItemWithAuthor(AUTHOR_ID);
await connect("author");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated own" } },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot update someone else's content (authorId set to other user)", async () => {
const id = await seedItemWithAuthor("user_someone_else");
await connect("author");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated other" } },
});
expect(result.isError).toBe(true);
});
it("EDITOR can update anyone's content (any-permission)", async () => {
const id = await seedItemWithAuthor("user_someone_else");
await connect("editor");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Editor override" } },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
});
describe("MCP ownership — error shape consistency", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("denied-by-permissions error does NOT mention 'authorId' (internal detail)", async () => {
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Test" },
slug: "perm-test",
status: "published",
});
harness = await connectMcpHarness({
db,
userId: AUTHOR_ID,
userRole: Role.AUTHOR,
});
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id: item.id, data: { title: "Nope" } },
});
expect(result.isError).toBe(true);
// Negative: "authorId" is an internal column name and must not leak
// to the user-facing message.
expect(extractText(result)).not.toMatch(/authorId/);
// Positive: the response carries a permissions code so callers can
// distinguish "you can't do this" from any other failure mode.
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
});