/** * 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, overrides: Partial<{ filename: string; mimeType: string; size: number; authorId: string | null; }> = {}, ): Promise { 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; 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; 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; 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; 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; 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); }); });