Skip to content

Commit 1b3124c

Browse files
brookscclaude
andcommitted
test: add StartMCPServer input validation tests (TODO johannesjo#35)
Extract validateStartMCPServerArgs from the handler into a testable exported function, then add Layer 4 tests covering all five rejection paths (non-absolute projectRoot/worktreePath, ".." traversal, non-string agentArgs elements, shell-special dockerContainerName). Each rejection test also spies on fs.writeFileSync/copyFileSync to confirm no I/O occurs before the error is thrown. Two positive tests verify optional fields (worktreePath, dockerContainerName) can be omitted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1f30548 commit 1b3124c

2 files changed

Lines changed: 107 additions & 12 deletions

File tree

electron/ipc/register-mcp.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
buildCoordinatorMCPConfig,
2020
getDockerMcpServerDestPath,
2121
selectMcpJsonDir,
22+
validateStartMCPServerArgs,
2223
} from './register.js';
2324
import { getMCPRemoteServerUrl } from '../mcp/config.js';
2425
import { startRemoteServer } from '../remote/server.js';
@@ -193,6 +194,95 @@ describe('Layer 3 — MCP startup pipeline (no Electron, real FS)', () => {
193194
});
194195
});
195196

197+
// ─────────────────────────────────────────────────────────────────────────────
198+
// Layer 4: StartMCPServer input validation
199+
// ─────────────────────────────────────────────────────────────────────────────
200+
201+
const VALID_ARGS = {
202+
coordinatorTaskId: 'coord-1',
203+
projectId: 'proj-1',
204+
projectRoot: '/absolute/project',
205+
worktreePath: '/absolute/worktree',
206+
agentArgs: ['--flag', 'value'],
207+
dockerContainerName: 'my-container',
208+
};
209+
210+
describe('Layer 4 — StartMCPServer input validation', () => {
211+
it('accepts valid args without throwing', () => {
212+
expect(() => validateStartMCPServerArgs(VALID_ARGS)).not.toThrow();
213+
});
214+
215+
it('rejects non-absolute projectRoot', () => {
216+
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
217+
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');
218+
219+
expect(() =>
220+
validateStartMCPServerArgs({ ...VALID_ARGS, projectRoot: 'relative/path' }),
221+
).toThrow('projectRoot must be absolute');
222+
223+
expect(writeFileSpy).not.toHaveBeenCalled();
224+
expect(copyFileSpy).not.toHaveBeenCalled();
225+
});
226+
227+
it('rejects projectRoot containing ".."', () => {
228+
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
229+
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');
230+
231+
expect(() => validateStartMCPServerArgs({ ...VALID_ARGS, projectRoot: '/tmp/../etc' })).toThrow(
232+
'projectRoot must not contain ".."',
233+
);
234+
235+
expect(writeFileSpy).not.toHaveBeenCalled();
236+
expect(copyFileSpy).not.toHaveBeenCalled();
237+
});
238+
239+
it('rejects non-absolute worktreePath', () => {
240+
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
241+
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');
242+
243+
expect(() =>
244+
validateStartMCPServerArgs({ ...VALID_ARGS, worktreePath: 'relative/worktree' }),
245+
).toThrow('worktreePath must be absolute');
246+
247+
expect(writeFileSpy).not.toHaveBeenCalled();
248+
expect(copyFileSpy).not.toHaveBeenCalled();
249+
});
250+
251+
it('rejects agentArgs containing a non-string element', () => {
252+
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
253+
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');
254+
255+
expect(() => validateStartMCPServerArgs({ ...VALID_ARGS, agentArgs: [1, 'foo'] })).toThrow(
256+
'agentArgs must be a string array',
257+
);
258+
259+
expect(writeFileSpy).not.toHaveBeenCalled();
260+
expect(copyFileSpy).not.toHaveBeenCalled();
261+
});
262+
263+
it('rejects dockerContainerName with shell-special characters', () => {
264+
const writeFileSpy = vi.spyOn(fs, 'writeFileSync');
265+
const copyFileSpy = vi.spyOn(fs, 'copyFileSync');
266+
267+
expect(() =>
268+
validateStartMCPServerArgs({ ...VALID_ARGS, dockerContainerName: '; rm -rf /' }),
269+
).toThrow('dockerContainerName contains invalid characters');
270+
271+
expect(writeFileSpy).not.toHaveBeenCalled();
272+
expect(copyFileSpy).not.toHaveBeenCalled();
273+
});
274+
275+
it('accepts worktreePath undefined (optional field)', () => {
276+
const { worktreePath: _, ...argsWithoutWorktree } = VALID_ARGS;
277+
expect(() => validateStartMCPServerArgs(argsWithoutWorktree)).not.toThrow();
278+
});
279+
280+
it('accepts dockerContainerName undefined (optional field)', () => {
281+
const { dockerContainerName: _, ...argsWithoutDocker } = VALID_ARGS;
282+
expect(() => validateStartMCPServerArgs(argsWithoutDocker)).not.toThrow();
283+
});
284+
});
285+
196286
// ─────────────────────────────────────────────────────────────────────────────
197287
// Layer 5: Failure-mode tests
198288
// ─────────────────────────────────────────────────────────────────────────────

electron/ipc/register.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,22 @@ function validatePath(p: unknown, label: string): void {
164164
if (p.includes('..')) throw new Error(`${label} must not contain ".."`);
165165
}
166166

167+
/** Validates renderer-supplied args for StartMCPServer before any file I/O. Exported for testing. */
168+
export function validateStartMCPServerArgs(args: Record<string, unknown>): void {
169+
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
170+
assertString(args.projectId, 'projectId');
171+
validatePath(args.projectRoot, 'projectRoot');
172+
if (args.worktreePath !== undefined) validatePath(args.worktreePath, 'worktreePath');
173+
if (args.agentCommand !== undefined) assertString(args.agentCommand, 'agentCommand');
174+
if (args.agentArgs !== undefined) assertStringArray(args.agentArgs, 'agentArgs');
175+
if (args.dockerContainerName !== undefined) {
176+
assertString(args.dockerContainerName, 'dockerContainerName');
177+
if (!/^[a-zA-Z0-9_.-]+$/.test(args.dockerContainerName as string)) {
178+
throw new Error('dockerContainerName contains invalid characters');
179+
}
180+
}
181+
}
182+
167183
/** Reject relative paths that attempt directory traversal or are absolute. */
168184
function validateRelativePath(p: unknown, label: string): void {
169185
if (typeof p !== 'string') throw new Error(`${label} must be a string`);
@@ -1267,18 +1283,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
12671283
dockerContainerName?: string;
12681284
},
12691285
) => {
1270-
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
1271-
assertString(args.projectId, 'projectId');
1272-
validatePath(args.projectRoot, 'projectRoot');
1273-
if (args.worktreePath !== undefined) validatePath(args.worktreePath, 'worktreePath');
1274-
if (args.agentCommand !== undefined) assertString(args.agentCommand, 'agentCommand');
1275-
if (args.agentArgs !== undefined) assertStringArray(args.agentArgs, 'agentArgs');
1276-
if (args.dockerContainerName !== undefined) {
1277-
assertString(args.dockerContainerName, 'dockerContainerName');
1278-
if (!/^[a-zA-Z0-9_.-]+$/.test(args.dockerContainerName)) {
1279-
throw new Error('dockerContainerName contains invalid characters');
1280-
}
1281-
}
1286+
validateStartMCPServerArgs(args as unknown as Record<string, unknown>);
12821287

12831288
await enableCoordinatorMode();
12841289
if (!coordinator) return;

0 commit comments

Comments
 (0)