Files
homaroma/apps/mcp-server/asset-toolkit.test.ts
T
2026-10-11 06:43:08 +07:00

97 lines
5.7 KiB
TypeScript

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<ReturnType<typeof handleAssetTool>>) => 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<string, string>;
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 });
}
});