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:
469
packages/core/tests/integration/mcp/media.test.ts
Normal file
469
packages/core/tests/integration/mcp/media.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user