Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

332 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MCP ownership / authorization integration tests.
*
* The MCP server's `extractContentAuthorId()` returns "" (empty string)
* for content with null authorId — mirroring the REST handler. Then
* `canActOnOwn(user, "", own, any)` defers to the "any" permission so
* EDITOR+ can edit seed-imported content while CONTRIBUTOR/AUTHOR are
* denied with a clean permission error.
*
* These tests cover every permutation of role × ownership × null-author.
*/
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, extractText, type McpHarness } from "../../utils/mcp-runtime.js";
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
const ADMIN_ID = "user_admin";
const EDITOR_ID = "user_editor";
const AUTHOR_ID = "user_author";
const CONTRIBUTOR_ID = "user_contributor";
const NULL_AUTHOR_ERROR = /no.*authorId|content has no authorId/i;
describe("MCP ownership — null authorId (bug #1)", () => {
let db: Kysely<Database>;
let harness: McpHarness;
async function seedItemWithAuthor(authorId: string | null): Promise<string> {
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Seeded Post" },
slug: `seeded-${Math.random().toString(36).slice(2, 8)}`,
status: "published",
...(authorId !== null ? { authorId } : {}),
});
return item.id;
}
async function connect(role: keyof typeof userIdByRole): Promise<void> {
harness = await connectMcpHarness({
db,
userId: userIdByRole[role],
userRole: roleByName[role],
});
}
const userIdByRole = {
admin: ADMIN_ID,
editor: EDITOR_ID,
author: AUTHOR_ID,
contributor: CONTRIBUTOR_ID,
} as const;
const roleByName = {
admin: Role.ADMIN,
editor: Role.EDITOR,
author: Role.AUTHOR,
contributor: Role.CONTRIBUTOR,
} as const;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
// ----- content_update -----
describe("content_update", () => {
it("ADMIN can update content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("admin");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated by admin" } },
});
// Currently fails with NULL_AUTHOR_ERROR. After fix: succeeds.
expect(result.isError, extractText(result)).toBeFalsy();
});
it("EDITOR can update content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("editor");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated by editor" } },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot update content with null authorId (no ownership claim)", async () => {
const id = await seedItemWithAuthor(null);
await connect("author");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Should fail" } },
});
// AUTHOR has only content:edit_own — without an authorId match,
// they have no "own" claim and lack content:edit_any.
expect(result.isError).toBe(true);
// Negative: NOT the null-author internal error.
expect(extractText(result)).not.toMatch(NULL_AUTHOR_ERROR);
// Positive: clean permission error with the structured code.
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
it("CONTRIBUTOR cannot update content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("contributor");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Should fail" } },
});
expect(result.isError).toBe(true);
expect(extractText(result)).not.toMatch(NULL_AUTHOR_ERROR);
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
});
// ----- content_delete -----
describe("content_delete (trash)", () => {
it("ADMIN can trash content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("admin");
const result = await harness.client.callTool({
name: "content_delete",
arguments: { collection: "post", id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot trash content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("author");
const result = await harness.client.callTool({
name: "content_delete",
arguments: { collection: "post", id },
});
expect(result.isError).toBe(true);
expect(extractText(result)).not.toMatch(NULL_AUTHOR_ERROR);
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
});
// ----- content_publish / content_unpublish -----
describe("publish / unpublish", () => {
it("ADMIN can publish content with null authorId", async () => {
// Create as draft so publish is meaningful
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Draft" },
slug: "draft-null-author",
status: "draft",
});
await connect("admin");
const result = await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id: item.id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("ADMIN can unpublish content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
await connect("admin");
const result = await harness.client.callTool({
name: "content_unpublish",
arguments: { collection: "post", id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ----- content_schedule -----
describe("content_schedule", () => {
it("ADMIN can schedule content with null authorId", async () => {
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Sched draft" },
slug: "sched-null-author",
status: "draft",
});
await connect("admin");
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const result = await harness.client.callTool({
name: "content_schedule",
arguments: { collection: "post", id: item.id, scheduledAt: future },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ----- content_restore (from trash) -----
describe("content_restore", () => {
it("ADMIN can restore trashed content with null authorId", async () => {
const id = await seedItemWithAuthor(null);
// Trash via repo to bypass MCP (which we're testing)
const repo = new ContentRepository(db);
await repo.delete("post", id);
await connect("admin");
const result = await harness.client.callTool({
name: "content_restore",
arguments: { collection: "post", id },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
// ----- Sanity checks: ownership behavior unchanged for non-null cases -----
describe("regression guard — ownership still enforced when authorId is set", () => {
it("AUTHOR can update their own content (authorId matches)", async () => {
const id = await seedItemWithAuthor(AUTHOR_ID);
await connect("author");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated own" } },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
it("AUTHOR cannot update someone else's content (authorId set to other user)", async () => {
const id = await seedItemWithAuthor("user_someone_else");
await connect("author");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Updated other" } },
});
expect(result.isError).toBe(true);
});
it("EDITOR can update anyone's content (any-permission)", async () => {
const id = await seedItemWithAuthor("user_someone_else");
await connect("editor");
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Editor override" } },
});
expect(result.isError, extractText(result)).toBeFalsy();
});
});
});
describe("MCP ownership — error shape consistency", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("denied-by-permissions error does NOT mention 'authorId' (internal detail)", async () => {
const repo = new ContentRepository(db);
const item = await repo.create({
type: "post",
data: { title: "Test" },
slug: "perm-test",
status: "published",
});
harness = await connectMcpHarness({
db,
userId: AUTHOR_ID,
userRole: Role.AUTHOR,
});
const result = await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id: item.id, data: { title: "Nope" } },
});
expect(result.isError).toBe(true);
// Negative: "authorId" is an internal column name and must not leak
// to the user-facing message.
expect(extractText(result)).not.toMatch(/authorId/);
// Positive: the response carries a permissions code so callers can
// distinguish "you can't do this" from any other failure mode.
const meta = (result as { _meta?: { code?: string } })._meta;
expect(meta?.code).toBe("INSUFFICIENT_PERMISSIONS");
});
});