Files
coreci-chat/apps/control-plane/tests/mcp-routes.test.ts
T
CIAgent 0c15d3d0b2 docs(milestone): complete M2 — MCP Layer & Day 1 Adapters (v0.2)
M2 delivers the read-only MCP capability broker gateway and four Day-1
infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027)
all pass. 656 tests green. M1 non-regression verified.

MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests).
Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec).
Two-track LLM smoke (Track A mock-path P0 gate passes).
CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification.

Phases shipped:
  P0  pre-execution          v0.1.0
  P1  Wave F — MCP gateway   v0.1.1
  P2  Wave G — Proxmox       v0.1.2
  P3  Wave H — SSH/Linux     v0.1.3
  P4  Wave I — Git adapters   v0.1.4
  P5  Wave J — SSE+smoke+UI  v0.1.5
  P6  Final — review+ship    v0.1.6 ← milestone release

---ci---
phase: 6
milestone: v0.2
status: complete
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027]
  partial: []
---/ci---
2026-08-25 06:14:21 +00:00

424 lines
16 KiB
TypeScript

/**
* mcp-routes.test.ts — MCP API routes (Wave F, Task 9).
*
* Boots PGlite + migrations + provisions a tenant + admin session, then
* exercises the route handlers directly (constructing NextRequest with the
* session cookie). Covers:
* - GET /api/mcp/tools returns the closed 9-tool set (MCP tools/list facade).
* - POST /api/mcp/adapter persists an adapter + audit event (adapter.configured).
* - GET /api/mcp/adapter lists the tenant's adapters (RLS-scoped).
* - PATCH/DELETE /api/mcp/adapter/[id] update/remove.
* - POST /api/mcp/invoke returns {correlationId, streamUrl}; 400 on invalid
* args (Edge 4); 404 adapter_not_found.
*
* The broker + stream-manager unit tests cover the deeper enforcement order;
* this test exercises the HTTP surface + auth + audit integration.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import { NextRequest } from "next/server";
import { withTenant, type DbClient, type ScopedClient } from "@coreci/db";
import { provisionTenant, createSession } from "@coreci/auth";
import { GET as getTools } from "../app/api/mcp/tools/route.js";
import { POST as postInvoke } from "../app/api/mcp/invoke/route.js";
import { POST as postAdapter, GET as getAdapter } from "../app/api/mcp/adapter/route.js";
import { PATCH as patchAdapter, DELETE as deleteAdapter } from "../app/api/mcp/adapter/[id]/route.js";
import { getMcpRuntime, setMcpRuntime } from "../lib/mcp.js";
import { getDb } from "../lib/db.js";
const SESSION_SIGNING_KEY = "mcp-routes-test-session-signing-key";
const MASTER_KEY = "mcp-routes-test-master-key-32+chars-long";
let db: DbClient;
let admin: { tenantId: string; userId: string; token: string } | null = null;
async function seedTenant(orgId: string, email: string): Promise<{
tenantId: string;
userId: string;
token: string;
}> {
const p = await provisionTenant(db, orgId, `workos_${orgId}`, email, `Tenant ${orgId}`);
const { token } = await createSession(db, SESSION_SIGNING_KEY, p.userId, p.tenantId, p.role, {
lifetimeSeconds: 3600,
});
return { tenantId: p.tenantId, userId: p.userId, token };
}
function authedReq(path: string, token: string, method = "GET", body?: unknown): NextRequest {
const url = new URL(path, "http://localhost:3000");
const headers: Record<string, string> = { cookie: `coreci_session=${token}` };
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
if (body !== undefined) {
headers["content-type"] = "application/json";
init.body = JSON.stringify(body);
}
return new NextRequest(url, init);
}
beforeAll(async () => {
process.env.SESSION_SIGNING_KEY = SESSION_SIGNING_KEY;
process.env.SECRET_MASTER_KEY_DEV = MASTER_KEY;
// Mock global fetch so submit-time validation succeeds without a live
// upstream in CI:
// - Proxmox (Wave G, REQ-025): GET /api2/json/version + GET /api2/json/nodes.
// - GitHub (Wave I, D-006/R-004): GET /user (validates fine-grained PAT +
// implicit metadata:read).
// - Gitea (Wave I, R-005): GET /api/v1/version + GET /api/v1/user/repos
// (≥1.22 read:repository) OR GET /api/v1/repos/search (<1.22 validity).
// The mock returns valid envelopes for these validation endpoints.
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
// Proxmox.
if (url.includes("/api2/json/version")) {
return new Response(JSON.stringify({ data: { version: "8.2.4", release: "bookworm" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/api2/json/nodes")) {
return new Response(JSON.stringify({ data: [{ node: "pve1", status: "online" }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// GitHub — GET /user (api.github.com or a GHES host).
if (/^https:\/\/[^/]+\/user(?:\?|$)/.test(url) && !url.includes("/api/v1/")) {
return new Response(JSON.stringify({ id: 1, login: "octo", type: "User" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// Gitea — GET /api/v1/version.
if (url.includes("/api/v1/version")) {
return new Response(JSON.stringify({ version: "1.22.0", revision: "abc" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// Gitea — GET /api/v1/user/repos (≥1.22 read:repository validation).
if (url.includes("/api/v1/user/repos")) {
return new Response(JSON.stringify([]), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// Gitea — GET /api/v1/repos/search (<1.22 token-validity check).
if (url.includes("/api/v1/repos/search")) {
return new Response(JSON.stringify({ ok: true, data: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return realFetch(input as RequestInfo | URL, init);
}) as typeof globalThis.fetch;
// Use the control-plane's getDb() so the test + route handlers share the
// same PGlite instance (getDb bootstraps + runs migrations 0001..0003).
db = await getDb();
admin = await seedTenant("org_mcp_routes", "admin@mcp-routes.test");
// Wire the MCP runtime to share this DB + the stub adapters + a local secrets provider.
const { InMemoryRateLimiter, StreamManager, InProcessTransport, defaultStubs } = await import(
"@coreci/mcp"
);
const { LocalEncryptedProvider } = await import("@coreci/secrets");
const streamManager = new StreamManager({ notOpenedTimeoutMs: 5000, maxLifetimeMs: 10000 });
const transport = new InProcessTransport();
for (const stub of defaultStubs()) await transport.register(stub);
setMcpRuntime({
db,
secrets: new LocalEncryptedProvider({ masterKey: MASTER_KEY }),
rateLimiter: new InMemoryRateLimiter(),
streamManager,
transport,
});
});
beforeEach(async () => {
// Wipe mcp_adapters between tests (disable RLS to mutate, re-enable).
await db.query("ALTER TABLE mcp_adapters DISABLE ROW LEVEL SECURITY");
await db.query("DELETE FROM mcp_adapters");
await db.query("ALTER TABLE mcp_adapters ENABLE ROW LEVEL SECURITY");
// Reset the stream manager between tests so contexts don't leak.
const { streamManager } = await getMcpRuntime();
streamManager.reset();
});
afterAll(async () => {
const { streamManager } = await getMcpRuntime();
streamManager.reset();
});
describe("GET /api/mcp/tools — closed 9-tool set (REQ-015)", () => {
it("returns exactly 9 tools with name + description + inputSchema", async () => {
const res = await getTools(authedReq("/api/mcp/tools", admin!.token));
expect(res.status).toBe(200);
const json = (await res.json()) as { tools: { name: string; description: string; inputSchema: unknown }[] };
expect(json.tools).toHaveLength(9);
for (const t of json.tools) {
expect(typeof t.name).toBe("string");
expect(typeof t.description).toBe("string");
expect(t.inputSchema).toBeTypeOf("object");
}
expect(json.tools.map((t) => t.name).sort()).toContain("proxmox.list_vms");
});
it("returns 401 without a session cookie", async () => {
const res = await getTools(new NextRequest(new URL("/api/mcp/tools", "http://localhost:3000")));
expect(res.status).toBe(401);
});
});
describe("POST /api/mcp/adapter — configure an adapter (INV-3, adapter.configured audit)", () => {
it("persists the adapter + audit event; returns adapterId", async () => {
const res = await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "proxmox",
targetId: "pve1",
config: { host: "https://pve1:8006" },
secret: "PVEAPIToken=root@pam!t=uuid",
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; adapterId: string };
expect(json.ok).toBe(true);
expect(json.adapterId).toBeTypeOf("string");
// Audit event appended (adapter.configured).
const audit = await withTenant(admin!.tenantId, async (c: ScopedClient) => {
const r = await c.query<{ event_type: string }>(
`SELECT event_type FROM audit_log WHERE tenant_id = $1 ORDER BY id DESC LIMIT 1`,
[admin!.tenantId],
);
return r.rows[0]?.event_type;
});
expect(audit).toBe("adapter.configured");
});
it("rejects an invalid adapterType with 400", async () => {
const res = await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "bogus",
targetId: "x",
secret: "s",
}),
);
expect(res.status).toBe(400);
});
it("rejects a missing secret with 400", async () => {
const res = await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "github",
targetId: "gh1",
}),
);
expect(res.status).toBe(400);
});
it("rejects a Proxmox config without config.host with 400", async () => {
const res = await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "proxmox",
targetId: "pve1",
config: {},
secret: "PVEAPIToken=root@pam!t=uuid",
}),
);
expect(res.status).toBe(400);
});
it("returns 422 (role_violation) when the Proxmox token fails GET /version (REQ-025)", async () => {
// Override the fetch mock to return 401 for /version on this host.
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/api2/json/version")) {
return new Response("nope", { status: 401 });
}
return new Response("nope", { status: 404 });
}) as typeof globalThis.fetch;
try {
const res = await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "proxmox",
targetId: "pve-bad",
config: { host: "pve-bad.example.com" },
secret: "PVEAPIToken=root@pam!bad=uuid",
}),
);
expect(res.status).toBe(422);
const json = (await res.json()) as { error: string; code: string };
expect(json.error).toBe("role_violation");
expect(json.code).toBe("invalid_token");
} finally {
globalThis.fetch = realFetch;
}
});
it("returns 422 (role_violation, insufficient_role) when GET /nodes is 403 (no Sys.Audit)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/api2/json/version")) {
return new Response(JSON.stringify({ data: { version: "8.0" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/api2/json/nodes")) {
return new Response("forbidden", { status: 403 });
}
return new Response("nope", { status: 404 });
}) as typeof globalThis.fetch;
try {
const res = await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "proxmox",
targetId: "pve-noaudit",
config: { host: "pve-noaudit.example.com" },
secret: "PVEAPIToken=root@pam!noaudit=uuid",
}),
);
expect(res.status).toBe(422);
const json = (await res.json()) as { error: string; code: string };
expect(json.code).toBe("insufficient_role");
} finally {
globalThis.fetch = realFetch;
}
});
});
describe("GET /api/mcp/adapter — list adapters (RLS-scoped)", () => {
it("returns the tenant's adapters", async () => {
await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "github",
targetId: "gh1",
config: {},
secret: "github_pat_x",
}),
);
const res = await getAdapter(authedReq("/api/mcp/adapter", admin!.token));
expect(res.status).toBe(200);
const json = (await res.json()) as { adapters: { adapterType: string; targetId: string }[] };
expect(json.adapters).toHaveLength(1);
expect(json.adapters[0]?.targetId).toBe("gh1");
});
});
describe("POST /api/mcp/invoke — correlation context (REQ-016/017/024)", () => {
it("returns {correlationId, streamUrl} on a happy-path invoke", async () => {
await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "proxmox",
targetId: "pve1",
config: { host: "pve1.example.com" },
secret: "s",
}),
);
const res = await postInvoke(
authedReq("/api/mcp/invoke", admin!.token, "POST", {
toolName: "proxmox.list_vms",
args: { node: "pve1" },
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as { correlationId: string; streamUrl: string };
expect(json.correlationId).toHaveLength(26);
expect(json.streamUrl).toBe(`/api/mcp/stream/${json.correlationId}`);
});
it("returns 400 on invalid args (Edge 4) — missing required 'node'", async () => {
const res = await postInvoke(
authedReq("/api/mcp/invoke", admin!.token, "POST", {
toolName: "proxmox.list_vms",
args: {},
}),
);
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("invalid_args");
});
it("returns 404 adapter_not_found when no adapter of the type exists", async () => {
const res = await postInvoke(
authedReq("/api/mcp/invoke", admin!.token, "POST", {
toolName: "gitea.list_repos",
args: {},
}),
);
expect(res.status).toBe(404);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("adapter_not_found");
});
it("returns 400 target_required (Edge 3) when ≥2 same-type and no targetId", async () => {
await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "proxmox",
targetId: "a",
config: { host: "pve-a.example.com" },
secret: "s",
}),
);
await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "proxmox",
targetId: "b",
config: { host: "pve-b.example.com" },
secret: "s",
}),
);
const res = await postInvoke(
authedReq("/api/mcp/invoke", admin!.token, "POST", {
toolName: "proxmox.list_vms",
args: { node: "pve1" },
}),
);
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string; detail?: { availableTargets?: string[] } | unknown };
expect(json.error).toBe("target_required");
});
});
describe("PATCH/DELETE /api/mcp/adapter/[id]", () => {
it("PATCH updates config; DELETE removes the row", async () => {
const postRes = await postAdapter(
authedReq("/api/mcp/adapter", admin!.token, "POST", {
adapterType: "github",
targetId: "gh-pd",
config: { host: "a" },
secret: "github_pat_test_secret",
}),
);
const { adapterId } = (await postRes.json()) as { adapterId: string };
const patchRes = await patchAdapter(
authedReq(`/api/mcp/adapter/${adapterId}`, admin!.token, "PATCH", {
config: { host: "b" },
}),
{ params: Promise.resolve({ id: adapterId }) },
);
expect(patchRes.status).toBe(200);
const delRes = await deleteAdapter(
authedReq(`/api/mcp/adapter/${adapterId}`, admin!.token, "DELETE"),
{ params: Promise.resolve({ id: adapterId }) },
);
expect(delRes.status).toBe(200);
// Gone: GET /api/mcp/adapter lists 0.
const listRes = await getAdapter(authedReq("/api/mcp/adapter", admin!.token));
const listJson = (await listRes.json()) as { adapters: unknown[] };
expect(listJson.adapters).toHaveLength(0);
});
it("DELETE returns 404 for a cross-tenant id (RLS)", async () => {
const res = await deleteAdapter(
authedReq("/api/mcp/adapter/00000000-0000-0000-0000-000000000099", admin!.token, "DELETE"),
{ params: Promise.resolve({ id: "00000000-0000-0000-0000-000000000099" }) },
);
expect(res.status).toBe(404);
});
});