Files
learnharness/tests/server-keys.test.ts
T
amosblomqvist cbaf6df454 feat(keys): first-class BYOK key management — store, catalog, /api/keys
The auth/keys wave (2026-09-19): the server now owns the flat auth.json
map directly (LH_AUTH_FILE from main.cjs; repo-root auth.json in dev,
gitignored). The store reflects itself into the child's env at boot with
fill-gap precedence — real env (incl. .env.local in dev) always wins —
so every consumer keeps its plain process.env reads and saved keys take
effect immediately, no restart; main.cjs's spawn-time injection retired
(an env copy would shadow every settings-page change).

Curated 4-key catalog (OpenRouter, Deepgram, Google search pair) with
shape-only validation (no live pings — owner decision), 400s on bad
pastes, last-4+length masking (plaintext never leaves the store),
unknown file vars preserved + surfaced. Routes: GET /api/keys,
PUT/DELETE /api/keys/:id. Tests: catalog/store/composition, with the
env-sanitizing prologue (a dev shell may export real keys; tests use
throwaway values only).
2026-09-19 20:54:16 +03:00

163 lines
6.6 KiB
TypeScript

/**
* The keys composition (auth-keys wave, 2026-09-19): env-reflecting store +
* env-wins precedence + the masked status view. Owner decisions pinned:
* - real env always wins; a PUT to an env-owned var persists to the file
* but never overwrites the env copy (status reports shadowedByEnv);
* - saves/deletes take effect immediately (file + process.env together);
* - unknown file vars are preserved and surfaced, never editable.
*
* LH_AUTH_FILE redirects to a fresh tmp dir BEFORE the imports (the seam is
* module-load bound); the \"real env\" var is set before src/server/keys.ts
* first loads so the boot snapshot treats it as real environment. All
* values here are throwaway test strings — never real secrets.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars
// (a developer shell may export REAL keys; tests use throwaway values only,
// never real secrets). The strip affects only this disposable child process.
for (const varName of [
"OPENROUTER_API_KEY",
"DEEPGRAM_API_KEY",
"GOOGLE_SEARCH_API_KEY",
"GOOGLE_CSE_ID",
"FUTURE_PLANS_TOKEN",
]) {
delete process.env[varName];
}
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-server-keys-"));
const AUTH = path.join(TMP, "auth.json");
process.env.LH_AUTH_FILE = AUTH;
// The file BEFORE first boot: a shadowed OpenRouter (env will differ) + an
// unknown var that must survive every write and surface as a generic row.
fs.writeFileSync(
AUTH,
JSON.stringify({
OPENROUTER_API_KEY: "sk-or-v1-filefilefile",
FUTURE_PLANS_TOKEN: "opaque-plans-token",
}),
);
// Real env (set BEFORE the module's first import → boot snapshots it):
process.env.OPENROUTER_API_KEY = "sk-or-v1-envvalue99";
// Import AFTER env setup — module init runs the boot fill-gap + snapshot.
const { deleteKey, initAuthEnv, keyStatuses, keyValue, resolveKeyStatuses, saveKey } = await import(
"../src/server/keys.ts"
);
const { authStore } = await import("../src/store/auth.ts");
const ENV_OWNED = "sk-or-v1-envvalue99";
after(() => {
delete process.env.OPENROUTER_API_KEY;
delete process.env.DEEPGRAM_API_KEY;
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("boot fill-gap + precedence", () => {
it("env-owned var: env wins, shadow reported, file copy kept", () => {
const row = keyStatuses().find((s) => s.id === "OPENROUTER_API_KEY")!;
assert.equal(row.source, "env");
assert.ok(row.shadowedByEnv, "file value differs from the env value");
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched");
});
it("unknown file var: fill-gapped into env, surfaced as a generic row", () => {
assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot");
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
assert.equal(row.info, null, "no curated form");
assert.equal(row.source, "file");
assert.ok(row.present);
});
it("absent curated keys: not present, source none", () => {
const row = keyStatuses().find((s) => s.id === "DEEPGRAM_API_KEY")!;
assert.ok(!row.present);
assert.equal(row.source, "none");
assert.equal(row.masked, null);
assert.ok(row.info, "catalog metadata still present for the page form");
});
it("keyValue resolves env-first", () => {
assert.equal(keyValue("OPENROUTER_API_KEY"), ENV_OWNED);
assert.equal(keyValue("FUTURE_PLANS_TOKEN"), "opaque-plans-token");
assert.equal(keyValue("DEEPGRAM_API_KEY"), undefined);
});
});
describe("saveKey / deleteKey (immediate effect, no restart)", () => {
it("save reflects into env + file (fill-gap set)", () => {
const value = "d".repeat(40); // throwaway, ≥ Deepgram floor
const r = saveKey("DEEPGRAM_API_KEY", value);
assert.ok(r.ok);
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated");
assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
assert.ok(!r.status.shadowedByEnv);
});
it("save to an env-owned var: file updated, env NOT overwritten, shadow reported", () => {
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
assert.ok(r.ok);
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
assert.ok(r.status.shadowedByEnv);
});
it("bad shape: rejected, nothing stored or reflected", () => {
const before = JSON.stringify(authStore.authVars());
const r = saveKey("DEEPGRAM_API_KEY", "short");
assert.ok(!r.ok);
if (!r.ok) assert.match(r.error, /at least 20/);
assert.equal(JSON.stringify(authStore.authVars()), before);
assert.equal(process.env.DEEPGRAM_API_KEY, "d".repeat(40));
});
it("unknown id: rejected with the supported list", () => {
const r = saveKey("FUTURE_PLANS_TOKEN", "anything-at-all-here");
assert.ok(!r.ok);
if (!r.ok) assert.match(r.error, /FUTURE_PLANS_TOKEN/);
});
it("delete removes env + file for fill-gap vars", () => {
const row = deleteKey("DEEPGRAM_API_KEY");
assert.ok(!row.present);
assert.equal(process.env.DEEPGRAM_API_KEY, undefined, "env copy was ours — removed");
assert.ok(!("DEEPGRAM_API_KEY" in authStore.authVars()));
});
it("delete of an env-owned var: file copy gone, env remains (env wins)", () => {
const row = deleteKey("OPENROUTER_API_KEY");
assert.equal(row.source, "env");
assert.ok(row.present, "still live from the real env");
assert.ok(!row.shadowedByEnv, "nothing left in the file to shadow");
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED);
});
it("re-running initAuthEnv is idempotent — deleted vars never resurrect", () => {
initAuthEnv();
assert.equal(process.env.DEEPGRAM_API_KEY, undefined, "file entry gone → no re-fill");
});
});
describe("resolveKeyStatuses (pure projection)", () => {
it("same value in env and file is NOT shadowed (redundant, not misleading)", () => {
const row = resolveKeyStatuses(
{ GOOGLE_SEARCH_API_KEY: "AIzaSame1234" },
{ GOOGLE_SEARCH_API_KEY: "AIzaSame1234" },
new Set(["GOOGLE_SEARCH_API_KEY"]),
).find((s) => s.id === "GOOGLE_SEARCH_API_KEY")!;
assert.ok(row, "the curated id is always in the projection");
assert.equal(row.source, "env");
assert.ok(!row.shadowedByEnv, "same value both places: redundant, not shadowed");
});
});