metatron-open-seo/src/server/mcp/project-auth.test.ts
2026-06-04 13:50:26 -04:00

127 lines
4.0 KiB
TypeScript

import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
const authContext = {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
};
const toolExtra: ToolExtra = {
signal: new AbortController().signal,
requestId: 1,
sendNotification: vi.fn(),
sendRequest: vi.fn(),
authInfo: {
token: "token",
clientId: "client_123",
scopes: ["mcp"],
resource: new URL("https://open-seo.test/mcp"),
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
} satisfies AuthInfo,
};
describe("withMcpProjectAuth", () => {
beforeEach(() => {
vi.resetModules();
mocks.getProjectForOrganization.mockReset();
// Default: the project belongs to the org. Individual tests override.
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_123",
name: "Test",
});
});
it("checks project access for the authenticated organization", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockResolvedValue("ok");
const wrapped = withMcpProjectAuth(handler);
await expect(
wrapped({ projectId: "project_123" }, toolExtra),
).resolves.toBe("ok");
expect(mocks.getProjectForOrganization).toHaveBeenCalledWith(
"org_123",
"project_123",
);
});
it("passes auth, baseUrl, and billing context to the wrapped handler", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockReturnValue("ok");
const wrapped = withMcpProjectAuth(handler);
await wrapped({ projectId: "project_123" }, toolExtra);
expect(handler).toHaveBeenCalledWith(
{ projectId: "project_123" },
{
auth: {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
},
baseUrl: "https://open-seo.test",
billing: {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
projectId: "project_123",
},
},
);
});
it("propagates project access failures without calling the wrapped handler", async () => {
const error = new Error("project not found");
mocks.getProjectForOrganization.mockRejectedValue(error);
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn();
const wrapped = withMcpProjectAuth(handler);
await expect(wrapped({ projectId: "project_123" }, toolExtra)).rejects.toBe(
error,
);
expect(handler).not.toHaveBeenCalled();
});
// Defense-in-depth: even if the project lookup ever resolves falsy instead of
// throwing (e.g. a future refactor returns null), the wrapper must still deny
// access rather than run the handler with an unauthorized projectId.
it("rejects when the project lookup resolves no project, without calling the handler", async () => {
mocks.getProjectForOrganization.mockResolvedValue(null);
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn();
const wrapped = withMcpProjectAuth(handler);
await expect(
wrapped({ projectId: "someone-elses-project" }, toolExtra),
).rejects.toThrow();
expect(handler).not.toHaveBeenCalled();
});
});