Files
kunthawat 2d1be52177 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
2026-05-03 10:44:54 +07:00

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);
}
});
});