/** * Tests for $D OpenAI auth source reporting (#1278, closes #1248). * * Verifies that resolveApiKey + requireApiKey: * - prefer ~/.gstack/openai.json over OPENAI_API_KEY * - report when the env-var key matches a cwd .env / .env.local * - never echo the key itself to stderr (only the source label) */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { describeApiKeySource, openaiUrl, requireApiKey, resolveApiKey, resolveApiKeyInfo, saveApiKey, } from "../src/auth"; let tmpDir: string; let tmpHome: string; let originalHome: string | undefined; let originalKey: string | undefined; let originalBaseUrl: string | undefined; let originalNodeEnv: string | undefined; let originalCwd: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-design-auth-")); tmpHome = path.join(tmpDir, "home"); fs.mkdirSync(tmpHome, { recursive: true }); originalHome = process.env.HOME; originalKey = process.env.OPENAI_API_KEY; originalBaseUrl = process.env.OPENAI_BASE_URL; originalNodeEnv = process.env.NODE_ENV; originalCwd = process.cwd(); process.env.HOME = tmpHome; delete process.env.OPENAI_API_KEY; delete process.env.OPENAI_BASE_URL; delete process.env.NODE_ENV; process.chdir(tmpDir); }); afterEach(() => { process.chdir(originalCwd); if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = originalKey; if (originalBaseUrl === undefined) delete process.env.OPENAI_BASE_URL; else process.env.OPENAI_BASE_URL = originalBaseUrl; if (originalNodeEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = originalNodeEnv; fs.rmSync(tmpDir, { recursive: true, force: true }); }); describe("resolveApiKeyInfo", () => { test("uses ~/.gstack/openai.json before OPENAI_API_KEY", () => { saveApiKey("sk-config"); process.env.OPENAI_API_KEY = "sk-env"; const resolution = resolveApiKeyInfo(); expect(resolution?.key).toBe("sk-config"); expect(resolution?.source).toBe("config"); expect(describeApiKeySource(resolution!)).toBe("~/.gstack/openai.json"); expect(resolveApiKey()).toBe("sk-config"); }); test("uses OPENAI_API_KEY when no config file exists", () => { process.env.OPENAI_API_KEY = "sk-env"; const resolution = resolveApiKeyInfo(); expect(resolution?.key).toBe("sk-env"); expect(resolution?.source).toBe("env"); expect(resolution?.envFile).toBeUndefined(); expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable"); }); test("reports when OPENAI_API_KEY matches current-directory .env", () => { fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-project\n"); process.env.OPENAI_API_KEY = "sk-project"; const resolution = resolveApiKeyInfo(); expect(resolution?.key).toBe("sk-project"); expect(resolution?.envFile).toBe(".env"); expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable (matches .env in current directory)"); expect(resolution?.warning).toContain("may bill that project's OpenAI account"); }); test("detects quoted and exported env-file values", () => { fs.writeFileSync(path.join(tmpDir, ".env.local"), "export OPENAI_API_KEY=\"sk-local\"\n"); process.env.OPENAI_API_KEY = "sk-local"; const resolution = resolveApiKeyInfo(); expect(resolution?.envFile).toBe(".env.local"); expect(resolution?.warning).toContain(".env.local"); }); test("does not claim env-file source when values differ", () => { fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-other\n"); process.env.OPENAI_API_KEY = "sk-shell"; const resolution = resolveApiKeyInfo(); expect(resolution?.key).toBe("sk-shell"); expect(resolution?.envFile).toBeUndefined(); expect(resolution?.warning).toBeUndefined(); }); }); describe("saveApiKey", () => { test("stores the key file owner-only, even under a permissive umask", () => { // The OpenAI key file must never be group/other-readable. saveApiKey now // creates it with mode 0600 up front (matching session.ts / #859) instead // of writing at the default umask and tightening afterwards, so the key is // not briefly world-readable in the write-then-chmod window (CWE-377/367). const prevUmask = process.umask(0o000); try { saveApiKey("sk-secret-value"); } finally { process.umask(prevUmask); } const keyPath = path.join(tmpHome, ".gstack", "openai.json"); const mode = fs.statSync(keyPath).mode & 0o777; expect(mode).toBe(0o600); // No group/other read/write/exec bits. expect(mode & 0o077).toBe(0); }); }); describe("openaiUrl", () => { test("defaults to OpenAI; a blank OPENAI_BASE_URL is unset", () => { expect(openaiUrl("responses")).toBe("https://api.openai.com/v1/responses"); process.env.OPENAI_BASE_URL = " "; expect(openaiUrl("chat/completions")).toBe("https://api.openai.com/v1/chat/completions"); }); test("uses a trimmed OPENAI_BASE_URL without duplicate slashes", () => { process.env.OPENAI_BASE_URL = " https://gateway.example/openai/v1/// "; expect(openaiUrl("responses")).toBe("https://gateway.example/openai/v1/responses"); }); test("no design source outside auth.ts hardcodes an OpenAI API URL", () => { const designDir = path.resolve(import.meta.dir, ".."); const walk = (dir: string): string[] => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const p = path.join(dir, e.name); if (e.isDirectory()) return e.name === "test" || e.name === "node_modules" || e.name === "dist" ? [] : walk(p); return /\.[cm]?[jt]s$/.test(e.name) ? [p] : []; }); const hardcoded = walk(designDir) .filter((p) => p !== path.join(designDir, "src", "auth.ts")) .filter((p) => fs.readFileSync(p, "utf-8").includes("https://api.openai.com")) .map((p) => path.relative(designDir, p)); expect(hardcoded).toEqual([]); }); }); describe("requireApiKey", () => { test("prints source disclosure without leaking the key", () => { process.env.OPENAI_API_KEY = "sk-secret-value"; const messages: string[] = []; const originalError = console.error; console.error = (...args: unknown[]) => { messages.push(args.map(String).join(" ")); }; try { expect(requireApiKey()).toBe("sk-secret-value"); } finally { console.error = originalError; } const stderr = messages.join("\n"); expect(stderr).toContain("Using OpenAI key from OPENAI_API_KEY environment variable."); expect(stderr).not.toContain("sk-secret-value"); }); });