Skip to content

Commit f2bfa2b

Browse files
rahmanunverclaude
andcommitted
test(pluggable-widgets-mcp): add vitest infrastructure and unit tests
Adds vitest config, MCP test harness (in-memory transport), temp directory helpers, and 55 unit tests covering config validation, security guardrails, project tools, scaffolding/build sandbox, session state, MPK finder, and response utilities. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 853e5a9 commit f2bfa2b

11 files changed

Lines changed: 802 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import type { SessionState } from "@/tools/session-state";
5+
6+
type ToolRegistrationFn = (server: McpServer, state: SessionState) => void;
7+
8+
interface McpTestContext {
9+
server: McpServer;
10+
client: Client;
11+
state: SessionState;
12+
cleanup: () => Promise<void>;
13+
}
14+
15+
/**
16+
* Creates an MCP server + client pair connected via InMemoryTransport.
17+
* By default registers `registerProjectTools`; callers can pass custom registration functions.
18+
*/
19+
export function getResultText(result: Awaited<ReturnType<Client["callTool"]>>): string {
20+
if ("content" in result && Array.isArray(result.content)) {
21+
return result.content
22+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
23+
.map(c => c.text)
24+
.join("\n");
25+
}
26+
return "";
27+
}
28+
29+
export function isError(result: Awaited<ReturnType<Client["callTool"]>>): boolean {
30+
return "isError" in result && result.isError === true;
31+
}
32+
33+
export async function createMcpTestContext(...registerFns: ToolRegistrationFn[]): Promise<McpTestContext> {
34+
const state: SessionState = { projectDir: undefined };
35+
36+
const server = new McpServer(
37+
{ name: "test-server", version: "0.0.0" },
38+
{ capabilities: { tools: {}, logging: {} } }
39+
);
40+
41+
// Register tools — default to project tools if none provided
42+
if (registerFns.length === 0) {
43+
const { registerProjectTools } = await import("@/tools/project.tools");
44+
registerProjectTools(server, state);
45+
} else {
46+
for (const fn of registerFns) {
47+
fn(server, state);
48+
}
49+
}
50+
51+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
52+
53+
const client = new Client({ name: "test-client", version: "0.0.0" });
54+
55+
await server.connect(serverTransport);
56+
await client.connect(clientTransport);
57+
58+
return {
59+
server,
60+
client,
61+
state,
62+
cleanup: async () => {
63+
await client.close();
64+
await server.close();
65+
}
66+
};
67+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { tmpdir } from "node:os";
4+
5+
interface TempMendixProjectOptions {
6+
projectName?: string;
7+
widgets?: string[];
8+
skipMpr?: boolean;
9+
skipWidgetsDir?: boolean;
10+
}
11+
12+
interface TempWidgetOptions {
13+
mpkName?: string;
14+
versionDir?: string;
15+
noMpk?: boolean;
16+
noDist?: boolean;
17+
}
18+
19+
interface TempDirResult {
20+
dir: string;
21+
cleanup: () => void;
22+
}
23+
24+
/**
25+
* Creates a temp directory that looks like a Mendix project.
26+
* Includes a .mpr file and optionally a widgets/ dir with .mpk files.
27+
*/
28+
export function createTempMendixProject(opts: TempMendixProjectOptions = {}): TempDirResult {
29+
const dir = mkdtempSync(join(tmpdir(), "mcp-test-project-"));
30+
const projectName = opts.projectName ?? "TestProject";
31+
32+
if (!opts.skipMpr) {
33+
writeFileSync(join(dir, `${projectName}.mpr`), "");
34+
}
35+
36+
if (!opts.skipWidgetsDir) {
37+
const widgetsDir = join(dir, "widgets");
38+
mkdirSync(widgetsDir, { recursive: true });
39+
40+
if (opts.widgets) {
41+
for (const w of opts.widgets) {
42+
writeFileSync(join(widgetsDir, w), "");
43+
}
44+
}
45+
}
46+
47+
return {
48+
dir,
49+
cleanup: () => rmSync(dir, { recursive: true, force: true })
50+
};
51+
}
52+
53+
/**
54+
* Creates a temp directory that looks like a built widget.
55+
* Includes a dist/ dir with an optional .mpk file (possibly nested in a version dir).
56+
*/
57+
export function createTempWidgetWithMpk(opts: TempWidgetOptions = {}): TempDirResult {
58+
const dir = mkdtempSync(join(tmpdir(), "mcp-test-widget-"));
59+
const mpkName = opts.mpkName ?? "TestWidget.mpk";
60+
61+
if (!opts.noDist) {
62+
const distDir = join(dir, "dist");
63+
mkdirSync(distDir, { recursive: true });
64+
65+
if (!opts.noMpk) {
66+
const mpkDir = opts.versionDir ? join(distDir, opts.versionDir) : distDir;
67+
mkdirSync(mpkDir, { recursive: true });
68+
writeFileSync(join(mpkDir, mpkName), "fake-mpk-content");
69+
}
70+
}
71+
72+
return {
73+
dir,
74+
cleanup: () => rmSync(dir, { recursive: true, force: true })
75+
};
76+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { join } from "node:path";
2+
import { writeFileSync } from "node:fs";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { validateProjectDir } from "@/config";
5+
import { createTempMendixProject } from "@/__test-utils__/temp-dir";
6+
7+
describe("validateProjectDir", () => {
8+
const cleanups: Array<() => void> = [];
9+
10+
afterEach(() => {
11+
for (const cleanup of cleanups) cleanup();
12+
cleanups.length = 0;
13+
});
14+
15+
it("returns invalid for a non-existent directory", async () => {
16+
const result = await validateProjectDir("/nonexistent/path/to/project");
17+
expect(result.valid).toBe(false);
18+
expect(result.error).toContain("does not exist");
19+
});
20+
21+
it("returns invalid when directory has no .mpr file", async () => {
22+
const { dir, cleanup } = createTempMendixProject({ skipMpr: true });
23+
cleanups.push(cleanup);
24+
const result = await validateProjectDir(dir);
25+
expect(result.valid).toBe(false);
26+
expect(result.error).toContain("No .mpr file");
27+
});
28+
29+
it("returns valid with correct projectName for a proper Mendix project", async () => {
30+
const { dir, cleanup } = createTempMendixProject({ projectName: "MyApp" });
31+
cleanups.push(cleanup);
32+
const result = await validateProjectDir(dir);
33+
expect(result.valid).toBe(true);
34+
expect(result.projectName).toBe("MyApp");
35+
});
36+
37+
it("sets widgetsDir to <dir>/widgets", async () => {
38+
const { dir, cleanup } = createTempMendixProject();
39+
cleanups.push(cleanup);
40+
const result = await validateProjectDir(dir);
41+
expect(result.widgetsDir).toBe(join(dir, "widgets"));
42+
});
43+
44+
it("lists .mpk files from widgets/ directory", async () => {
45+
const { dir, cleanup } = createTempMendixProject({
46+
widgets: ["Foo.mpk", "Bar.mpk"]
47+
});
48+
cleanups.push(cleanup);
49+
const result = await validateProjectDir(dir);
50+
expect(result.existingWidgets).toContain("Foo.mpk");
51+
expect(result.existingWidgets).toContain("Bar.mpk");
52+
});
53+
54+
it("returns empty existingWidgets when widgets/ dir does not exist", async () => {
55+
const { dir, cleanup } = createTempMendixProject({ skipWidgetsDir: true });
56+
cleanups.push(cleanup);
57+
const result = await validateProjectDir(dir);
58+
expect(result.valid).toBe(true);
59+
expect(result.existingWidgets).toEqual([]);
60+
});
61+
62+
it("filters out non-.mpk files from widgets/", async () => {
63+
const { dir, cleanup } = createTempMendixProject({ widgets: ["Widget.mpk"] });
64+
cleanups.push(cleanup);
65+
// Add a non-mpk file
66+
writeFileSync(join(dir, "widgets", "readme.txt"), "not a widget");
67+
const result = await validateProjectDir(dir);
68+
expect(result.existingWidgets).toEqual(["Widget.mpk"]);
69+
});
70+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isPathWithinDirectory, isExtensionAllowed, validateFilePath } from "@/security/guardrails";
3+
4+
describe("isPathWithinDirectory", () => {
5+
it("returns true for a path within the base directory", () => {
6+
expect(isPathWithinDirectory("/widgets/foo", "src/Bar.tsx")).toBe(true);
7+
});
8+
9+
it("returns true for a nested path within the base directory", () => {
10+
expect(isPathWithinDirectory("/widgets/foo", "src/components/deep/Bar.tsx")).toBe(true);
11+
});
12+
13+
it("returns false for .. traversal escaping the base", () => {
14+
expect(isPathWithinDirectory("/widgets/foo", "../../../etc/passwd")).toBe(false);
15+
});
16+
17+
it("returns false for a sibling directory (foobar vs foo)", () => {
18+
// /widgets/foobar is NOT within /widgets/foo (prefix attack)
19+
expect(isPathWithinDirectory("/widgets/foo", "../foobar/secret.txt")).toBe(false);
20+
});
21+
});
22+
23+
describe("isExtensionAllowed", () => {
24+
it.each([".tsx", ".ts", ".xml", ".scss", ".json"])("allows %s extension", ext => {
25+
expect(isExtensionAllowed(`Component${ext}`)).toBe(true);
26+
});
27+
28+
it.each([".exe", ".sh"])("rejects %s extension", ext => {
29+
expect(isExtensionAllowed(`script${ext}`)).toBe(false);
30+
});
31+
32+
it("allows .gitignore (dot-file in allowlist)", () => {
33+
expect(isExtensionAllowed(".gitignore")).toBe(true);
34+
});
35+
36+
it("allows extensionless config files like tsconfig", () => {
37+
expect(isExtensionAllowed("tsconfig")).toBe(true);
38+
});
39+
});
40+
41+
describe("validateFilePath", () => {
42+
it("does not throw for a valid path without extension check", () => {
43+
expect(() => validateFilePath("/widgets/foo", "src/Bar.tsx")).not.toThrow();
44+
});
45+
46+
it("throws for .. in the path", () => {
47+
expect(() => validateFilePath("/widgets/foo", "../secret.txt")).toThrow("Path traversal");
48+
});
49+
50+
it("throws for disallowed extension when checkExtension is true", () => {
51+
expect(() => validateFilePath("/widgets/foo", "src/script.exe", true)).toThrow("extension not allowed");
52+
});
53+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import { createMcpTestContext, getResultText, isError } from "@/__test-utils__/mcp-test-harness";
3+
import { createTempMendixProject } from "@/__test-utils__/temp-dir";
4+
import { registerBuildTools } from "@/tools/build.tools";
5+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6+
import { join } from "node:path";
7+
import { tmpdir } from "node:os";
8+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
9+
import type { SessionState } from "@/tools/session-state";
10+
11+
describe("build-widget sandbox expansion", () => {
12+
let client: Client;
13+
let state: SessionState;
14+
let cleanup: () => Promise<void>;
15+
const tempCleanups: Array<() => void> = [];
16+
17+
beforeEach(async () => {
18+
({ client, state, cleanup } = await createMcpTestContext(registerBuildTools));
19+
});
20+
21+
afterEach(async () => {
22+
await cleanup();
23+
for (const c of tempCleanups) c();
24+
tempCleanups.length = 0;
25+
});
26+
27+
it("rejects widget path outside allowed directories", async () => {
28+
// Create a real temp dir (must exist to pass the existsSync check)
29+
const rogueDir = mkdtempSync(join(tmpdir(), "mcp-test-rogue-"));
30+
tempCleanups.push(() => rmSync(rogueDir, { recursive: true, force: true }));
31+
state.projectDir = undefined;
32+
const result = await client.callTool({
33+
name: "build-widget",
34+
arguments: { widgetPath: rogueDir }
35+
});
36+
const text = getResultText(result);
37+
expect(isError(result)).toBe(true);
38+
expect(text).toContain("not within an allowed directory");
39+
});
40+
41+
it("allows widget path within state.projectDir", async () => {
42+
const { dir, cleanup: tempCleanup } = createTempMendixProject();
43+
tempCleanups.push(tempCleanup);
44+
state.projectDir = dir;
45+
46+
// Create a fake widget dir inside the project with a package.json
47+
const widgetDir = join(dir, "my-widget");
48+
mkdirSync(widgetDir, { recursive: true });
49+
writeFileSync(join(widgetDir, "package.json"), '{"name":"my-widget"}');
50+
51+
const result = await client.callTool({
52+
name: "build-widget",
53+
arguments: { widgetPath: widgetDir }
54+
});
55+
const text = getResultText(result);
56+
// Path check passed — build itself will fail (no real widget), but NOT with sandbox error
57+
expect(text).not.toContain("not within an allowed directory");
58+
});
59+
});

0 commit comments

Comments
 (0)