228 lines
6.4 KiB
TypeScript
228 lines
6.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import http from "http";
|
|
import type { AddressInfo } from "net";
|
|
|
|
let testHome: string;
|
|
|
|
async function loadConnectionConfigModule(): Promise<
|
|
typeof import("../src/main/config")
|
|
> {
|
|
vi.resetModules();
|
|
vi.stubEnv("HERMES_HOME", testHome);
|
|
return await import("../src/main/config");
|
|
}
|
|
|
|
function listen(server: http.Server): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address() as AddressInfo;
|
|
resolve(`http://127.0.0.1:${address.port}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("connection config secret exposure", () => {
|
|
beforeEach(() => {
|
|
testHome = mkdtempSync(join(tmpdir(), "hermes-connection-config-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
rmSync(testHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it("keeps the remote API key out of the public renderer config", async () => {
|
|
const {
|
|
getConnectionConfig,
|
|
getPublicConnectionConfig,
|
|
resolveConnectionApiKeyUpdate,
|
|
setConnectionConfig,
|
|
} = await loadConnectionConfigModule();
|
|
|
|
setConnectionConfig({
|
|
mode: "remote",
|
|
remoteUrl: "https://hermes.example",
|
|
apiKey: "remote-secret",
|
|
remoteChatTransport: "dashboard",
|
|
sshChatTransport: "auto",
|
|
ssh: getConnectionConfig().ssh,
|
|
});
|
|
|
|
expect(getConnectionConfig().apiKey).toBe("remote-secret");
|
|
|
|
const publicConfig = getPublicConnectionConfig();
|
|
expect(publicConfig).toMatchObject({
|
|
mode: "remote",
|
|
remoteUrl: "https://hermes.example",
|
|
remoteChatTransport: "dashboard",
|
|
sshChatTransport: "auto",
|
|
hasApiKey: true,
|
|
// Length is intentionally exposed so the renderer can render a
|
|
// mask that matches the stored key's width. The secret itself
|
|
// must NOT be present — covered by the assertions below.
|
|
apiKeyLength: "remote-secret".length,
|
|
});
|
|
expect("apiKey" in publicConfig).toBe(false);
|
|
expect(JSON.stringify(publicConfig)).not.toContain("remote-secret");
|
|
|
|
const existing = getConnectionConfig();
|
|
expect(
|
|
resolveConnectionApiKeyUpdate(
|
|
existing,
|
|
"remote",
|
|
"https://hermes.example",
|
|
),
|
|
).toBe("remote-secret");
|
|
expect(
|
|
resolveConnectionApiKeyUpdate(
|
|
existing,
|
|
"remote",
|
|
"https://attacker.example",
|
|
),
|
|
).toBe("");
|
|
});
|
|
|
|
it("reads desktop config files written with a UTF-8 BOM", async () => {
|
|
const { getConnectionConfig } = await loadConnectionConfigModule();
|
|
|
|
writeFileSync(
|
|
join(testHome, "desktop.json"),
|
|
`\uFEFF${JSON.stringify({
|
|
connectionMode: "remote",
|
|
remoteUrl: "https://hermes.example",
|
|
remoteApiKey: "remote-secret",
|
|
remoteChatTransport: "dashboard",
|
|
})}`,
|
|
"utf-8",
|
|
);
|
|
|
|
expect(getConnectionConfig()).toMatchObject({
|
|
mode: "remote",
|
|
remoteUrl: "https://hermes.example",
|
|
apiKey: "remote-secret",
|
|
remoteChatTransport: "dashboard",
|
|
});
|
|
});
|
|
|
|
it("uses the stored remote API key for main-process connection tests", async () => {
|
|
const { setConnectionConfig } = await loadConnectionConfigModule();
|
|
const { testRemoteConnection } = await import("../src/main/hermes");
|
|
const server = http.createServer((req, res) => {
|
|
res.statusCode =
|
|
req.headers.authorization === "Bearer remote-secret" ? 200 : 401;
|
|
res.end();
|
|
});
|
|
|
|
const url = await listen(server);
|
|
|
|
try {
|
|
setConnectionConfig({
|
|
mode: "remote",
|
|
remoteUrl: url,
|
|
apiKey: "remote-secret",
|
|
remoteChatTransport: "auto",
|
|
sshChatTransport: "auto",
|
|
ssh: {
|
|
host: "",
|
|
port: 22,
|
|
username: "",
|
|
keyPath: "",
|
|
remotePort: 8642,
|
|
localPort: 18642,
|
|
},
|
|
});
|
|
|
|
await expect(testRemoteConnection(url)).resolves.toBe(true);
|
|
await expect(testRemoteConnection(url, "wrong-secret")).resolves.toBe(
|
|
false,
|
|
);
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("preserves remote settings when switching away from remote mode", async () => {
|
|
const {
|
|
getConnectionConfig,
|
|
resolveConnectionApiKeyUpdate,
|
|
setConnectionConfig,
|
|
} = await loadConnectionConfigModule();
|
|
|
|
const ssh = getConnectionConfig().ssh;
|
|
setConnectionConfig({
|
|
mode: "remote",
|
|
remoteUrl: "https://hermes.example",
|
|
apiKey: "remote-secret",
|
|
remoteChatTransport: "dashboard",
|
|
sshChatTransport: "auto",
|
|
ssh,
|
|
});
|
|
|
|
setConnectionConfig({
|
|
...getConnectionConfig(),
|
|
mode: "local",
|
|
remoteUrl: "",
|
|
apiKey: "",
|
|
});
|
|
|
|
expect(getConnectionConfig()).toMatchObject({
|
|
mode: "local",
|
|
remoteUrl: "https://hermes.example",
|
|
apiKey: "remote-secret",
|
|
remoteChatTransport: "dashboard",
|
|
});
|
|
|
|
const localConfig = getConnectionConfig();
|
|
const restoredApiKey = resolveConnectionApiKeyUpdate(
|
|
localConfig,
|
|
"remote",
|
|
"https://hermes.example",
|
|
undefined,
|
|
);
|
|
setConnectionConfig({
|
|
...localConfig,
|
|
mode: "remote",
|
|
remoteUrl: "https://hermes.example",
|
|
apiKey: restoredApiKey,
|
|
});
|
|
|
|
expect(getConnectionConfig()).toMatchObject({
|
|
mode: "remote",
|
|
remoteUrl: "https://hermes.example",
|
|
apiKey: "remote-secret",
|
|
remoteChatTransport: "dashboard",
|
|
});
|
|
});
|
|
|
|
it("exposes SSH settings without exposing the stored remote API key", async () => {
|
|
const { getPublicConnectionConfig, setConnectionConfig } =
|
|
await loadConnectionConfigModule();
|
|
|
|
setConnectionConfig({
|
|
mode: "ssh",
|
|
remoteUrl: "",
|
|
apiKey: "remote-secret",
|
|
remoteChatTransport: "auto",
|
|
sshChatTransport: "legacy",
|
|
ssh: {
|
|
host: "example.internal",
|
|
port: 22,
|
|
username: "hermes",
|
|
keyPath: "~/.ssh/id_rsa",
|
|
remotePort: 8642,
|
|
localPort: 18642,
|
|
},
|
|
});
|
|
|
|
const publicConfig = getPublicConnectionConfig();
|
|
expect(publicConfig.mode).toBe("ssh");
|
|
expect(publicConfig.sshChatTransport).toBe("legacy");
|
|
expect(publicConfig.ssh.host).toBe("example.internal");
|
|
expect("apiKey" in publicConfig).toBe(false);
|
|
expect(JSON.stringify(publicConfig)).not.toContain("remote-secret");
|
|
});
|
|
});
|