/** * MCP Authorization Tests * * Verifies that MCP tools enforce ownership checks and role requirements, * mirroring the REST API's authorization patterns. * * Tests use the MCP Client/Server SDK with InMemoryTransport, injecting * authInfo to simulate different users and roles. */ import { Role } from "@emdash-cms/auth"; import type { RoleLevel } from "@emdash-cms/auth"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { EmDashHandlers } from "../../../src/astro/types.js"; import { createMcpServer } from "../../../src/mcp/server.js"; // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- const INSUFFICIENT_PERMISSIONS_RE = /Insufficient permissions/i; const INSUFFICIENT_SCOPE_RE = /Insufficient scope/i; const AUTHOR_USER_ID = "user_author"; const OTHER_USER_ID = "user_other"; const ADMIN_USER_ID = "user_admin"; const CONTENT_ID = "01CONTENT"; const CONTENT_SLUG = "test-post"; const REVISION_ID = "01REVISION"; const MEDIA_ID = "01MEDIA"; // --------------------------------------------------------------------------- // Mock EmDashHandlers // --------------------------------------------------------------------------- /** Create a minimal mock EmDashHandlers that returns content owned by `ownerId`. */ function createMockHandlers(ownerId: string = AUTHOR_USER_ID): EmDashHandlers { const contentItem = { id: CONTENT_ID, slug: "test-post", authorId: ownerId, status: "draft", title: "Test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; const mediaItem = { id: MEDIA_ID, filename: "test.png", authorId: ownerId, mimeType: "image/png", size: 1024, }; return { db: {} as EmDashHandlers["db"], invalidateUrlPatternCache: vi.fn(), handleContentGet: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem, _rev: "rev1" }, }), handleContentGetIncludingTrashed: vi.fn().mockResolvedValue({ success: true, data: { item: { ...contentItem, status: "trashed" } }, }), handleContentList: vi.fn().mockResolvedValue({ success: true, data: { items: [contentItem] }, }), handleContentCreate: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), handleContentUpdate: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), handleContentDelete: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), handleContentRestore: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), handleContentPermanentDelete: vi.fn().mockResolvedValue({ success: true, data: { deleted: true }, }), handleContentPublish: vi.fn().mockResolvedValue({ success: true, data: { item: { ...contentItem, status: "published" } }, }), handleContentUnpublish: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), handleContentSchedule: vi.fn().mockResolvedValue({ success: true, data: { item: { ...contentItem, status: "scheduled" } }, }), handleContentCompare: vi.fn().mockResolvedValue({ success: true, data: { live: null, draft: contentItem, hasChanges: false }, }), handleContentDiscardDraft: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), handleContentListTrashed: vi.fn().mockResolvedValue({ success: true, data: { items: [] }, }), handleContentDuplicate: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), handleContentTranslations: vi.fn().mockResolvedValue({ success: true, data: { translations: [] }, }), handleMediaGet: vi.fn().mockResolvedValue({ success: true, data: { item: mediaItem }, }), handleMediaList: vi.fn().mockResolvedValue({ success: true, data: { items: [mediaItem] }, }), handleMediaUpdate: vi.fn().mockResolvedValue({ success: true, data: { item: mediaItem }, }), handleMediaDelete: vi.fn().mockResolvedValue({ success: true, data: { deleted: true }, }), handleRevisionList: vi.fn().mockResolvedValue({ success: true, data: { items: [] }, }), handleRevisionGet: vi.fn().mockResolvedValue({ success: true, data: { item: { id: REVISION_ID, collection: "post", entryId: CONTENT_ID, authorId: ownerId, data: {}, }, }, }), handleRevisionRestore: vi.fn().mockResolvedValue({ success: true, data: { item: contentItem }, }), } as unknown as EmDashHandlers; } // --------------------------------------------------------------------------- // Transport helper // // InMemoryTransport supports passing authInfo on send(). We create a // subclass that automatically injects authInfo on every message sent from // the client side, simulating the HTTP transport's auth injection. // --------------------------------------------------------------------------- class AuthInjectingTransport extends InMemoryTransport { constructor(private authInfo: Record) { super(); } override async send( message: Parameters[0], options?: Parameters[1], ): Promise { const existingExtra = options?.authInfo && typeof options.authInfo === "object" && "extra" in options.authInfo ? (options.authInfo.extra as Record) : {}; return super.send(message, { ...options, authInfo: { token: "", clientId: "test", scopes: [], ...options?.authInfo, extra: { ...this.authInfo, ...existingExtra, }, }, }); } } /** * Create a linked transport pair where the client side injects authInfo. */ function createAuthenticatedPair(authInfo: { emdash: EmDashHandlers; userId: string; userRole: RoleLevel; tokenScopes?: string[]; }): [AuthInjectingTransport, InMemoryTransport] { const clientTransport = new AuthInjectingTransport(authInfo); const serverTransport = new InMemoryTransport(); // Link them (accessing private field) (clientTransport as unknown as Record)._otherTransport = serverTransport; (serverTransport as unknown as Record)._otherTransport = clientTransport; return [clientTransport, serverTransport]; } // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- async function setupMcpPair(opts: { userId: string; userRole: RoleLevel; handlers?: EmDashHandlers; tokenScopes?: string[]; }): Promise<{ client: Client; cleanup: () => Promise }> { const handlers = opts.handlers ?? createMockHandlers(); const server = createMcpServer(); const [clientTransport, serverTransport] = createAuthenticatedPair({ emdash: handlers, userId: opts.userId, userRole: opts.userRole, tokenScopes: opts.tokenScopes, }); const client = new Client({ name: "test", version: "1.0" }); await server.connect(serverTransport); await client.connect(clientTransport); return { client, cleanup: async () => { await client.close(); await server.close(); }, }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("MCP Authorization", () => { let client: Client; let cleanup: () => Promise; afterEach(async () => { if (cleanup) await cleanup(); }); // ----------------------------------------------------------------------- // Ownership checks: CONTRIBUTOR cannot modify others' content // ----------------------------------------------------------------------- describe("content ownership enforcement", () => { it("CONTRIBUTOR cannot update another user's content", async () => { // Content owned by AUTHOR_USER_ID, caller is OTHER_USER_ID with CONTRIBUTOR role const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.CONTRIBUTOR, handlers, })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "Hacked" }, }, }); // CONTRIBUTOR role is below AUTHOR minimum expect(result.isError).toBe(true); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toMatch(INSUFFICIENT_PERMISSIONS_RE); }); it("AUTHOR can update their own content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "My update" }, }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentUpdate).toHaveBeenCalled(); }); it("AUTHOR cannot update another user's content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "Hacked" }, }, }); expect(result.isError).toBe(true); expect(handlers.handleContentUpdate).not.toHaveBeenCalled(); }); it("EDITOR can update any user's content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.EDITOR, handlers, })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "Editor update" }, }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentUpdate).toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // content_delete ownership // ----------------------------------------------------------------------- describe("content_delete ownership", () => { it("AUTHOR can delete their own content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_delete", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentDelete).toHaveBeenCalled(); }); it("AUTHOR cannot delete another user's content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_delete", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); expect(handlers.handleContentDelete).not.toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // content_permanent_delete: ADMIN only // ----------------------------------------------------------------------- describe("content_permanent_delete requires ADMIN", () => { it("EDITOR cannot permanently delete content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.EDITOR, handlers, })); const result = await client.callTool({ name: "content_permanent_delete", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); expect(handlers.handleContentPermanentDelete).not.toHaveBeenCalled(); }); it("ADMIN can permanently delete content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.ADMIN, handlers, })); const result = await client.callTool({ name: "content_permanent_delete", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentPermanentDelete).toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // content_publish ownership // ----------------------------------------------------------------------- describe("content_publish ownership", () => { it("AUTHOR can publish their own content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_publish", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentPublish).toHaveBeenCalled(); }); it("AUTHOR cannot publish another user's content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_publish", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); expect(handlers.handleContentPublish).not.toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // content_restore ownership // ----------------------------------------------------------------------- describe("content_restore ownership", () => { it("AUTHOR cannot restore another user's trashed content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_restore", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); expect(handlers.handleContentRestore).not.toHaveBeenCalled(); }); it("EDITOR can restore any user's trashed content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.EDITOR, handlers, })); const result = await client.callTool({ name: "content_restore", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentRestore).toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // revision_restore ownership // ----------------------------------------------------------------------- describe("revision_restore ownership", () => { it("AUTHOR cannot restore revision on another user's content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "revision_restore", arguments: { revisionId: REVISION_ID }, }); expect(result.isError).toBe(true); expect(handlers.handleRevisionRestore).not.toHaveBeenCalled(); }); it("EDITOR can restore revision on any content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.EDITOR, handlers, })); const result = await client.callTool({ name: "revision_restore", arguments: { revisionId: REVISION_ID }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleRevisionRestore).toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // Media ownership // ----------------------------------------------------------------------- describe("media ownership enforcement", () => { it("AUTHOR can update their own media", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "media_update", arguments: { id: MEDIA_ID, alt: "Updated alt" }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleMediaUpdate).toHaveBeenCalled(); }); it("AUTHOR cannot update another user's media", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "media_update", arguments: { id: MEDIA_ID, alt: "Hacked" }, }); expect(result.isError).toBe(true); expect(handlers.handleMediaUpdate).not.toHaveBeenCalled(); }); it("AUTHOR cannot delete another user's media", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "media_delete", arguments: { id: MEDIA_ID }, }); expect(result.isError).toBe(true); expect(handlers.handleMediaDelete).not.toHaveBeenCalled(); }); it("EDITOR can delete any user's media", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.EDITOR, handlers, })); const result = await client.callTool({ name: "media_delete", arguments: { id: MEDIA_ID }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleMediaDelete).toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // Token scope enforcement // ----------------------------------------------------------------------- describe("token scope enforcement", () => { it("rejects content_update without content:write scope", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.ADMIN, handlers, tokenScopes: ["content:read"], })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "No scope" }, }, }); expect(result.isError).toBe(true); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toMatch(INSUFFICIENT_SCOPE_RE); }); it("allows content_update with content:write scope", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, tokenScopes: ["content:read", "content:write"], })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "Valid scope" }, }, }); expect(result.isError).toBeFalsy(); }); it("session auth (no tokenScopes) allows all scopes", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, // No tokenScopes = session auth })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "Session auth" }, }, }); expect(result.isError).toBeFalsy(); }); }); // ----------------------------------------------------------------------- // content_schedule ownership // ----------------------------------------------------------------------- describe("content_schedule ownership", () => { it("AUTHOR cannot schedule another user's content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_schedule", arguments: { collection: "post", id: CONTENT_ID, scheduledAt: "2030-01-01T00:00:00Z", }, }); expect(result.isError).toBe(true); expect(handlers.handleContentSchedule).not.toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // content_unpublish ownership // ----------------------------------------------------------------------- describe("content_unpublish ownership", () => { it("AUTHOR cannot unpublish another user's content", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_unpublish", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); expect(handlers.handleContentUnpublish).not.toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // resolvedId: slug -> ULID resolution before handler calls // ----------------------------------------------------------------------- describe("resolvedId passthrough", () => { it("content_restore passes resolvedId (not slug) to handler", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_restore", arguments: { collection: "post", id: CONTENT_SLUG }, }); expect(result.isError).toBeFalsy(); // The mock returns item.id = CONTENT_ID. The tool should resolve // the slug to CONTENT_ID via extractContentId and pass that to the handler. expect(handlers.handleContentRestore).toHaveBeenCalledWith("post", CONTENT_ID); }); it("content_discard_draft passes resolvedId (not slug) to handler", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_discard_draft", arguments: { collection: "post", id: CONTENT_SLUG }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentDiscardDraft).toHaveBeenCalledWith("post", CONTENT_ID); }); it("content_update passes resolvedId (not slug) to handler", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_SLUG, data: { title: "Updated" }, }, }); expect(result.isError).toBeFalsy(); expect(handlers.handleContentUpdate).toHaveBeenCalledWith( "post", CONTENT_ID, expect.objectContaining({ data: { title: "Updated" } }), ); }); }); // ----------------------------------------------------------------------- // extractContentAuthorId: missing authorId // ----------------------------------------------------------------------- describe("missing authorId handling", () => { // Content with null/missing authorId (e.g. seed-imported rows) must be // editable by anyone with `*:edit_any` and rejected for actors with only // `*:edit_own` — without leaking internal "authorId" wording. const contentWithoutAuthor = { id: CONTENT_ID, slug: "imported-post", status: "draft", title: "Imported", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), // no authorId }; it("ADMIN succeeds on content with null authorId", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); handlers.handleContentGet = vi.fn().mockResolvedValue({ success: true, data: { item: contentWithoutAuthor }, }); ({ client, cleanup } = await setupMcpPair({ userId: ADMIN_USER_ID, userRole: Role.ADMIN, handlers, })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "ok" }, }, }); expect(result.isError).toBeFalsy(); }); it("AUTHOR denied on content with null authorId (clean permission error)", async () => { const handlers = createMockHandlers(AUTHOR_USER_ID); handlers.handleContentGet = vi.fn().mockResolvedValue({ success: true, data: { item: contentWithoutAuthor }, }); ({ client, cleanup } = await setupMcpPair({ userId: AUTHOR_USER_ID, userRole: Role.AUTHOR, handlers, })); const result = await client.callTool({ name: "content_update", arguments: { collection: "post", id: CONTENT_ID, data: { title: "Should fail" }, }, }); expect(result.isError).toBe(true); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toMatch(INSUFFICIENT_PERMISSIONS_RE); expect(text).not.toMatch(/authorId/); }); }); // ----------------------------------------------------------------------- // Draft visibility — SUBSCRIBER must not see non-published content // ----------------------------------------------------------------------- describe("draft visibility", () => { it("forces status=published on content_list for SUBSCRIBER", async () => { const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); await client.callTool({ name: "content_list", arguments: { collection: "post" }, }); expect(handlers.handleContentList).toHaveBeenCalledWith( "post", expect.objectContaining({ status: "published" }), ); }); it("ignores caller-supplied status=draft from SUBSCRIBER on content_list", async () => { const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); await client.callTool({ name: "content_list", arguments: { collection: "post", status: "draft" }, }); // Even though caller asked for "draft", route forces "published" expect(handlers.handleContentList).toHaveBeenCalledWith( "post", expect.objectContaining({ status: "published" }), ); }); it("respects caller-supplied status filter for CONTRIBUTOR on content_list", async () => { const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.CONTRIBUTOR, handlers, })); await client.callTool({ name: "content_list", arguments: { collection: "post", status: "draft" }, }); expect(handlers.handleContentList).toHaveBeenCalledWith( "post", expect.objectContaining({ status: "draft" }), ); }); it("hides draft items from SUBSCRIBER on content_get", async () => { // Default mock returns status: "draft" const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); const result = await client.callTool({ name: "content_get", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toMatch(/not found/i); }); it("allows SUBSCRIBER to read published items via content_get", async () => { const handlers = createMockHandlers(); // Override to return published content handlers.handleContentGet = vi.fn().mockResolvedValue({ success: true, data: { item: { id: CONTENT_ID, slug: CONTENT_SLUG, authorId: AUTHOR_USER_ID, status: "published", }, _rev: "rev1", }, }); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); const result = await client.callTool({ name: "content_get", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); }); it("allows CONTRIBUTOR to read drafts via content_get", async () => { const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.CONTRIBUTOR, handlers, })); const result = await client.callTool({ name: "content_get", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); }); it("denies SUBSCRIBER on content_compare", async () => { const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); const result = await client.callTool({ name: "content_compare", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toMatch(INSUFFICIENT_PERMISSIONS_RE); expect(handlers.handleContentCompare).not.toHaveBeenCalled(); }); it("denies SUBSCRIBER on content_list_trashed", async () => { const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); const result = await client.callTool({ name: "content_list_trashed", arguments: { collection: "post" }, }); expect(result.isError).toBe(true); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toMatch(INSUFFICIENT_PERMISSIONS_RE); expect(handlers.handleContentListTrashed).not.toHaveBeenCalled(); }); it("denies SUBSCRIBER on revision_list", async () => { const handlers = createMockHandlers(); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); const result = await client.callTool({ name: "revision_list", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBe(true); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toMatch(INSUFFICIENT_PERMISSIONS_RE); expect(handlers.handleRevisionList).not.toHaveBeenCalled(); }); it("filters non-published translations for SUBSCRIBER", async () => { const handlers = createMockHandlers(); handlers.handleContentTranslations = vi.fn().mockResolvedValue({ success: true, data: { translationGroup: "tg-1", translations: [ { id: "t-en", locale: "en", slug: "p", status: "published", updatedAt: "" }, { id: "t-fr", locale: "fr", slug: "p", status: "draft", updatedAt: "" }, ], }, }); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.SUBSCRIBER, handlers, })); const result = await client.callTool({ name: "content_translations", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toContain("t-en"); expect(text).not.toContain("t-fr"); }); it("allows CONTRIBUTOR to see all translations", async () => { const handlers = createMockHandlers(); handlers.handleContentTranslations = vi.fn().mockResolvedValue({ success: true, data: { translationGroup: "tg-1", translations: [ { id: "t-en", locale: "en", slug: "p", status: "published", updatedAt: "" }, { id: "t-fr", locale: "fr", slug: "p", status: "draft", updatedAt: "" }, ], }, }); ({ client, cleanup } = await setupMcpPair({ userId: OTHER_USER_ID, userRole: Role.CONTRIBUTOR, handlers, })); const result = await client.callTool({ name: "content_translations", arguments: { collection: "post", id: CONTENT_ID }, }); expect(result.isError).toBeFalsy(); const text = (result.content as Array<{ text: string }>)[0]?.text ?? ""; expect(text).toContain("t-en"); expect(text).toContain("t-fr"); }); }); });