Files
2026-07-13 13:36:53 +08:00

269 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "events";
/**
* Coverage for the in-app OAuth login feature (`src/main/hermes-auth.ts`):
*
* - provider allowlist (`isOAuthLoginProvider`)
* - `runHermesAuthLogin` spawns `hermes auth add <provider> --type
* oauth`, streams output, and maps exit code → success/failure
* - the single-flight guard rejects a second concurrent login
* - `cancelHermesAuthLogin` kills the in-flight subprocess
*/
interface FakeProc extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
}
const { spawnSpy, fakeProcs } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { EventEmitter: EE } = require("events");
const fakeProcs: FakeProc[] = [];
return {
fakeProcs,
spawnSpy: vi.fn(() => {
const proc = new EE() as FakeProc;
proc.stdout = new EE();
proc.stderr = new EE();
proc.kill = vi.fn();
fakeProcs.push(proc);
return proc;
}),
};
});
vi.mock("../src/main/installer", () => ({
HERMES_PYTHON: "/usr/bin/python3",
HERMES_REPO: "/tmp/hermes-repo",
HERMES_HOME: "/tmp/hermes-home",
hermesCliArgs: (args: string[]) => args,
getEnhancedPath: () => process.env.PATH || "",
}));
vi.mock("../src/main/process-options", () => ({
HIDDEN_SUBPROCESS_OPTIONS: {},
}));
vi.mock("../src/main/utils", () => ({
stripAnsi: (s: string) => s,
}));
// hermes-auth.ts only consumes `spawn` from child_process, so a minimal
// replacement is enough. Both the named export and a `default` are
// provided so the CJS↔ESM interop layer is satisfied.
vi.mock("child_process", () => ({
spawn: spawnSpy,
default: { spawn: spawnSpy },
}));
import {
runHermesAuthLogin,
cancelHermesAuthLogin,
isOAuthLoginProvider,
detectDeviceCode,
OAUTH_LOGIN_PROVIDERS,
} from "../src/main/hermes-auth";
function lastProc(): FakeProc {
return fakeProcs[fakeProcs.length - 1];
}
describe("detectDeviceCode", () => {
// The exact shape the OpenAI Codex CLI prints.
const codexPrompt = [
"To continue, follow these steps:",
"",
" 1. Open this URL in your browser:",
" https://auth.openai.com/codex/device",
"",
" 2. Enter this code:",
" BVY0-XEPCD",
"",
"Waiting for sign-in... (press Ctrl+C to cancel)",
].join("\n");
it("extracts the URL and code from a Codex device prompt", () => {
expect(detectDeviceCode(codexPrompt)).toEqual({
url: "https://auth.openai.com/codex/device",
code: "BVY0-XEPCD",
});
});
it("returns null until both URL and code are present", () => {
const partial =
" 1. Open this URL in your browser:\n https://auth.openai.com/codex/device\n";
expect(detectDeviceCode(partial)).toBeNull();
expect(detectDeviceCode("")).toBeNull();
});
it("ignores a non-https URL", () => {
const insecure = codexPrompt.replace("https://", "http://");
expect(detectDeviceCode(insecure)).toBeNull();
});
it("does not match browser-loopback provider output", () => {
// Gemini opens its own browser and has no device code — must not
// trigger the device-code path.
const gemini =
"Opening your browser to sign in to Google…\n" +
"If it does not open automatically, visit:\n" +
" https://accounts.google.com/o/oauth2/v2/auth?client_id=x\n";
expect(detectDeviceCode(gemini)).toBeNull();
});
it("does not silently consume a blank line between label and value", () => {
// A naive `\s*` for indentation would skip across the empty line and
// wrongly attach the wrong URL/code as the value (fathah's review on
// PR #280). Horizontal-whitespace-only stops at the linebreak.
const blankUrlGap = [
" 1. Open this URL in your browser:",
"",
" https://attacker.example/phish",
"",
" 2. Enter this code:",
" BVY0-XEPCD",
].join("\n");
expect(detectDeviceCode(blankUrlGap)).toBeNull();
const blankCodeGap = [
" 1. Open this URL in your browser:",
" https://auth.openai.com/codex/device",
"",
" 2. Enter this code:",
"",
" BVY0-XEPCD",
].join("\n");
expect(detectDeviceCode(blankCodeGap)).toBeNull();
});
});
describe("isOAuthLoginProvider", () => {
it("accepts the five OAuth-capable providers", () => {
for (const p of OAUTH_LOGIN_PROVIDERS) {
expect(isOAuthLoginProvider(p)).toBe(true);
}
});
it("rejects API-key providers and unknown values", () => {
expect(isOAuthLoginProvider("openrouter")).toBe(false);
expect(isOAuthLoginProvider("anthropic")).toBe(false);
expect(isOAuthLoginProvider("kimi-coding")).toBe(false);
expect(isOAuthLoginProvider("")).toBe(false);
});
});
describe("runHermesAuthLogin", () => {
beforeEach(() => {
spawnSpy.mockClear();
fakeProcs.length = 0;
});
it("refuses an unsupported provider without spawning", async () => {
const result = await runHermesAuthLogin("openrouter", () => {});
expect(result.success).toBe(false);
expect(result.error).toMatch(/unsupported/i);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("spawns `auth add <provider> --type oauth` and streams output", async () => {
const chunks: string[] = [];
const promise = runHermesAuthLogin("google-gemini-cli", (c) =>
chunks.push(c),
);
expect(spawnSpy).toHaveBeenCalledTimes(1);
const args = spawnSpy.mock.calls[0][1] as string[];
expect(args).toEqual([
"auth",
"add",
"google-gemini-cli",
"--type",
"oauth",
]);
const options = spawnSpy.mock.calls[0][2] as {
env?: Record<string, string | undefined>;
};
expect(options.env?.PYTHONUNBUFFERED).toBe("1");
const proc = lastProc();
proc.stdout.emit("data", Buffer.from("Opening browser for sign-in...\n"));
proc.stderr.emit("data", Buffer.from("waiting for redirect\n"));
proc.emit("close", 0, null);
const result = await promise;
expect(result.success).toBe(true);
expect(chunks.join("")).toContain("Opening browser");
expect(chunks.join("")).toContain("waiting for redirect");
});
it("passes the profile flag when a named profile is given", async () => {
const promise = runHermesAuthLogin("xai-oauth", () => {}, "work");
const args = spawnSpy.mock.calls[0][1] as string[];
expect(args).toEqual([
"-p",
"work",
"auth",
"add",
"xai-oauth",
"--type",
"oauth",
]);
lastProc().emit("close", 0, null);
await promise;
});
it("reports failure on a non-zero exit code", async () => {
const promise = runHermesAuthLogin("qwen-oauth", () => {});
lastProc().emit("close", 1, null);
const result = await promise;
expect(result.success).toBe(false);
expect(result.error).toMatch(/code 1/);
});
it("reports cancellation when the process is killed by signal", async () => {
const promise = runHermesAuthLogin("minimax-oauth", () => {});
lastProc().emit("close", null, "SIGTERM");
const result = await promise;
expect(result.success).toBe(false);
expect(result.error).toMatch(/cancelled/i);
});
it("rejects a second concurrent login while one is in flight", async () => {
const first = runHermesAuthLogin("openai-codex", () => {});
const second = await runHermesAuthLogin("xai-oauth", () => {});
expect(second.success).toBe(false);
expect(second.error).toMatch(/already in progress/i);
// Only the first login spawned a process.
expect(spawnSpy).toHaveBeenCalledTimes(1);
lastProc().emit("close", 0, null);
await first;
});
});
describe("cancelHermesAuthLogin", () => {
beforeEach(() => {
spawnSpy.mockClear();
fakeProcs.length = 0;
});
it("returns false when no login is running", () => {
expect(cancelHermesAuthLogin()).toBe(false);
});
it("kills the in-flight subprocess and returns true", async () => {
const promise = runHermesAuthLogin("openai-codex", () => {});
const proc = lastProc();
expect(cancelHermesAuthLogin()).toBe(true);
expect(proc.kill).toHaveBeenCalled();
// Real kill would fire a close with a signal; emulate so the
// promise settles and the single-flight slot is released.
proc.emit("close", null, "SIGTERM");
await promise;
expect(cancelHermesAuthLogin()).toBe(false);
});
});