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,469 @@
/**
* MCP media tools — comprehensive integration tests.
*
* Covers:
* - media_list (incl. mimeType filter, pagination)
* - media_get
* - media_update (incl. ownership)
* - media_delete (incl. ownership)
*
* Plus regression for bug #14 (no media_upload tool gap) and bug #1
* variants for media (the MCP code already handles null authorId
* correctly for media — `media_update`/`media_delete` use `... || ""`,
* unlike content extraction).
*/
import { Role } from "@emdash-cms/auth";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MediaRepository } from "../../../src/database/repositories/media.js";
import type { Database } from "../../../src/database/types.js";
import {
connectMcpHarness,
extractJson,
extractText,
type McpHarness,
} from "../../utils/mcp-runtime.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
const ADMIN_ID = "user_admin";
const EDITOR_ID = "user_editor";
const AUTHOR_ID = "user_author";
const OTHER_AUTHOR_ID = "user_other_author";
const SUBSCRIBER_ID = "user_subscriber";
async function seedMedia(
db: Kysely<Database>,
overrides: Partial<{
filename: string;
mimeType: string;
size: number;
authorId: string | null;
}> = {},
): Promise<string> {
const repo = new MediaRepository(db);
const item = await repo.create({
filename: overrides.filename ?? `file-${Math.random().toString(36).slice(2, 8)}.png`,
mimeType: overrides.mimeType ?? "image/png",
size: overrides.size ?? 1024,
storageKey: `media/${Math.random().toString(36).slice(2, 10)}`,
...(overrides.authorId !== null ? { authorId: overrides.authorId ?? ADMIN_ID } : {}),
});
return item.id;
}
// ---------------------------------------------------------------------------
// media_list
// ---------------------------------------------------------------------------
describe("media_list", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("returns empty list when no media exists", async () => {
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_list",
arguments: {},
});
expect(result.isError, extractText(result)).toBeFalsy();
const { items } = extractJson<{ items: unknown[] }>(result);
expect(items).toEqual([]);
});
it("lists all uploaded media", async () => {
await seedMedia(db);
await seedMedia(db);
await seedMedia(db);
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_list",
arguments: {},
});
const { items } = extractJson<{ items: unknown[] }>(result);
expect(items).toHaveLength(3);
});
it("filters by mimeType prefix", async () => {
await seedMedia(db, { mimeType: "image/png" });
await seedMedia(db, { mimeType: "image/jpeg" });
await seedMedia(db, { mimeType: "application/pdf" });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_list",
arguments: { mimeType: "image/" },
});
const { items } = extractJson<{ items: Array<{ mimeType: string }> }>(result);
expect(items).toHaveLength(2);
for (const item of items) {
expect(item.mimeType.startsWith("image/")).toBe(true);
}
});
it("paginates with cursor", async () => {
for (let i = 0; i < 5; i++) await seedMedia(db);
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const page1 = await harness.client.callTool({
name: "media_list",
arguments: { limit: 2 },
});
const p1 = extractJson<{ items: Array<{ id: string }>; nextCursor?: string }>(page1);
expect(p1.items).toHaveLength(2);
expect(p1.nextCursor).toBeTruthy();
const page2 = await harness.client.callTool({
name: "media_list",
arguments: { limit: 2, cursor: p1.nextCursor },
});
const p2 = extractJson<{ items: Array<{ id: string }> }>(page2);
expect(p2.items).toHaveLength(2);
const p1Ids = p1.items.map((i) => i.id);
for (const item of p2.items) expect(p1Ids).not.toContain(item.id);
});
it("any logged-in user can list media", async () => {
await seedMedia(db);
harness = await connectMcpHarness({ db, userId: SUBSCRIBER_ID, userRole: Role.SUBSCRIBER });
const result = await harness.client.callTool({
name: "media_list",
arguments: {},
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ---------------------------------------------------------------------------
// media_get
// ---------------------------------------------------------------------------
describe("media_get", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("returns full media metadata", async () => {
const id = await seedMedia(db, {
filename: "logo.png",
mimeType: "image/png",
size: 2048,
});
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_get",
arguments: { id },
});
expect(result.isError, extractText(result)).toBeFalsy();
const data = extractJson<{
item: { id: string; filename: string; mimeType: string; size: number };
}>(result);
expect(data.item.id).toBe(id);
expect(data.item.filename).toBe("logo.png");
expect(data.item.mimeType).toBe("image/png");
expect(data.item.size).toBe(2048);
});
it("returns NOT_FOUND for missing id", async () => {
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_get",
arguments: { id: "01NOTAMEDIAID" },
});
expect(result.isError).toBe(true);
expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i);
expect(extractText(result)).toContain("01NOTAMEDIAID");
});
it("any logged-in user can get media", async () => {
const id = await seedMedia(db);
harness = await connectMcpHarness({ db, userId: SUBSCRIBER_ID, userRole: Role.SUBSCRIBER });
const result = await harness.client.callTool({
name: "media_get",
arguments: { id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ---------------------------------------------------------------------------
// media_update
// ---------------------------------------------------------------------------
describe("media_update", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("updates alt text and caption", async () => {
const id = await seedMedia(db, { authorId: ADMIN_ID });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id, alt: "Logo image", caption: "Brand logo" },
});
expect(result.isError, extractText(result)).toBeFalsy();
const data = extractJson<{ item: { alt: string; caption: string } }>(result);
expect(data.item.alt).toBe("Logo image");
expect(data.item.caption).toBe("Brand logo");
});
it("updates dimensions", async () => {
const id = await seedMedia(db);
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id, width: 1920, height: 1080 },
});
expect(result.isError, extractText(result)).toBeFalsy();
const data = extractJson<{ item: { width: number; height: number } }>(result);
expect(data.item.width).toBe(1920);
expect(data.item.height).toBe(1080);
});
it("AUTHOR can update their own media", async () => {
const id = await seedMedia(db, { authorId: AUTHOR_ID });
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id, alt: "Mine" },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot update another user's media", async () => {
const id = await seedMedia(db, { authorId: OTHER_AUTHOR_ID });
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id, alt: "Theirs" },
});
expect(result.isError).toBe(true);
});
it("EDITOR can update any user's media", async () => {
const id = await seedMedia(db, { authorId: OTHER_AUTHOR_ID });
harness = await connectMcpHarness({ db, userId: EDITOR_ID, userRole: Role.EDITOR });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id, alt: "Editor override" },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("ADMIN can update media with null authorId (regression: this works for media but not content — bug #1 inconsistency)", async () => {
const id = await seedMedia(db, { authorId: null });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id, alt: "No author" },
});
// Already works correctly for media — confirms the fix path for
// content (use `... || ""` instead of throwing).
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot update media with null authorId (no ownership claim)", async () => {
const id = await seedMedia(db, { authorId: null });
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id, alt: "Should fail" },
});
expect(result.isError).toBe(true);
});
it("returns NOT_FOUND-style error for missing id", async () => {
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_update",
arguments: { id: "01NEVEREXISTED", alt: "x" },
});
expect(result.isError).toBe(true);
expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i);
expect(extractText(result)).toContain("01NEVEREXISTED");
});
});
// ---------------------------------------------------------------------------
// media_delete
// ---------------------------------------------------------------------------
describe("media_delete", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("deletes a media item", async () => {
const id = await seedMedia(db, { authorId: ADMIN_ID });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_delete",
arguments: { id },
});
expect(result.isError, extractText(result)).toBeFalsy();
// Verify it's gone
const got = await harness.client.callTool({
name: "media_get",
arguments: { id },
});
expect(got.isError).toBe(true);
});
it("AUTHOR cannot delete another user's media", async () => {
const id = await seedMedia(db, { authorId: OTHER_AUTHOR_ID });
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
const result = await harness.client.callTool({
name: "media_delete",
arguments: { id },
});
expect(result.isError).toBe(true);
});
it("EDITOR can delete any user's media", async () => {
const id = await seedMedia(db, { authorId: OTHER_AUTHOR_ID });
harness = await connectMcpHarness({ db, userId: EDITOR_ID, userRole: Role.EDITOR });
const result = await harness.client.callTool({
name: "media_delete",
arguments: { id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("returns NOT_FOUND for missing id", async () => {
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "media_delete",
arguments: { id: "01NOPE" },
});
expect(result.isError).toBe(true);
expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i);
expect(extractText(result)).toContain("01NOPE");
});
it("delete is idempotent — second delete on same id returns NOT_FOUND, not crash", async () => {
const id = await seedMedia(db, { authorId: ADMIN_ID });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
await harness.client.callTool({ name: "media_delete", arguments: { id } });
const result = await harness.client.callTool({
name: "media_delete",
arguments: { id },
});
expect(result.isError).toBe(true);
expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i);
});
});
// ---------------------------------------------------------------------------
// Bug #14 — gap: media_create tool is now available
// F1: media_create persists authorId so ownership checks subsequently succeed
// ---------------------------------------------------------------------------
describe("media_create (bug #14 / F1)", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("MCP exposes media_create", async () => {
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const tools = await harness.client.listTools();
const names = new Set(tools.tools.map((t) => t.name));
expect(names.has("media_create")).toBe(true);
});
it("AUTHOR creates media; subsequent media_get returns it; same author can update; different author cannot", async () => {
// AUTHOR creates the media item via media_create.
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
const create = await harness.client.callTool({
name: "media_create",
arguments: {
filename: "logo.png",
mimeType: "image/png",
storageKey: "media/logo-key",
size: 4096,
},
});
expect(create.isError, extractText(create)).toBeFalsy();
const created = extractJson<{ item: { id: string; filename: string } }>(create);
expect(created.item.filename).toBe("logo.png");
// media_get returns the same id.
const got = await harness.client.callTool({
name: "media_get",
arguments: { id: created.item.id },
});
expect(got.isError, extractText(got)).toBeFalsy();
const fetched = extractJson<{ item: { id: string } }>(got);
expect(fetched.item.id).toBe(created.item.id);
// Same AUTHOR can update — proves authorId was persisted.
const ownUpdate = await harness.client.callTool({
name: "media_update",
arguments: { id: created.item.id, alt: "company logo" },
});
expect(ownUpdate.isError, extractText(ownUpdate)).toBeFalsy();
await harness.cleanup();
// A different AUTHOR is denied.
harness = await connectMcpHarness({
db,
userId: OTHER_AUTHOR_ID,
userRole: Role.AUTHOR,
});
const otherUpdate = await harness.client.callTool({
name: "media_update",
arguments: { id: created.item.id, alt: "intruder caption" },
});
expect(otherUpdate.isError).toBe(true);
expect(extractText(otherUpdate)).toMatch(/insufficient|permission|forbidden/i);
});
});