/** * KHAELOR * File: tests/config/config.test.ts * Description: Config tests — schema validation, precedence merge, and API-key redaction. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { inspect } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_CONFIG, validatePartialConfig } from "../../src/config/schema.js"; import { loadConfig, REDACTED, ResolvedConfig } from "../../src/config/loader.js"; const SECRET = "sk-ant-super-secret-key-000"; describe("validatePartialConfig", () => { it("accepts a valid partial config", () => { const r = validatePartialConfig( { model: "claude-x", thinking: "off", maxOutputTokens: 4096, permissions: { "file.read": "allow", "process.execute": "ask" }, }, "test", ); expect(r.ok).toBe(true); if (r.ok) { expect(r.value.model).toBe("claude-x"); expect(r.value.permissions).toEqual({ "file.read": "allow", "process.execute": "ask" }); } }); it("accepts nested and explicit-rules permission forms (PERMISSION_MODEL.md §4.1)", () => { const r = validatePartialConfig( { permissions: { "file.read": "allow", "process.execute": { "git status": "allow", "git push *": "allow", "*": "ask" }, rules: [ { capability: "file.write.outsideProject", pattern: "/tmp/*", action: "allow" }, { capability: "network.access", action: "deny" }, ], }, }, "test", ); expect(r.ok).toBe(true); if (r.ok) { expect(r.value.permissions).toEqual({ "file.read": "allow", "process.execute": { "git status": "allow", "git push *": "allow", "*": "ask" }, rules: [ { capability: "file.write.outsideProject", pattern: "/tmp/*", action: "allow" }, { capability: "network.access", action: "deny" }, ], }); } }); it("rejects malformed nested and rules permission forms", () => { expect(validatePartialConfig({ permissions: { "x.y": { p: "maybe" } } }, "t").ok).toBe(false); expect(validatePartialConfig({ permissions: { rules: "nope" } }, "t").ok).toBe(false); expect(validatePartialConfig({ permissions: { rules: [null] } }, "t").ok).toBe(false); expect( validatePartialConfig({ permissions: { rules: [{ capability: "", action: "allow" }] } }, "t") .ok, ).toBe(false); expect( validatePartialConfig({ permissions: { rules: [{ capability: "x", action: "maybe" }] } }, "t") .ok, ).toBe(false); expect( validatePartialConfig( { permissions: { rules: [{ capability: "x", pattern: 1, action: "allow" }] } }, "t", ).ok, ).toBe(false); }); it("rejects non-object roots and bad field types", () => { expect(validatePartialConfig("nope", "t").ok).toBe(false); expect(validatePartialConfig(null, "t").ok).toBe(false); expect(validatePartialConfig([], "t").ok).toBe(false); expect(validatePartialConfig({ model: 42 }, "t").ok).toBe(false); expect(validatePartialConfig({ model: "" }, "t").ok).toBe(false); expect(validatePartialConfig({ thinking: "sometimes" }, "t").ok).toBe(false); expect(validatePartialConfig({ maxOutputTokens: -1 }, "t").ok).toBe(false); expect(validatePartialConfig({ maxOutputTokens: 1.5 }, "t").ok).toBe(false); expect(validatePartialConfig({ permissions: { x: "maybe" } }, "t").ok).toBe(false); expect(validatePartialConfig({ permissions: [] }, "t").ok).toBe(false); }); it("ignores unknown fields (forward compatibility)", () => { const r = validatePartialConfig({ futureFeature: true, model: "m" }, "t"); expect(r.ok).toBe(true); if (r.ok) expect("futureFeature" in r.value).toBe(false); }); it("rejects secret fields in config files without echoing the value", () => { const r = validatePartialConfig({ apiKey: SECRET }, "t"); expect(r.ok).toBe(false); if (!r.ok) { expect(r.error.message).toContain("ANTHROPIC_API_KEY"); expect(r.error.message).not.toContain(SECRET); expect(JSON.stringify(r.error.details)).not.toContain(SECRET); } }); }); describe("loadConfig precedence (flags > project > user > env > defaults)", () => { let root: string; let userDir: string; let projectDir: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "khaelor-config-")); userDir = join(root, "user-home", ".khaelor"); projectDir = join(root, "project"); await mkdir(userDir, { recursive: true }); await mkdir(join(projectDir, ".khaelor"), { recursive: true }); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); const writeUser = (config: unknown) => writeFile(join(userDir, "config.json"), JSON.stringify(config)); const writeProject = (config: unknown) => writeFile(join(projectDir, ".khaelor", "config.json"), JSON.stringify(config)); const load = (flags?: { model?: string; auxModel?: string; thinking?: string; maxOutputTokens?: number }, env: Record = {}) => loadConfig( flags ? { flags, cwd: projectDir, env, userConfigDir: userDir } : { cwd: projectDir, env, userConfigDir: userDir }, ); it("returns defaults when nothing is configured", async () => { const config = await load(); expect(config.model).toBe(DEFAULT_CONFIG.model); expect(config.auxModel).toBe(DEFAULT_CONFIG.auxModel); expect(config.thinking).toBe(DEFAULT_CONFIG.thinking); expect(config.maxOutputTokens).toBe(DEFAULT_CONFIG.maxOutputTokens); expect(config.apiKey).toBeNull(); expect(config.hasApiKey).toBe(false); }); it("user config overrides defaults", async () => { await writeUser({ model: "user-model", maxOutputTokens: 8000 }); const config = await load(); expect(config.model).toBe("user-model"); expect(config.maxOutputTokens).toBe(8000); expect(config.thinking).toBe(DEFAULT_CONFIG.thinking); // untouched }); it("project config overrides user config", async () => { await writeUser({ model: "user-model", thinking: "off" }); await writeProject({ model: "project-model" }); const config = await load(); expect(config.model).toBe("project-model"); expect(config.thinking).toBe("off"); // user tier survives where project is silent }); it("CLI flags override everything", async () => { await writeUser({ model: "user-model" }); await writeProject({ model: "project-model", maxOutputTokens: 2000 }); const config = await load({ model: "flag-model" }); expect(config.model).toBe("flag-model"); expect(config.maxOutputTokens).toBe(2000); // project tier survives where flags are silent expect(config.sources[0]).toBe("cli-flags"); }); it("permission maps merge per capability across tiers", async () => { await writeUser({ permissions: { "file.read": "allow", "process.execute": "ask" } }); await writeProject({ permissions: { "process.execute": "allow" } }); const config = await load(); expect(config.permissions).toEqual({ "file.read": "allow", "process.execute": "allow" }); }); it("concatenates explicit rules arrays across tiers (user first, project last)", async () => { await writeUser({ permissions: { rules: [{ capability: "network.access", pattern: "curl *", action: "allow" }] }, }); await writeProject({ permissions: { rules: [{ capability: "process.execute", pattern: "npm *", action: "allow" }] }, }); const config = await load(); expect(config.permissions["rules"]).toEqual([ { capability: "network.access", pattern: "curl *", action: "allow" }, { capability: "process.execute", pattern: "npm *", action: "allow" }, ]); }); it("reads the API key from ANTHROPIC_API_KEY only", async () => { const config = await load(undefined, { ANTHROPIC_API_KEY: SECRET }); expect(config.apiKey).toBe(SECRET); expect(config.hasApiKey).toBe(true); }); it("rejects invalid JSON and invalid flag values with typed errors", async () => { await writeProject("{ not json"); await expect(load()).rejects.toMatchObject({ code: "config-invalid" }); await rm(join(projectDir, ".khaelor", "config.json")); await expect(load({ thinking: "banana" })).rejects.toMatchObject({ code: "config-invalid" }); }); it("rejects config files that try to carry an API key", async () => { await writeProject({ apiKey: SECRET }); await expect(load()).rejects.toMatchObject({ code: "config-invalid" }); }); }); describe("ResolvedConfig redaction", () => { const make = () => new ResolvedConfig( { ...DEFAULT_CONFIG, permissions: { ...DEFAULT_CONFIG.permissions } }, SECRET, ["cli-flags"], ); it("never exposes the key via JSON.stringify, toString, or util.inspect", () => { const config = make(); expect(JSON.stringify(config)).not.toContain(SECRET); expect(JSON.stringify(config)).toContain(REDACTED); expect(String(config)).not.toContain(SECRET); expect(`${config}`).not.toContain(SECRET); expect(inspect(config)).not.toContain(SECRET); expect(inspect(config, { showHidden: true, depth: null })).not.toContain(SECRET); }); it("does not expose the key as an enumerable property", () => { const config = make(); expect(Object.keys(config)).not.toContain("apiKey"); expect(JSON.parse(JSON.stringify(config)).apiKey).toBe(REDACTED); expect(Object.entries(config).some(([, v]) => v === SECRET)).toBe(false); }); it("still provides the key programmatically for the Anthropic client", () => { const config = make(); expect(config.apiKey).toBe(SECRET); }); it("shows apiKey: null when absent", () => { const config = new ResolvedConfig( { ...DEFAULT_CONFIG, permissions: {} }, null, [], ); expect(config.toJSON()["apiKey"]).toBeNull(); expect(config.hasApiKey).toBe(false); }); });