Files
learnharness/tests/auth-store.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

81 lines
3.4 KiB
TypeScript

/**
* The AuthStore fs adapter (auth-keys wave, 2026-09-19) — flat {VAR: value}
* map at config.AUTH_FILE. THIS FILE is the sole owner of its auth file:
* LH_AUTH_FILE is redirected to a fresh tmp dir BEFORE the first config
* import (the seam is module-load-bound — data-dir-seam precedent), so no
* hand-edited dev/desktop auth.json is ever touched.
*
* Pinning: absent/corrupt = {} ("no keys yet"); set/delete round-trip with
* unknown vars preserved verbatim; the file is ALWAYS mode 0600 (secrets);
* writes are atomic (tmp+rename — no partial JSON state).
*/
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, before, describe, it } from "node:test";
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-auth-store-"));
process.env.LH_AUTH_FILE = path.join(TMP, "auth.json");
// Import AFTER the env redirect — module-load-bound seam.
const { authStore } = await import("../src/store/auth.ts");
const { AUTH_FILE } = await import("../config.ts");
after(() => {
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("AuthStore adapter", () => {
it("absent file = {} (the documented first-boot state), never throws", () => {
assert.deepEqual(authStore.authVars(), {});
});
it("set → read round-trip; trim is the CALLER's (server's) job, verbatim here", () => {
authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-abc");
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-abc");
});
it("the file is mode 0600 (secrets), also after rewrites", () => {
authStore.setAuthVar("DEEPGRAM_API_KEY", "d".repeat(40));
const first = fs.statSync(AUTH_FILE).mode & 0o777;
assert.equal(first, 0o600);
authStore.setAuthVar("DEEPGRAM_API_KEY", "e".repeat(40));
assert.equal(fs.statSync(AUTH_FILE).mode & 0o777, 0o600, "rewrite keeps 0600");
});
it("set preserves unknown vars verbatim (forward-compat)", () => {
fs.writeFileSync(AUTH_FILE, JSON.stringify({ FUTURE_PLANS_TOKEN: "opaque-xyz", KEEP: "me" }));
authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-new");
const vars = authStore.authVars();
assert.equal(vars.FUTURE_PLANS_TOKEN, "opaque-xyz");
assert.equal(vars.KEEP, "me");
assert.equal(vars.OPENROUTER_API_KEY, "sk-or-v1-new");
});
it("corrupt/non-object file = {} — never throws, looks like first boot", () => {
fs.writeFileSync(AUTH_FILE, "{not json");
assert.deepEqual(authStore.authVars(), {});
fs.writeFileSync(AUTH_FILE, JSON.stringify(["an", "array"]));
assert.deepEqual(authStore.authVars(), {});
fs.writeFileSync(AUTH_FILE, JSON.stringify({ OK: "fine", bad: 42 }));
assert.deepEqual(authStore.authVars(), { OK: "fine" }, "non-string values drop");
});
it("delete removes just that var; absent delete = silent no-op", () => {
fs.writeFileSync(AUTH_FILE, JSON.stringify({ A: "1", B: "2" }));
authStore.deleteAuthVar("A");
assert.deepEqual(authStore.authVars(), { B: "2" });
authStore.deleteAuthVar("NOT_THERE");
assert.deepEqual(authStore.authVars(), { B: "2" });
});
it("atomic writes: no tmp leftovers, valid JSON on disk every time", () => {
authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-final");
const files = fs.readdirSync(TMP).filter((f) => f.startsWith(".auth.json.tmp"));
assert.deepEqual(files, [], "no tmp file survives the rename");
JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); // must parse
});
});