import { expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import axios from "axios"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { handleAssetTool } from "./asset-toolkit"; const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]); const parsed = (value: Awaited>) => JSON.parse(value!.content[0].text); test("upload_cms_asset validates local image scope before backend access", async () => { const dir = mkdtempSync(join(tmpdir(), "dbk-assets-")); const root = join(dir, "allowed"); mkdirSync(root); const valid = join(root, "photo.png"); const outside = join(dir, "outside.png"); const invalid = join(root, "wrong.png"); const big = join(root, "big.png"); writeFileSync(valid, png); writeFileSync(outside, png); writeFileSync(invalid, Buffer.from("not an image")); writeFileSync(big, Buffer.alloc(10 * 1024 * 1024 + 1)); const seen: Array<{ url: string; body: any }> = []; const api = axios.create(); api.post = async (url: string, body: any) => { seen.push({ url, body }); return { data: { success: true, url: "http://localhost:3051/uploads/new.png", path: "/uploads/new.png" } } as any; }; try { const noRoot = await handleAssetTool(api, "upload_cms_asset", { filePath: valid }, ""); expect(noRoot?.isError).toBe(true); const outsideResult = await handleAssetTool(api, "upload_cms_asset", { filePath: outside }, root); expect(parsed(outsideResult).code).toBe("FILE_OUTSIDE_ASSET_ROOT"); expect(parsed(await handleAssetTool(api, "upload_cms_asset", { filePath: invalid }, root)).code).toBe("INVALID_IMAGE_TYPE"); expect(parsed(await handleAssetTool(api, "upload_cms_asset", { filePath: big }, root)).code).toBe("INVALID_IMAGE_SIZE"); const link = join(root, "escape.png"); try { symlinkSync(outside, link, "file"); expect(parsed(await handleAssetTool(api, "upload_cms_asset", { filePath: link }, root)).code).toBe("FILE_OUTSIDE_ASSET_ROOT"); } catch (error: any) { // Windows without Developer Mode may disallow creating test symlinks. if (error?.code !== "EPERM") throw error; } expect(seen).toHaveLength(0); const success = await handleAssetTool(api, "upload_cms_asset", { filePath: valid }, root); expect(parsed(success)).toEqual({ success: true, url: "http://localhost:3051/uploads/new.png", path: "/uploads/new.png", mimeType: "image/png", sizeBytes: png.length }); expect(JSON.stringify(success)).not.toContain(valid); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("/upload"); expect(seen[0]?.body.getBuffer().toString("latin1")).toContain('name="file"; filename="photo.png"'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("upload_cms_asset obeys profile binding and sends authenticated multipart", async () => { const dir = mkdtempSync(join(tmpdir(), "dbk-assets-profile-")); const root = join(dir, "allowed"); mkdirSync(root); const image = join(root, "article.png"); const registry = join(dir, "profiles.json"); writeFileSync(image, png); const uploads: Array<{ contentType: string; authorization: string; body: string }> = []; const backend = Bun.serve({ port: 0, fetch: async (request) => { const path = new URL(request.url).pathname; if (path === "/api/auth/login") return Response.json({ token: "test-token" }); if (path === "/api/upload") { uploads.push({ contentType: request.headers.get("content-type") ?? "", authorization: request.headers.get("authorization") ?? "", body: Buffer.from(await request.arrayBuffer()).toString("latin1") }); return Response.json({ success: true, url: `${new URL(request.url).origin}/uploads/asset.png`, path: "/uploads/asset.png", filename: "asset.png" }); } return new Response("Not found", { status: 404 }); } }); writeFileSync(registry, JSON.stringify({ profiles: [{ id: "tenant-a", displayName: "Tenant A", kind: "local", baseUrl: `http://localhost:${backend.port}/api`, usernameEnv: "A_USER", passwordEnv: "A_PASS" }] })); const env = { ...process.env, MCP_TARGET_PROFILES_FILE: registry, MCP_DEFAULT_TARGET_PROFILE: "tenant-a", MCP_ASSET_ROOT: root, A_USER: "alice", A_PASS: "secret" } as Record; const client = new Client({ name: "asset-upload-test", version: "1.0.0" }); try { await client.connect(new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, "index.ts")], cwd: import.meta.dir, env, stderr: "pipe" })); const tool = (await client.listTools()).tools.find((item) => item.name === "upload_cms_asset"); expect(tool?.inputSchema.required).toEqual(["filePath", "expectedProfileId"]); const stale = await client.callTool({ name: "upload_cms_asset", arguments: { filePath: image, expectedProfileId: "wrong" } }); expect(stale.isError).toBe(true); expect(uploads).toHaveLength(0); const good = await client.callTool({ name: "upload_cms_asset", arguments: { filePath: image, expectedProfileId: "tenant-a" } }); expect(good.isError).toBeFalsy(); expect(good.structuredContent).toMatchObject({ success: true, path: "/uploads/asset.png" }); expect(JSON.stringify(good)).not.toContain(image); expect(uploads).toHaveLength(1); expect(uploads[0]?.contentType).toContain("multipart/form-data; boundary="); expect(uploads[0]?.authorization).toBe("Bearer test-token"); expect(uploads[0]?.body).toContain('name="file"; filename="article.png"'); } finally { await client.close(); backend.stop(true); rmSync(dir, { recursive: true, force: true }); } });