227 lines
7.6 KiB
TypeScript
227 lines
7.6 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.
|
|
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, it } from "vitest";
|
|
|
|
import { createMcpServer } from "./server.js";
|
|
import { SessionManager } from "./session-manager.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
});
|
|
});
|