singularity-forge/packages/mcp-server/src/secure-env-collect.test.ts
2026-04-15 13:38:15 +02:00

265 lines
9.8 KiB
TypeScript

// @singularity-forge/mcp-server — Tests for secure_env_collect MCP tool
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Tests the secure_env_collect tool registered in createMcpServer.
// Uses a mock MCP server to intercept tool registration and elicitInput calls.
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMcpServer } from './server.js';
import { SessionManager } from './session-manager.js';
// ---------------------------------------------------------------------------
// Mock infrastructure
// ---------------------------------------------------------------------------
/**
* We intercept McpServer construction by monkey-patching the dynamic import.
* Instead, we'll test the tool handler indirectly through the exported
* createMcpServer function — capturing the registered tool handlers.
*
* Since createMcpServer dynamically imports McpServer, we need to test at
* a level that exercises the tool handler logic. We do this by extracting
* the tool handler through the server.tool() calls.
*/
interface RegisteredTool {
name: string;
description: string;
params: Record<string, unknown>;
handler: (args: Record<string, unknown>) => Promise<unknown>;
}
interface ToolResult {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
}
/**
* Mock McpServer that captures tool registrations and provides
* a controllable elicitInput response.
*/
class MockMcpServer {
registeredTools: RegisteredTool[] = [];
elicitResponse: { action: string; content?: Record<string, unknown> } = { action: 'accept', content: {} };
server = {
elicitInput: async (_params: unknown) => {
return this.elicitResponse;
},
};
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>) {
this.registeredTools.push({ name, description, params, handler });
}
async connect(_transport: unknown) { /* no-op */ }
async close() { /* no-op */ }
getToolHandler(name: string): ((args: Record<string, unknown>) => Promise<unknown>) | undefined {
return this.registeredTools.find((t) => t.name === name)?.handler;
}
}
// ---------------------------------------------------------------------------
// Helper to create a mock MCP server with secure_env_collect registered
// ---------------------------------------------------------------------------
/**
* Since createMcpServer uses dynamic import for McpServer, we can't easily
* mock it. Instead, we test the env-writer utilities directly (in env-writer.test.ts)
* and test the tool integration by verifying:
* 1. The tool exists in the registered tools list
* 2. The handler produces correct results with mock data
*
* For handler-level testing, we create a standalone test that replicates
* the tool handler logic with a controllable mock.
*/
function makeTempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), `${prefix}-`));
}
// ---------------------------------------------------------------------------
// Integration test — verify tool is registered
// ---------------------------------------------------------------------------
describe('secure_env_collect tool registration', () => {
it('createMcpServer registers secure_env_collect tool', async () => {
// This test verifies the tool exists — createMcpServer internally calls
// server.tool('secure_env_collect', ...) which we can't intercept without
// module mocking, but we can verify the server creates successfully
const sm = new SessionManager();
try {
const { server } = await createMcpServer(sm);
assert.ok(server, 'server should be created');
// The McpServer internally tracks registered tools — we verify no error
} finally {
await sm.cleanup();
}
});
});
// ---------------------------------------------------------------------------
// Handler logic tests — using env-writer directly to test the flow
// ---------------------------------------------------------------------------
describe('secure_env_collect handler logic', () => {
it('skips keys that already exist in .env', async () => {
const tmp = makeTempDir('sec-collect');
try {
const envPath = join(tmp, '.env');
writeFileSync(envPath, 'ALREADY_SET=existing-value\n');
// Import the utility directly to test the pre-check logic
const { checkExistingEnvKeys } = await import('./env-writer.js');
const existing = await checkExistingEnvKeys(['ALREADY_SET', 'NEW_KEY'], envPath);
assert.deepStrictEqual(existing, ['ALREADY_SET']);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('writes collected values to .env without returning secret values', async () => {
const tmp = makeTempDir('sec-collect');
try {
const envPath = join(tmp, '.env');
const savedKey = process.env.SEC_COLLECT_TEST_KEY;
const { applySecrets } = await import('./env-writer.js');
const { applied, errors } = await applySecrets(
[{ key: 'SEC_COLLECT_TEST_KEY', value: 'super-secret-value' }],
'dotenv',
{ envFilePath: envPath },
);
assert.deepStrictEqual(applied, ['SEC_COLLECT_TEST_KEY']);
assert.deepStrictEqual(errors, []);
// Verify the value was written
const content = readFileSync(envPath, 'utf8');
assert.ok(content.includes('SEC_COLLECT_TEST_KEY=super-secret-value'));
// Verify process.env was hydrated
assert.equal(process.env.SEC_COLLECT_TEST_KEY, 'super-secret-value');
// Cleanup
if (savedKey === undefined) delete process.env.SEC_COLLECT_TEST_KEY;
else process.env.SEC_COLLECT_TEST_KEY = savedKey;
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('auto-detects vercel destination from vercel.json', async () => {
const tmp = makeTempDir('sec-collect');
try {
writeFileSync(join(tmp, 'vercel.json'), '{}');
const { detectDestination } = await import('./env-writer.js');
assert.equal(detectDestination(tmp), 'vercel');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('handles empty form values as skipped', async () => {
// Simulate what happens when user leaves a field empty in the form
const formContent: Record<string, string> = {
'API_KEY': 'provided-value',
'OPTIONAL_KEY': '', // empty = skip
};
const provided: Array<{ key: string; value: string }> = [];
const skipped: string[] = [];
for (const [key, raw] of Object.entries(formContent)) {
const value = typeof raw === 'string' ? raw.trim() : '';
if (value.length > 0) {
provided.push({ key, value });
} else {
skipped.push(key);
}
}
assert.deepStrictEqual(provided, [{ key: 'API_KEY', value: 'provided-value' }]);
assert.deepStrictEqual(skipped, ['OPTIONAL_KEY']);
});
it('result text never contains secret values', async () => {
const tmp = makeTempDir('sec-collect');
try {
const envPath = join(tmp, '.env');
const savedKey = process.env.RESULT_TEXT_TEST;
const { applySecrets } = await import('./env-writer.js');
const { applied } = await applySecrets(
[{ key: 'RESULT_TEXT_TEST', value: 'sk-super-secret-abc123' }],
'dotenv',
{ envFilePath: envPath },
);
// Simulate building result text (same logic as the tool handler)
const lines: string[] = [
'destination: dotenv (auto-detected)',
...applied.map((k) => `${k}: applied`),
];
const resultText = lines.join('\n');
// The result MUST NOT contain the secret value
assert.ok(!resultText.includes('sk-super-secret-abc123'), 'result text must not contain secret value');
assert.ok(resultText.includes('RESULT_TEXT_TEST'), 'result text should contain key name');
// Cleanup
if (savedKey === undefined) delete process.env.RESULT_TEXT_TEST;
else process.env.RESULT_TEXT_TEST = savedKey;
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('handles multiple keys with mixed existing/new/skipped', async () => {
const tmp = makeTempDir('sec-collect');
try {
const envPath = join(tmp, '.env');
writeFileSync(envPath, 'EXISTING_A=already-here\n');
const savedB = process.env.NEW_B;
const savedC = process.env.SKIP_C;
const { checkExistingEnvKeys, applySecrets } = await import('./env-writer.js');
const allKeys = ['EXISTING_A', 'NEW_B', 'SKIP_C'];
const existing = await checkExistingEnvKeys(allKeys, envPath);
assert.deepStrictEqual(existing, ['EXISTING_A']);
// Simulate form response: NEW_B has value, SKIP_C is empty
const formContent = { NEW_B: 'new-value', SKIP_C: '' };
const provided: Array<{ key: string; value: string }> = [];
const skipped: string[] = [];
for (const key of allKeys.filter((k) => !existing.includes(k))) {
const raw = formContent[key as keyof typeof formContent] ?? '';
if (raw.trim().length > 0) provided.push({ key, value: raw.trim() });
else skipped.push(key);
}
const { applied, errors } = await applySecrets(provided, 'dotenv', { envFilePath: envPath });
assert.deepStrictEqual(applied, ['NEW_B']);
assert.deepStrictEqual(skipped, ['SKIP_C']);
assert.deepStrictEqual(errors, []);
assert.deepStrictEqual(existing, ['EXISTING_A']);
// Cleanup
if (savedB === undefined) delete process.env.NEW_B;
else process.env.NEW_B = savedB;
if (savedC === undefined) delete process.env.SKIP_C;
else process.env.SKIP_C = savedC;
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});