Files
oh-my-pi/packages/coding-agent/test/mcp-command-reauth.test.ts
T
can1357 9ffaace8bc test(coding-agent): prevented sqlite database handle leaks in tests
- Track and explicitly close in-memory SQLite database handles in MCP auth tests.
- Resolved Bun GC-related crashes occurring when parallel tests finalize dangling database handles.
2026-06-25 20:59:38 +02:00

271 lines
9.8 KiB
TypeScript

import { Database } from "bun:sqlite";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
import * as mcpClient from "@oh-my-pi/pi-coding-agent/mcp/client";
import * as oauthFlow from "@oh-my-pi/pi-coding-agent/mcp/oauth-flow";
import type { MCPServerConfig } from "@oh-my-pi/pi-coding-agent/mcp/types";
import { MCPCommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/mcp-command-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { getConfigRootDir, getMCPConfigPath, getProjectDir, setAgentDir, setProjectDir } from "@oh-my-pi/pi-utils";
const RAW_SERVER_URL = `https://\${MCP_HOST}/mcp`;
const EXPANDED_SERVER_URL = "https://mcp.example.com/mcp";
const AUTH_ERROR = new Error(
'HTTP 401: {"authorization_url":"https://auth.example.com/authorize","token_url":"https://auth.example.com/token"}',
);
type TestConfigFile = {
mcpServers?: Record<string, MCPServerConfig>;
};
const originalProjectDir = getProjectDir();
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
function restoreEnvValue(name: string, value: string | undefined): void {
if (value === undefined) {
delete Bun.env[name];
delete process.env[name];
return;
}
Bun.env[name] = value;
process.env[name] = value;
}
function createController(authStorage: AuthStorage, mcpManagerOverrides: Record<string, unknown> = {}) {
const showError = vi.fn();
const prepareConfig = vi.fn(async (config: MCPServerConfig) => config);
const mcpManager = {
prepareConfig,
disconnectAll: vi.fn(async () => {}),
discoverAndConnect: vi.fn(async () => ({ errors: new Map<string, string>() })),
getTools: vi.fn(() => []),
waitForConnection: vi.fn(async () => {}),
getConnectionStatus: vi.fn(() => "connected"),
...mcpManagerOverrides,
};
const controller = new MCPCommandController({
chatContainer: { addChild: vi.fn() },
present: vi.fn(),
ui: { requestRender: vi.fn() },
editor: {},
showError,
showStatus: vi.fn(),
oauthManualInput: {
hasPending: vi.fn(() => false),
pendingProviderId: undefined,
tryClaimInput: vi.fn(),
},
session: {
refreshMCPTools: vi.fn(),
modelRegistry: { authStorage },
},
mcpManager,
} as never);
return { controller, showError, prepareConfig, mcpManager };
}
describe("/mcp auth commands", () => {
let projectDir = "";
let agentDir = "";
let configPath = "";
let originalMcpHost: string | undefined;
// Track every in-memory auth store so afterEach can close the underlying
// bun:sqlite Database. Leaked Database handles are JSDestructibleObjects that
// JSC otherwise finalizes during an arbitrary later GC sweep — under
// `bun test --parallel` that sweep can run mid-suite on the shared VM and
// trip a Bun GC crash (SIGABRT "Pure virtual function called").
const openAuthStores: AuthStorage[] = [];
function freshAuthStorage(): AuthStorage {
const storage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")));
openAuthStores.push(storage);
return storage;
}
beforeAll(() => {
initTheme();
});
beforeEach(async () => {
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-reauth-project-"));
agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-reauth-agent-"));
configPath = path.join(projectDir, ".mcp.json");
originalMcpHost = Bun.env.MCP_HOST;
Bun.env.MCP_HOST = "mcp.example.com";
process.env.MCP_HOST = "mcp.example.com";
setProjectDir(projectDir);
setAgentDir(agentDir);
await Bun.write(
configPath,
`${JSON.stringify(
{
mcpServers: {
envserver: {
type: "http",
url: RAW_SERVER_URL,
},
},
},
null,
2,
)}\n`,
);
});
afterEach(async () => {
while (openAuthStores.length > 0) openAuthStores.pop()?.close();
vi.restoreAllMocks();
restoreEnvValue("MCP_HOST", originalMcpHost);
setProjectDir(originalProjectDir);
if (originalAgentDir) {
setAgentDir(originalAgentDir);
} else {
setAgentDir(fallbackAgentDir);
delete process.env.PI_CODING_AGENT_DIR;
}
await fs.rm(projectDir, { recursive: true, force: true });
await fs.rm(agentDir, { recursive: true, force: true });
});
test("stores definition-only OAuth credentials under the expanded URL key", async () => {
const authStorage = freshAuthStorage();
await authStorage.reload();
const connectToServer = vi.spyOn(mcpClient, "connectToServer").mockRejectedValue(AUTH_ERROR);
vi.spyOn(oauthFlow.MCPOAuthFlow.prototype, "login").mockResolvedValue({
access: "fresh-access",
refresh: "fresh-refresh",
expires: Date.now() + 3_600_000,
});
const { controller, showError, prepareConfig } = createController(authStorage);
await controller.handle("/mcp reauth envserver");
expect(showError).not.toHaveBeenCalled();
expect(prepareConfig).toHaveBeenCalledWith(
expect.objectContaining({ url: EXPANDED_SERVER_URL }),
expect.objectContaining({ oauth: false }),
);
expect(connectToServer).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ url: EXPANDED_SERVER_URL }),
);
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toMatchObject({
type: "oauth",
access: "fresh-access",
tokenUrl: "https://auth.example.com/token",
resource: EXPANDED_SERVER_URL,
});
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(RAW_SERVER_URL))).toBeUndefined();
const saved = JSON.parse(await Bun.file(configPath).text()) as TestConfigFile;
const savedServer = saved.mcpServers?.envserver;
const savedUrl = savedServer?.type === "http" || savedServer?.type === "sse" ? savedServer.url : undefined;
expect(savedUrl).toBe(RAW_SERVER_URL);
expect(savedServer?.auth).toBeUndefined();
});
test("reuses embedded DCR client secret during reauth token exchange", async () => {
const authStorage = freshAuthStorage();
await authStorage.reload();
await authStorage.set(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL), {
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: Date.now() + 3_600_000,
tokenUrl: "https://auth.example.com/token",
clientId: "dcr-client",
clientSecret: "dcr-secret",
resource: EXPANDED_SERVER_URL,
} as oauthFlow.MCPStoredOAuthCredential);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
access_token: "fresh-access",
refresh_token: "fresh-refresh",
expires_in: 3600,
token_type: "Bearer",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.spyOn(mcpClient, "connectToServer").mockRejectedValue(AUTH_ERROR);
vi.spyOn(oauthFlow.MCPOAuthFlow.prototype, "login").mockImplementation(function (this: oauthFlow.MCPOAuthFlow) {
return this.exchangeToken("authorization-code", "state", "http://127.0.0.1/callback");
});
const { controller, showError } = createController(authStorage);
await controller.handle("/mcp reauth envserver");
expect(showError).not.toHaveBeenCalled();
const tokenRequestBody = String(fetchSpy.mock.calls[0]?.[1]?.body ?? "");
const tokenRequest = new URLSearchParams(tokenRequestBody);
expect(tokenRequest.get("client_id")).toBe("dcr-client");
expect(tokenRequest.get("client_secret")).toBe("dcr-secret");
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toMatchObject({
type: "oauth",
access: "fresh-access",
clientId: "dcr-client",
clientSecret: "dcr-secret",
});
});
test("clears both expanded and stale raw URL-keyed credentials on unauth", async () => {
const authStorage = freshAuthStorage();
await authStorage.reload();
await authStorage.set(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL), {
type: "oauth",
access: "expanded-access",
refresh: "expanded-refresh",
expires: Date.now() + 3_600_000,
});
await authStorage.set(oauthFlow.mcpOAuthCredentialId(RAW_SERVER_URL), {
type: "oauth",
access: "raw-access",
refresh: "raw-refresh",
expires: Date.now() + 3_600_000,
});
const { controller, showError } = createController(authStorage);
await controller.handle("/mcp unauth envserver");
expect(showError).not.toHaveBeenCalled();
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toBeUndefined();
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(RAW_SERVER_URL))).toBeUndefined();
const saved = JSON.parse(await Bun.file(configPath).text()) as TestConfigFile;
const savedServer = saved.mcpServers?.envserver;
const savedUrl = savedServer?.type === "http" || savedServer?.type === "sse" ? savedServer.url : undefined;
expect(savedUrl).toBe(RAW_SERVER_URL);
expect(savedServer?.auth).toBeUndefined();
});
test("clears url-keyed auth for discovered definition-only servers", async () => {
const authStorage = freshAuthStorage();
await authStorage.reload();
await authStorage.set(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL), {
type: "oauth",
access: "discovered-access",
refresh: "discovered-refresh",
expires: Date.now() + 3_600_000,
});
const { controller, showError } = createController(authStorage, {
getServerConfig: vi.fn(() => ({ type: "http", url: EXPANDED_SERVER_URL })),
getSource: vi.fn(() => ({ provider: "test", path: "/tmp/discovered.json" })),
});
await controller.handle("/mcp unauth discovered");
expect(showError).not.toHaveBeenCalled();
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toBeUndefined();
const userConfigPath = getMCPConfigPath("user", projectDir);
const userConfig = JSON.parse(
await Bun.file(userConfigPath)
.text()
.catch(() => "{}"),
) as TestConfigFile;
expect(userConfig.mcpServers?.discovered).toBeUndefined();
});
});