diff --git a/.changeset/entry-edit-lock.md b/.changeset/entry-edit-lock.md index 5a58c05708..930a1b5e7a 100644 --- a/.changeset/entry-edit-lock.md +++ b/.changeset/entry-edit-lock.md @@ -11,7 +11,7 @@ The lock lasts seven minutes. The admin renews it every two minutes while the en #### Who is newly refused -Scripts, API tokens and the CLI that update, delete, publish, unpublish, schedule or discard an entry while an editor has it open in the admin now receive `409 ENTRY_LOCKED` where the write used to succeed. This applies to every collection once the migration has run. The response's `error.message` names the holder and `error.details` carries their `userId`, `userName`, `acquiredAt` and `expiresAt`. Pass `"overrideLock": true` in the request body to write anyway, or `?overrideLock=true` on `DELETE`, which has no body. The CLI takes `--override-lock` on `content update`, `content delete`, `content publish`, `content unpublish` and `content schedule`. The MCP content tools do not honour the lock yet. +Scripts, API tokens and the CLI that update, delete, publish, unpublish, schedule or discard an entry while an editor has it open in the admin now receive `409 ENTRY_LOCKED` where the write used to succeed. This applies to every collection once the migration has run. The response's `error.message` names the holder and `error.details` carries their `userId`, `userName`, `acquiredAt` and `expiresAt`. Pass `"overrideLock": true` in the request body to write anyway, or `?overrideLock=true` on `DELETE`, which has no body. The CLI takes `--override-lock` on `content update`, `content delete`, `content publish`, `content unpublish` and `content schedule`. Locks are per entry and per locale, so two translations of the same entry can be edited at once. diff --git a/.changeset/mcp-honour-entry-lock.md b/.changeset/mcp-honour-entry-lock.md new file mode 100644 index 0000000000..25d56e37c2 --- /dev/null +++ b/.changeset/mcp-honour-entry-lock.md @@ -0,0 +1,11 @@ +--- +"emdash": minor +--- + +Updates the MCP content tools and revision restore to honor an entry's edit lock, so an AI tool connected over MCP no longer overwrites an entry that someone else has open in the admin. + +`content_update`, `content_delete`, `content_publish`, `content_unpublish`, `content_schedule`, `content_unschedule`, `content_discard_draft` and `revision_restore` fail with `ENTRY_LOCKED` while another user holds the entry's lock, where the call used to succeed. The error message names the holder, and `_meta.details` carries their `userId`, `userName`, `acquiredAt` and `expiresAt`. Reading the item again does not clear the refusal. Each of these tools takes an optional `overrideLock: true` to write anyway. + +`POST /_emdash/api/revisions/{revisionId}/restore` now returns `409 ENTRY_LOCKED` in the same case. Pass `"overrideLock": true` in the request body to restore anyway. + +To keep the previous behavior for a whole collection, switch edit locking off for it under **Content Types** → your collection → **Edit locking**. diff --git a/docs/src/content/docs/guides/working-with-content.mdx b/docs/src/content/docs/guides/working-with-content.mdx index 9a1c5e2bab..b0f0213511 100644 --- a/docs/src/content/docs/guides/working-with-content.mdx +++ b/docs/src/content/docs/guides/working-with-content.mdx @@ -103,9 +103,11 @@ If the browser or computer closes without releasing it, the lock expires seven m last renewal. Locks are independent for each locale, so two people can edit different translations of the same -entry. A script or API client is also refused while another editor holds the lock unless it uses the -documented override. See [Entry edit lock](/reference/rest-api/#entry-edit-lock) for the complete -API behavior. +entry. A script, API client, or MCP client is also refused while another editor holds the lock +unless it uses the documented override. See [Entry edit lock](/reference/rest-api/#entry-edit-lock) +for the complete API behavior and +[Content lifecycle and bylines](/reference/mcp-server/#content-lifecycle-and-bylines) for the MCP +tools. An administrator can turn locking off for a collection under **Content Types**, then the collection, then **Edit locking**. diff --git a/docs/src/content/docs/reference/mcp-server.mdx b/docs/src/content/docs/reference/mcp-server.mdx index 2b1843cf87..0b34b88ba6 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -218,7 +218,7 @@ The result is returned as JSON text in the first content block. A tool with an o Bylines are reusable author or contributor credits. `byline_create` can create a guest credit or link a byline to a CMS user. Pass the returned byline ID in the `bylines` input accepted by `content_create` and `content_update`. Deleting a byline removes that credit from content and clears it as the primary byline. -MCP writes do not participate in the admin's [entry edit lock](/reference/rest-api/#entry-edit-lock). The `_rev` check protects the operations that accept it, but other write tools can change an entry while an editor has it open. +While another user holds an entry's [edit lock](/reference/rest-api/#entry-edit-lock) because they have it open in the admin, `content_update`, `content_delete`, `content_publish`, `content_unpublish`, `content_schedule`, `content_unschedule`, `content_discard_draft` and `revision_restore` fail with `ENTRY_LOCKED`, and the error names the holder. Reading the item again does not clear the refusal. Pass `overrideLock: true` to write anyway. ## Translations @@ -283,4 +283,22 @@ A tool failure has `isError: true`. The first text block starts with a stable co } ``` +A refusal from an entry's edit lock carries the holder in `_meta.details`: + +```json +{ + "content": [{ "type": "text", "text": "[ENTRY_LOCKED] Ada is holding this entry" }], + "isError": true, + "_meta": { + "code": "ENTRY_LOCKED", + "details": { + "userId": "01JB...", + "userName": "Ada", + "acquiredAt": "2026-05-01T09:12:04.117Z", + "expiresAt": "2026-05-01T09:19:04.117Z" + } + } +} +``` + Authentication failures use codes such as `INSUFFICIENT_SCOPE` and `INSUFFICIENT_PERMISSIONS`. Transport failures use the JSON-RPC internal-error code `-32603` and do not expose the underlying exception. diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index 1abf8c217a..81eca729d9 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -242,6 +242,10 @@ export const contentRevisionConditionBody = z.object({ overrideLock: overrideLockFlag, }); +export const revisionRestoreBody = z.object({ + overrideLock: overrideLockFlag, +}); + export const contentPublishBody = contentRevisionConditionBody .extend({ // .optional() rather than .nullish(): publishing has no semantic diff --git a/packages/core/src/astro/routes/api/revisions/[revisionId]/restore.ts b/packages/core/src/astro/routes/api/revisions/[revisionId]/restore.ts index 81a9c837a6..ff726be03b 100644 --- a/packages/core/src/astro/routes/api/revisions/[revisionId]/restore.ts +++ b/packages/core/src/astro/routes/api/revisions/[revisionId]/restore.ts @@ -2,18 +2,25 @@ * Restore revision endpoint - injected by EmDash integration * * POST /_emdash/api/revisions/{revisionId}/restore - Restore revision + * + * Optional JSON body: { overrideLock?: boolean } */ import type { APIRoute } from "astro"; import { requireOwnerPerm } from "#api/authorize.js"; import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js"; +import { claimEntryLockForWrite } from "#api/handlers/entry-lock.js"; +import { isParseError, parseOptionalBody } from "#api/parse.js"; +import { revisionRestoreBody } from "#api/schemas.js"; export const prerender = false; -export const POST: APIRoute = async ({ params, locals }) => { +export const POST: APIRoute = async ({ params, request, locals }) => { const { emdash, user } = locals; const revisionId = params.revisionId!; + const body = await parseOptionalBody(request, revisionRestoreBody, {}); + if (isParseError(body)) return body; if (!emdash?.handleRevisionRestore || !emdash?.handleRevisionGet || !emdash?.handleContentGet) { return apiError("NOT_CONFIGURED", "EmDash not configured", 500); @@ -52,6 +59,15 @@ export const POST: APIRoute = async ({ params, locals }) => { const denied = requireOwnerPerm(user, authorId, "content:edit_own", "content:edit_any"); if (denied) return denied; + const refusal = await claimEntryLockForWrite(emdash.db, collection, entryId, user!.id, { + override: body.overrideLock, + }); + if (refusal) { + return apiError(refusal.code, refusal.message, mapErrorStatus(refusal.code), { + ...refusal.details, + }); + } + const result = await emdash.handleRevisionRestore(revisionId, user!.id); return unwrapResult(result); diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index ac7e0e7c77..1254f49c30 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -27,6 +27,7 @@ import { updateTaxonomyDefBody, } from "#api/schemas.js"; +import { claimEntryLockForWrite } from "../api/handlers/entry-lock.js"; import type { MediaUsageRepairRequest } from "../api/schemas/media-usage.js"; import type { EmDashHandlers } from "../astro/types.js"; import { hasScope } from "../auth/api-tokens.js"; @@ -56,6 +57,17 @@ const REV_PARAM_DESCRIPTION = const REV_MISSING_ERROR = "_rev is required: call content_get for this item and pass back the _rev it returns."; +/** + * Shared wording for the `overrideLock` parameter on write tools. Agents only + * see the tool schema, and re-reading never clears ENTRY_LOCKED, so when to + * override is stated here. + */ +const OVERRIDE_LOCK_PARAM_DESCRIPTION = + "Write even though someone else has this item open in the admin. Without it, " + + "the call fails with ENTRY_LOCKED and the error names who is editing; calling " + + "content_get again does not clear it. Tell the user who holds the item, and " + + "set this only if they ask you to write anyway."; + const TAXONOMY_CURSOR_VERSION = 2; const MAX_TAXONOMY_CURSOR_LENGTH = 2048; const contentDateTimeInputSchema = z.iso @@ -657,6 +669,24 @@ function extractContentId(data: unknown): string | undefined { return typeof item?.id === "string" ? item.id : undefined; } +/** + * Refuses a write while another user holds the entry's edit lock. Resolves to + * `null` when the write may proceed, and extends the caller's own lease if they + * hold it. + */ +async function refuseLockedEntry( + extra: { authInfo?: { extra?: Record } }, + collection: string, + entryId: string, + overrideLock: boolean | undefined, +): Promise { + const { emdash, userId } = getExtra(extra); + const refusal = await claimEntryLockForWrite(emdash.db, collection, entryId, userId, { + override: overrideLock, + }); + return refusal ? respondError(refusal.code, refusal.message, { ...refusal.details }) : null; +} + /** Extract the `_rev` token from a content handler response. */ function extractContentRev(data: unknown): string | undefined { if (!data || typeof data !== "object") return undefined; @@ -1103,6 +1133,7 @@ export function createMcpServer( "Override the publication timestamp (ISO 8601). Requires content:publish_any permission. Pass null to clear. Useful for content migrations.", ), _rev: z.string({ error: REV_MISSING_ERROR }).describe(REV_PARAM_DESCRIPTION), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), }, async (args, extra) => { @@ -1138,9 +1169,15 @@ export function createMcpServer( const resolvedId = extractContentId(existing.data) ?? args.id; + if (args.status !== undefined) { + requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any"); + } + + const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock); + if (locked) return locked; + // Status transitions route through dedicated handlers for proper revision management if (args.status === "published") { - requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any"); let rev: string | undefined = args._rev; if ( args.data || @@ -1170,7 +1207,6 @@ export function createMcpServer( } if (args.status === "draft") { - requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any"); let rev: string | undefined = args._rev; if ( args.data || @@ -1226,6 +1262,7 @@ export function createMcpServer( inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), annotations: { destructiveHint: true }, }, @@ -1247,6 +1284,8 @@ export function createMcpServer( ); const resolvedId = extractContentId(existing.data) ?? args.id; + const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock); + if (locked) return locked; return unwrap(await ec.handleContentDelete(args.collection, resolvedId)); }, ); @@ -1325,6 +1364,7 @@ export function createMcpServer( .describe( "Override publication timestamp (ISO 8601). Requires content:publish_any permission. Useful when importing content with original publish dates.", ), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), }, async (args, extra) => { @@ -1353,6 +1393,8 @@ export function createMcpServer( } const resolvedId = extractContentId(existing.data) ?? args.id; + const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock); + if (locked) return locked; return unwrap( await emdash.handleContentPublish(args.collection, resolvedId, { publishedAt: args.publishedAt, @@ -1373,6 +1415,7 @@ export function createMcpServer( collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), _rev: z.string({ error: REV_MISSING_ERROR }).describe(REV_PARAM_DESCRIPTION), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), }, async (args, extra) => { @@ -1393,6 +1436,8 @@ export function createMcpServer( ); const resolvedId = extractContentId(existing.data) ?? args.id; + const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock); + if (locked) return locked; return unwrap( await ec.handleContentUnpublish(args.collection, resolvedId, { _rev: args._rev }), ); @@ -1413,6 +1458,7 @@ export function createMcpServer( scheduledAt: z .string() .describe("ISO 8601 datetime for publication (e.g. '2025-06-01T09:00:00Z')"), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), }, async (args, extra) => { @@ -1433,6 +1479,8 @@ export function createMcpServer( ); const resolvedId = extractContentId(existing.data) ?? args.id; + const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock); + if (locked) return locked; return unwrap(await ec.handleContentSchedule(args.collection, resolvedId, args.scheduledAt)); }, ); @@ -1448,6 +1496,7 @@ export function createMcpServer( inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), }, async (args, extra) => { @@ -1467,6 +1516,8 @@ export function createMcpServer( ); const resolvedId = extractContentId(existing.data) ?? args.id; + const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock); + if (locked) return locked; return unwrap(await ec.handleContentUnschedule(args.collection, resolvedId)); }, ); @@ -1504,6 +1555,7 @@ export function createMcpServer( collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), _rev: z.string({ error: REV_MISSING_ERROR }).describe(REV_PARAM_DESCRIPTION), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), annotations: { destructiveHint: true }, }, @@ -1525,6 +1577,8 @@ export function createMcpServer( ); const resolvedId = extractContentId(existing.data) ?? args.id; + const locked = await refuseLockedEntry(extra, args.collection, resolvedId, args.overrideLock); + if (locked) return locked; return unwrap( await ec.handleContentDiscardDraft(args.collection, resolvedId, { _rev: args._rev }), ); @@ -3164,6 +3218,7 @@ export function createMcpServer( "use content_publish afterward if needed.", inputSchema: z.object({ revisionId: z.string().describe("Revision ID to restore"), + overrideLock: z.boolean().optional().describe(OVERRIDE_LOCK_PARAM_DESCRIPTION), }), }, async (args, extra) => { @@ -3196,6 +3251,14 @@ export function createMcpServer( "content:edit_any", ); + const locked = await refuseLockedEntry( + extra, + revItem.collection, + revItem.entryId, + args.overrideLock, + ); + if (locked) return locked; + return unwrap(await emdash.handleRevisionRestore(args.revisionId, userId)); }, ); diff --git a/packages/core/tests/integration/mcp/entry-lock.test.ts b/packages/core/tests/integration/mcp/entry-lock.test.ts new file mode 100644 index 0000000000..3027c8abb5 --- /dev/null +++ b/packages/core/tests/integration/mcp/entry-lock.test.ts @@ -0,0 +1,240 @@ +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { handleEntryLockAcquire } from "../../../src/api/handlers/entry-lock.js"; +import { RevisionRepository } from "../../../src/database/repositories/revision.js"; +import type { Database } from "../../../src/database/types.js"; +import { + connectMcpHarness, + currentRev, + extractJson, + extractText, + type McpHarness, +} from "../../utils/mcp-runtime.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +const ADA = "user_ada"; +const LINUS = "user_linus"; + +interface Item { + id: string; + status: string; + scheduledAt?: string | null; + data: { title?: string }; +} + +interface LockCase { + label: string; + tool: string; + /** Brings an entry into a state the tool can act on. */ + prepare: () => Promise; + args: (id: string) => Promise>; + /** Asserts that the refused call left the entry as it was. */ + unchanged: (item: Item) => void; +} + +describe("MCP content writes against an entry edit lock", () => { + let db: Kysely; + let harness: McpHarness; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + await db + .insertInto("users") + .values([ + { id: ADA, email: "ada@example.com", name: "Ada", role: Role.EDITOR, email_verified: 1 }, + { + id: LINUS, + email: "linus@example.com", + name: "Linus", + role: Role.EDITOR, + email_verified: 1, + }, + ]) + .execute(); + harness = await connectMcpHarness({ db, userId: LINUS, userRole: Role.EDITOR }); + }); + + afterEach(async () => { + await harness.cleanup(); + await teardownTestDatabase(db); + }); + + function call(tool: string, args: Record) { + return harness.client.callTool({ name: tool, arguments: { collection: "post", ...args } }); + } + + async function succeed(tool: string, args: Record): Promise { + const result = await call(tool, args); + expect(result.isError, extractText(result)).toBeFalsy(); + return result; + } + + async function read(id: string): Promise { + return extractJson<{ item: Item }>(await call("content_get", { id })).item; + } + + function rev(id: string): Promise { + return currentRev(harness.client, "post", id); + } + + async function draft(): Promise { + return extractJson<{ item: Item }>( + await succeed("content_create", { data: { title: "Draft" } }), + ).item.id; + } + + async function published(): Promise { + const id = await draft(); + await succeed("content_publish", { id, _rev: await rev(id) }); + return id; + } + + async function earlierRevision(id: string): Promise { + const revision = await new RevisionRepository(db).create({ + collection: "post", + entryId: id, + data: { title: "Earlier" }, + authorId: LINUS, + }); + return revision.id; + } + + function inAnHour(): string { + return new Date(Date.now() + 3_600_000).toISOString(); + } + + const cases: LockCase[] = [ + { + label: "content_update", + tool: "content_update", + prepare: draft, + args: async (id) => ({ id, data: { title: "Agent edit" }, _rev: await rev(id) }), + unchanged: (item) => expect(item.data.title).toBe("Draft"), + }, + { + label: "content_update with a status change", + tool: "content_update", + prepare: draft, + args: async (id) => ({ + id, + data: { title: "Agent edit" }, + status: "published", + _rev: await rev(id), + }), + unchanged: (item) => + expect(item).toMatchObject({ status: "draft", data: { title: "Draft" } }), + }, + { + label: "content_delete", + tool: "content_delete", + prepare: draft, + args: async (id) => ({ id }), + unchanged: (item) => expect(item.status).toBe("draft"), + }, + { + label: "content_publish", + tool: "content_publish", + prepare: draft, + args: async (id) => ({ id, _rev: await rev(id) }), + unchanged: (item) => expect(item.status).toBe("draft"), + }, + { + label: "content_unpublish", + tool: "content_unpublish", + prepare: published, + args: async (id) => ({ id, _rev: await rev(id) }), + unchanged: (item) => expect(item.status).toBe("published"), + }, + { + label: "content_schedule", + tool: "content_schedule", + prepare: draft, + args: async (id) => ({ id, scheduledAt: inAnHour() }), + unchanged: (item) => expect(item.scheduledAt).toBeFalsy(), + }, + { + label: "content_unschedule", + tool: "content_unschedule", + prepare: async () => { + const id = await draft(); + await succeed("content_schedule", { id, scheduledAt: inAnHour() }); + return id; + }, + args: async (id) => ({ id }), + unchanged: (item) => expect(item.scheduledAt).toBeTruthy(), + }, + { + label: "content_discard_draft", + tool: "content_discard_draft", + prepare: async () => { + const id = await published(); + await succeed("content_update", { id, data: { title: "Pending" }, _rev: await rev(id) }); + return id; + }, + args: async (id) => ({ id, _rev: await rev(id) }), + unchanged: (item) => expect(item.data.title).toBe("Pending"), + }, + { + label: "revision_restore", + tool: "revision_restore", + prepare: draft, + args: async (id) => ({ revisionId: await earlierRevision(id) }), + unchanged: (item) => expect(item.data.title).toBe("Draft"), + }, + ]; + + describe.each(cases)("$label", ({ tool, prepare, args, unchanged }) => { + it("is refused while another user holds the entry, naming them", async () => { + const id = await prepare(); + await handleEntryLockAcquire(db, "post", id, ADA); + + const result = await call(tool, await args(id)); + + expect(result.isError).toBe(true); + expect(extractText(result)).toBe("[ENTRY_LOCKED] Ada is holding this entry"); + expect(result._meta).toMatchObject({ + code: "ENTRY_LOCKED", + details: { userId: ADA, userName: "Ada" }, + }); + unchanged(await read(id)); + }); + + it("goes through with overrideLock", async () => { + const id = await prepare(); + await handleEntryLockAcquire(db, "post", id, ADA); + + const result = await call(tool, { ...(await args(id)), overrideLock: true }); + + expect(result.isError, extractText(result)).toBeFalsy(); + }); + }); + + it("lets the caller write to an entry they hold the lock on", async () => { + const id = await draft(); + await handleEntryLockAcquire(db, "post", id, LINUS); + + await succeed("content_update", { id, data: { title: "Own edit" }, _rev: await rev(id) }); + + expect((await read(id)).data.title).toBe("Own edit"); + }); + + it("reports a missing permission before the lock, so the holder is not named", async () => { + const id = await draft(); + await handleEntryLockAcquire(db, "post", id, ADA); + const author = await connectMcpHarness({ db, userId: "user_author", userRole: Role.AUTHOR }); + + try { + const result = await author.client.callTool({ + name: "content_update", + arguments: { collection: "post", id, data: { title: "Not mine" }, _rev: await rev(id) }, + }); + + expect(result.isError).toBe(true); + expect(result._meta).toMatchObject({ code: "INSUFFICIENT_PERMISSIONS" }); + } finally { + await author.cleanup(); + } + }); +}); diff --git a/packages/core/tests/unit/astro/content-route-entry-lock.test.ts b/packages/core/tests/unit/astro/content-route-entry-lock.test.ts index 8ca55c23aa..e7e1fb2fa4 100644 --- a/packages/core/tests/unit/astro/content-route-entry-lock.test.ts +++ b/packages/core/tests/unit/astro/content-route-entry-lock.test.ts @@ -19,6 +19,7 @@ import { DELETE as unscheduleContent, } from "../../../src/astro/routes/api/content/[collection]/[id]/schedule.js"; import { POST as unpublishContent } from "../../../src/astro/routes/api/content/[collection]/[id]/unpublish.js"; +import { POST as restoreRevision } from "../../../src/astro/routes/api/revisions/[revisionId]/restore.js"; import { EntryLockRepository } from "../../../src/database/repositories/entry-locks.js"; import type { Database } from "../../../src/database/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; @@ -27,6 +28,7 @@ import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js" const HOLDER = "user-ada"; const WRITER = "user-linus"; const ENTRY_ID = "01JXENTRY0000000000000000"; +const REVISION_ID = "01JXREVISION0000000000000"; const ROUTES = [ { name: "PUT", handler: updateContent, method: "PUT", path: "", body: {} }, @@ -54,6 +56,15 @@ const ROUTES = [ path: "/schedule", body: undefined, }, + { + name: "revision restore", + handler: restoreRevision, + method: "POST", + path: "", + body: {}, + url: `http://localhost/_emdash/api/revisions/${REVISION_ID}/restore`, + params: { revisionId: REVISION_ID }, + }, ] as const; describe("content write routes — entry edit lock", () => { @@ -85,7 +96,11 @@ describe("content write routes — entry edit lock", () => { }); // DELETE takes no body, so its opt-out rides on the query string. const query = options.overrideLock && route.method === "DELETE" ? "?overrideLock=true" : ""; - const url = `http://localhost/_emdash/api/content/posts/${ENTRY_ID}${route.path}${query}`; + const base = + "url" in route + ? route.url + : `http://localhost/_emdash/api/content/posts/${ENTRY_ID}${route.path}`; + const url = `${base}${query}`; const request = new Request(url, { method: route.method, headers: { "Content-Type": "application/json" }, @@ -99,7 +114,7 @@ describe("content write routes — entry edit lock", () => { }); const okItem = { success: true, data: { item: {} } }; return route.handler({ - params: { collection: "posts", id: ENTRY_ID }, + params: "params" in route ? route.params : { collection: "posts", id: ENTRY_ID }, request, url: new URL(url), locals: { @@ -114,6 +129,11 @@ describe("content write routes — entry edit lock", () => { handleContentSchedule: vi.fn().mockResolvedValue(okItem), handleContentUnschedule: vi.fn().mockResolvedValue(okItem), handleContentDelete: vi.fn().mockResolvedValue({ success: true, data: {} }), + handleRevisionGet: vi.fn().mockResolvedValue({ + success: true, + data: { item: { collection: "posts", entryId: ENTRY_ID } }, + }), + handleRevisionRestore: vi.fn().mockResolvedValue(okItem), }, }, cache: { enabled: false, invalidate: vi.fn() }, diff --git a/packages/core/tests/unit/mcp/authorization.test.ts b/packages/core/tests/unit/mcp/authorization.test.ts index 06d528247a..457141a582 100644 --- a/packages/core/tests/unit/mcp/authorization.test.ts +++ b/packages/core/tests/unit/mcp/authorization.test.ts @@ -12,12 +12,15 @@ 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 { Kysely } from "kysely"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { EmDashHandlers } from "../../../src/astro/types.js"; +import type { Database } from "../../../src/database/types.js"; import { createMcpServer, type PluginMcpRegistration } from "../../../src/mcp/server.js"; import type { RouteCallerInput } from "../../../src/plugins/routes.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; // --------------------------------------------------------------------------- // Test constants @@ -40,6 +43,16 @@ const MEDIA_ID = "01MEDIA"; // Mock EmDashHandlers // --------------------------------------------------------------------------- +let db: Kysely; + +beforeAll(async () => { + db = await setupTestDatabase(); +}); + +afterAll(async () => { + await teardownTestDatabase(db); +}); + /** Create a minimal mock EmDashHandlers that returns content owned by `ownerId`. */ function createMockHandlers(ownerId: string = AUTHOR_USER_ID): EmDashHandlers { const contentItem = { @@ -61,7 +74,7 @@ function createMockHandlers(ownerId: string = AUTHOR_USER_ID): EmDashHandlers { }; return { - db: {} as EmDashHandlers["db"], + db, invalidateUrlPatternCache: vi.fn(), handleContentGet: vi.fn().mockResolvedValue({ success: true,