141 lines
10 KiB
TypeScript
141 lines
10 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
import { loadTargetRegistry } from "./target-profiles";
|
|
|
|
test("profile registry rejects unsafe or ambiguous configuration", () => {
|
|
expect(() => loadTargetRegistry({ MCP_DEFAULT_TARGET_PROFILE: "tenant-a" })).toThrow(/requires MCP_TARGET_PROFILES_FILE/);
|
|
const dir = mkdtempSync(join(tmpdir(), "dbk-mcp-profiles-"));
|
|
const file = join(dir, "profiles.json");
|
|
const base = { id: "tenant-a", displayName: "Tenant A", kind: "production", baseUrl: "https://a.example/api", usernameEnv: "A_USER", passwordEnv: "A_PASS" };
|
|
const env = { MCP_TARGET_PROFILES_FILE: file, MCP_DEFAULT_TARGET_PROFILE: "tenant-a", A_USER: "alice", A_PASS: "secret", B_USER: "bob", B_PASS: "secret2" };
|
|
try {
|
|
writeFileSync(file, JSON.stringify({ profiles: [base] }));
|
|
expect(loadTargetRegistry(env)?.profiles[0]?.id).toBe("tenant-a");
|
|
writeFileSync(file, JSON.stringify({ profiles: [base, base] }));
|
|
expect(() => loadTargetRegistry(env)).toThrow(/duplicated/);
|
|
writeFileSync(file, JSON.stringify({ profiles: [{ ...base, baseUrl: "http://customer.example/api" }] }));
|
|
expect(() => loadTargetRegistry(env)).toThrow(/HTTPS/);
|
|
for (const baseUrl of ["https://a.example/api?", "https://a.example/api#"]) {
|
|
writeFileSync(file, JSON.stringify({ profiles: [{ ...base, baseUrl }] }));
|
|
expect(() => loadTargetRegistry(env)).toThrow(/query, or fragment/);
|
|
}
|
|
writeFileSync(file, JSON.stringify({ profiles: [base, { ...base, id: "tenant-b", baseUrl: "https://A.EXAMPLE:443/api/", usernameEnv: "B_USER", passwordEnv: "B_PASS" }] }));
|
|
expect(() => loadTargetRegistry(env)).toThrow(/duplicated/);
|
|
writeFileSync(file, JSON.stringify({ profiles: [{ ...base, password: "literal" }] }));
|
|
expect(() => loadTargetRegistry(env)).toThrow(/literal credentials/);
|
|
writeFileSync(file, JSON.stringify({ profiles: [base] }));
|
|
expect(() => loadTargetRegistry({ ...env, A_PASS: "", ADMIN_PASSWORD: "legacy-fallback" })).toThrow(/missing credential/);
|
|
expect(() => loadTargetRegistry({ ...env, MCP_DEFAULT_TARGET_PROFILE: "missing" })).toThrow(/MCP_DEFAULT_TARGET_PROFILE/);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("MCP switches between two backends without sharing credentials or tokens", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "dbk-mcp-protocol-"));
|
|
const file = join(dir, "profiles.json");
|
|
const seenA: Array<{ path: string; auth: string; username?: string; password?: string }> = [];
|
|
const seenB: Array<{ path: string; auth: string; username?: string; password?: string }> = [];
|
|
const bSettingsBodies: unknown[] = [];
|
|
let holdA = false;
|
|
let signalAStarted: (() => void) | undefined;
|
|
let releaseA: (() => void) | undefined;
|
|
const aStarted = new Promise<void>((resolve) => { signalAStarted = resolve; });
|
|
const aReleased = new Promise<void>((resolve) => { releaseA = resolve; });
|
|
const createBackend = (seen: typeof seenA, token: string) => Bun.serve({
|
|
port: 0,
|
|
fetch: async (request) => {
|
|
const path = new URL(request.url).pathname;
|
|
if (path === "/api/auth/login") {
|
|
const body = await request.json() as { username: string; password: string };
|
|
seen.push({ path, auth: request.headers.get("authorization") ?? "", ...body });
|
|
return Response.json({ token });
|
|
}
|
|
seen.push({ path, auth: request.headers.get("authorization") ?? "" });
|
|
if (token === "token-b" && path === "/api/settings" && request.method === "PUT") {
|
|
const body = await request.json();
|
|
bSettingsBodies.push(body);
|
|
return Response.json(body);
|
|
}
|
|
if (token === "token-a" && path === "/api/products/brands" && holdA) {
|
|
signalAStarted?.();
|
|
await aReleased;
|
|
}
|
|
if (path === "/api/products/brands") return Response.json([{ id: token }]);
|
|
return new Response("Not found", { status: 404 });
|
|
},
|
|
});
|
|
const a = createBackend(seenA, "token-a");
|
|
const b = createBackend(seenB, "token-b");
|
|
let client: Client | undefined;
|
|
try {
|
|
writeFileSync(file, JSON.stringify({ profiles: [
|
|
{ id: "tenant-a", displayName: "Tenant A", kind: "local", baseUrl: `http://localhost:${a.port}/api`, usernameEnv: "A_USER", passwordEnv: "A_PASS" },
|
|
{ id: "tenant-b", displayName: "Tenant B", kind: "local", baseUrl: `http://localhost:${b.port}/api`, usernameEnv: "B_USER", passwordEnv: "B_PASS" },
|
|
] }));
|
|
const env = { ...process.env, MCP_TARGET_PROFILES_FILE: file, MCP_DEFAULT_TARGET_PROFILE: "tenant-a", A_USER: "alice", A_PASS: "pass-a", B_USER: "bob", B_PASS: "pass-b" } as Record<string, string>;
|
|
const transport = new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, "index.ts")], cwd: import.meta.dir, env, stderr: "pipe" });
|
|
client = new Client({ name: "profile-test", version: "1.0.0" });
|
|
await client.connect(transport);
|
|
const tools = (await client.listTools()).tools;
|
|
expect(tools.some((tool) => tool.name === "list_target_profiles")).toBe(true);
|
|
expect(tools.some((tool) => tool.name === "select_target_profile")).toBe(true);
|
|
expect(tools.some((tool) => tool.name === "upload_product_image_from_url")).toBe(false);
|
|
expect(tools.find((tool) => tool.name === "list_product_brands")?.inputSchema.required).toContain("expectedProfileId");
|
|
expect(tools.find((tool) => tool.name === "update_brand_name")?.inputSchema.required).toContain("expectedProfileId");
|
|
const listed = await client.callTool({ name: "list_target_profiles", arguments: {} });
|
|
expect(JSON.stringify(listed)).not.toContain("pass-a");
|
|
expect(JSON.stringify(listed)).not.toContain("pass-b");
|
|
const missing = await client.callTool({ name: "list_product_brands", arguments: {} });
|
|
expect(missing.isError).toBe(true);
|
|
const blockedUpload = await client.callTool({ name: "upload_product_image_from_url", arguments: { expectedProfileId: "tenant-a", imageUrl: "https://example.com/photo.jpg", productId: "p1" } });
|
|
expect(blockedUpload.isError).toBe(true);
|
|
expect(JSON.stringify(blockedUpload)).toContain("PROFILE_TOOL_UNAVAILABLE");
|
|
expect(seenA.filter((item) => item.path === "/api/products/brands")).toHaveLength(0);
|
|
const first = await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
|
expect(first.structuredContent).toEqual({ data: [{ id: "token-a" }] });
|
|
const legacy = await client.callTool({ name: "set_target_environment", arguments: { env: "production", customUrl: "https://evil.example/api" } });
|
|
expect(legacy.isError).toBe(true);
|
|
expect((await client.callTool({ name: "get_target_environment", arguments: {} })).structuredContent).toMatchObject({ activeProfile: { id: "tenant-a" } });
|
|
const unknown = await client.callTool({ name: "select_target_profile", arguments: { profileId: "unknown" } });
|
|
expect(unknown.isError).toBe(true);
|
|
await client.callTool({ name: "select_target_profile", arguments: { profileId: "tenant-b" } });
|
|
const staleRead = await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
|
expect(staleRead.isError).toBe(true);
|
|
const staleWrite = await client.callTool({ name: "update_brand_name", arguments: { expectedProfileId: "tenant-a", brandName: "Wrong tenant" } });
|
|
expect(staleWrite.isError).toBe(true);
|
|
expect(seenB).toHaveLength(0); // no login, stale read, or stale write reached backend
|
|
const second = await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-b" } });
|
|
expect(second.structuredContent).toEqual({ data: [{ id: "token-b" }] });
|
|
const validWrite = await client.callTool({ name: "update_brand_name", arguments: { expectedProfileId: "tenant-b", brandName: "Tenant B" } });
|
|
expect(validWrite.isError).toBeFalsy();
|
|
expect(bSettingsBodies).toEqual([{ brandName: "Tenant B" }]);
|
|
await client.callTool({ name: "select_target_profile", arguments: { profileId: "tenant-a" } });
|
|
await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
|
holdA = true;
|
|
const inFlight = client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-a" } });
|
|
await aStarted;
|
|
const switching = client.callTool({ name: "select_target_profile", arguments: { profileId: "tenant-b" } });
|
|
releaseA?.();
|
|
expect((await inFlight).structuredContent).toEqual({ data: [{ id: "token-a" }] });
|
|
expect((await switching).structuredContent).toMatchObject({ activeProfile: { id: "tenant-b" } });
|
|
expect((await client.callTool({ name: "list_product_brands", arguments: { expectedProfileId: "tenant-b" } })).structuredContent).toEqual({ data: [{ id: "token-b" }] });
|
|
expect(seenA.filter((item) => item.path === "/api/auth/login")).toEqual([{ path: "/api/auth/login", auth: "", username: "alice", password: "pass-a" }]);
|
|
expect(seenB.filter((item) => item.path === "/api/auth/login")).toEqual([{ path: "/api/auth/login", auth: "", username: "bob", password: "pass-b" }]);
|
|
expect(seenA.filter((item) => item.path === "/api/products/brands").every((item) => item.auth === "Bearer token-a")).toBe(true);
|
|
expect(seenB.filter((item) => item.path === "/api/products/brands").every((item) => item.auth === "Bearer token-b")).toBe(true);
|
|
await client.close();
|
|
client = undefined;
|
|
const restartTransport = new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, "index.ts")], cwd: import.meta.dir, env, stderr: "pipe" });
|
|
client = new Client({ name: "profile-restart-test", version: "1.0.0" });
|
|
await client.connect(restartTransport);
|
|
expect((await client.callTool({ name: "get_target_environment", arguments: {} })).structuredContent).toMatchObject({ mode: "profiles", activeProfile: { id: "tenant-a" } });
|
|
} finally {
|
|
await client?.close();
|
|
a.stop(); b.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|