Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
265 lines
8.4 KiB
TypeScript
265 lines
8.4 KiB
TypeScript
/**
|
|
* MCP pagination / cursor tests.
|
|
*
|
|
* Malformed cursors must produce a structured `INVALID_CURSOR` error
|
|
* instead of silently returning the first page (the latter would let UI
|
|
* pagination bugs re-fetch the whole table without any signal).
|
|
*
|
|
* `decodeCursor()` throws `InvalidCursorError` on invalid input; handler
|
|
* catches translate that to `INVALID_CURSOR`. The MCP boundary also
|
|
* applies `z.string().min(1).max(2048)` to reject obvious DoS attempts
|
|
* before they reach the decoder.
|
|
*
|
|
* Tests cover the MCP-visible list surface: content_list,
|
|
* content_list_trashed, media_list, revision_list, taxonomy_list_terms.
|
|
*/
|
|
|
|
import { Role } from "@emdash-cms/auth";
|
|
import type { Kysely } from "kysely";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import {
|
|
connectMcpHarness,
|
|
extractJson,
|
|
extractText,
|
|
type McpHarness,
|
|
} from "../../utils/mcp-runtime.js";
|
|
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
|
|
|
const ADMIN_ID = "user_admin";
|
|
|
|
const INVALID_CURSOR = /cursor|invalid|malformed/i;
|
|
|
|
async function seedPosts(db: Kysely<Database>, count: number, prefix = "post"): Promise<string[]> {
|
|
const repo = new ContentRepository(db);
|
|
const ids: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const item = await repo.create({
|
|
type: "post",
|
|
data: { title: `${prefix} ${i}` },
|
|
slug: `${prefix}-${i}`,
|
|
status: "draft",
|
|
authorId: ADMIN_ID,
|
|
});
|
|
ids.push(item.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
describe("MCP cursor pagination — content_list (bug #12)", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("rejects garbage cursor with a structured error (does NOT silently return first page)", async () => {
|
|
await seedPosts(db, 5);
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", cursor: "obviously-malformed-cursor" },
|
|
});
|
|
|
|
// Currently: returns the full first page.
|
|
// After fix: returns isError with INVALID_CURSOR-style message.
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(INVALID_CURSOR);
|
|
});
|
|
|
|
it("rejects empty-string cursor with a structured error", async () => {
|
|
await seedPosts(db, 5);
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", cursor: "" },
|
|
});
|
|
|
|
// An empty cursor is unambiguously invalid — should error rather
|
|
// than silently treating it as "no cursor".
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
|
|
it("rejects base64-decodable but structurally-wrong cursor", async () => {
|
|
await seedPosts(db, 5);
|
|
|
|
// Valid base64 but doesn't match the expected `{orderValue, id}` shape.
|
|
const bogus = Buffer.from(JSON.stringify({ wrong: "shape" })).toString("base64");
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", cursor: bogus },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(INVALID_CURSOR);
|
|
});
|
|
|
|
it("rejects cursor with non-string id field", async () => {
|
|
await seedPosts(db, 5);
|
|
|
|
const bogus = Buffer.from(JSON.stringify({ orderValue: "x", id: 42 })).toString("base64");
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", cursor: bogus },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
|
|
it("valid cursor returns the correct next page (regression guard)", async () => {
|
|
await seedPosts(db, 5);
|
|
|
|
const first = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", limit: 2 },
|
|
});
|
|
const firstData = extractJson<{
|
|
items: Array<{ id: string }>;
|
|
nextCursor?: string;
|
|
}>(first);
|
|
expect(firstData.items).toHaveLength(2);
|
|
expect(firstData.nextCursor).toBeTruthy();
|
|
|
|
const second = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", limit: 2, cursor: firstData.nextCursor },
|
|
});
|
|
const secondData = extractJson<{
|
|
items: Array<{ id: string }>;
|
|
}>(second);
|
|
expect(secondData.items).toHaveLength(2);
|
|
// Different ids than the first page
|
|
const firstIds = firstData.items.map((i) => i.id);
|
|
const secondIds = secondData.items.map((i) => i.id);
|
|
for (const id of secondIds) {
|
|
expect(firstIds).not.toContain(id);
|
|
}
|
|
});
|
|
|
|
it("rejects oversized cursor without attempting to decode it (DoS guard)", async () => {
|
|
await seedPosts(db, 3);
|
|
|
|
// Cursors we issue are well under 200 chars. A multi-KB cursor is
|
|
// almost certainly an attacker probing the base64 decoder. The
|
|
// MCP input schema caps cursors at 2048 chars; this test forces a
|
|
// rejection at the schema boundary rather than letting the
|
|
// decoder allocate against a giant string.
|
|
const huge = "A".repeat(10_000);
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", cursor: huge },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
|
|
it("malformed cursor on second page does not skip back to start", async () => {
|
|
await seedPosts(db, 5);
|
|
|
|
const first = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", limit: 2 },
|
|
});
|
|
const firstData = extractJson<{ items: Array<{ id: string }>; nextCursor?: string }>(first);
|
|
|
|
// Tamper with the cursor — change one character
|
|
const tampered = firstData.nextCursor ? firstData.nextCursor.slice(0, -1) + "X" : "garbage";
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", limit: 2, cursor: tampered },
|
|
});
|
|
|
|
// Bug today: returns first page again (callers re-process duplicates).
|
|
// After fix: errors so callers can detect the bug.
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("MCP cursor pagination — other list tools (bug #12 propagation)", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("content_list_trashed rejects malformed cursor", async () => {
|
|
const ids = await seedPosts(db, 3);
|
|
const repo = new ContentRepository(db);
|
|
for (const id of ids) await repo.delete("post", id);
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "content_list_trashed",
|
|
arguments: { collection: "post", cursor: "garbage" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
|
|
// (revision_list cursor test deleted: the tool's input schema doesn't
|
|
// declare a cursor parameter, and Zod's default behavior is to drop
|
|
// unknown keys silently — so the previous "expect(result).toBeDefined()"
|
|
// was meaningless. Forcing schema strict-mode is out of scope.)
|
|
|
|
it("media_list rejects malformed cursor", async () => {
|
|
const result = await harness.client.callTool({
|
|
name: "media_list",
|
|
arguments: { cursor: "garbage" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("MCP cursor pagination — limit clamping (regression guard)", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("limit beyond max is clamped, not rejected", async () => {
|
|
await seedPosts(db, 3);
|
|
|
|
// Per AGENTS.md: max limit is 100. Higher should be clamped, not error.
|
|
const result = await harness.client.callTool({
|
|
name: "content_list",
|
|
arguments: { collection: "post", limit: 1000 },
|
|
});
|
|
// Either Zod rejects via inputSchema (also fine) or the handler clamps.
|
|
// Both are valid; what's NOT valid is silently honoring 1000 against
|
|
// a real backend.
|
|
if (result.isError) {
|
|
// Rejection branch: must be a validation error, not a generic 500.
|
|
expect(extractText(result)).toMatch(/limit|max|exceed|invalid/i);
|
|
} else {
|
|
const data = extractJson<{ items: unknown[] }>(result);
|
|
expect(data.items.length).toBeLessThanOrEqual(100);
|
|
}
|
|
});
|
|
});
|