Files
gstack/design/test/auth.test.ts
Garry Tan 16fca84d04 fix(design): disclose OpenAI key source + warn on cwd .env match (#1278, closes #1248)
The design binary previously called process.env.OPENAI_API_KEY without
checking where the key came from. If a user ran $D inside someone
else's project that had OPENAI_API_KEY in its .env, the resulting
generation billed that project's account. Silent and irreversible.

Fix: resolveApiKeyInfo() returns both the key and its source. When the
env-var path matches an OPENAI_API_KEY entry in the current
directory's .env, .env.<NODE_ENV>, or .env.local file, we set a
warning. requireApiKey() prints "Using OpenAI key from <source>" plus
the warning before the run — never the key itself.

Adds 6 unit tests covering: config-vs-env precedence, env-only (no
match), env+cwd .env match, quoted/exported values, value-mismatch
(no false positive), and the no-leak invariant for requireApiKey
stderr output.

Contributed by @jbetala7 via #1278. Closes #1248.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:22:18 -07:00

134 lines
4.4 KiB
TypeScript

/**
* 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,
requireApiKey,
resolveApiKey,
resolveApiKeyInfo,
saveApiKey,
} from "../src/auth";
let tmpDir: string;
let tmpHome: string;
let originalHome: string | undefined;
let originalKey: 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;
originalNodeEnv = process.env.NODE_ENV;
originalCwd = process.cwd();
process.env.HOME = tmpHome;
delete process.env.OPENAI_API_KEY;
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 (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("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");
});
});