Files
learnharness/tests/server-keys.test.ts
T
amosblomqvist f1373d5de5 feat(search): Tavily + Brave behind a search-provider port; Google CSE retires
Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
  pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
  against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
  auth is the Bearer header, not the legacy body api_key; Brave is
  X-Subscription-Token) + engine resolution per owner decisions: a key
  ALONE activates its engine (no engine choice needed), explicit
  agent.search.engine pins the preference (both keys: Tavily first),
  a pinned engine with a missing key is honestly unavailable — no
  silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
  (CSE closed to new customers, dies Jan 2027 — stored values become
  preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
  (length guard) join. UI keys-section TOOL_IDS now derives from the
  catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
  the settings gray-out; researcher-disable semantics ride the same
  signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
  web.ts's WebSearchResult is now the core SearchHit (re-exported).
2026-09-22 00:02:07 +03:00

210 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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",
"TAVILY_API_KEY",
"BRAVE_API_KEY",
"GOOGLE_SEARCH_API_KEY",
"GOOGLE_CSE_ID",
"OPENAI_API_KEY",
"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, isWritableAuthKey } = 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;
delete process.env.OPENAI_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("model-provider keys (provider-keys interlude)", () => {
it("absent provider keys do NOT spam status rows — the dropdown is discovery", () => {
const rows = keyStatuses().map((s) => s.id);
assert.ok(!rows.includes("OPENAI_API_KEY"), "never saved → no row");
assert.ok(!rows.includes("ZAI_API_KEY"));
});
it("save via provider env var: generic guard, reflected immediately", () => {
const value = "sk-throwaway-openai-0123456789"; // never a real secret
const r = saveKey("OPENAI_API_KEY", value);
assert.ok(r.ok);
assert.equal(process.env.OPENAI_API_KEY, value, "env updated immediately");
assert.equal(authStore.authVars().OPENAI_API_KEY, value, "file updated");
const row = keyStatuses().find((s) => s.id === "OPENAI_API_KEY")!;
assert.ok(row.present);
assert.equal(row.source, "file", "fill-gap reflection — origin is the store");
});
it("provider truncations/whitespace are 400s (paste hygiene only)", () => {
assert.ok(!saveKey("OPENAI_API_KEY", "short").ok);
assert.ok(!saveKey("OPENAI_API_KEY", "has whitespace now").ok);
// no family check anymore — any ≥12-char token passes
assert.ok(saveKey("OPENROUTER_API_KEY", "not-sk-or-but-12chars").ok);
});
it("isWritableAuthKey: curated ∪ provider env vars; unknown stays unwritable", () => {
assert.ok(isWritableAuthKey("DEEPGRAM_API_KEY"));
assert.ok(isWritableAuthKey("OPENROUTER_API_KEY"));
assert.ok(isWritableAuthKey("OPENAI_API_KEY"));
assert.ok(!isWritableAuthKey("FUTURE_PLANS_TOKEN"));
assert.ok(!isWritableAuthKey("ANTHROPIC_AUTH_TOKEN"), "anthropic alternates stay env-only");
});
it("delete of a provider key: env + file both cleared", () => {
deleteKey("OPENAI_API_KEY");
assert.equal(process.env.OPENAI_API_KEY, undefined);
assert.ok(!("OPENAI_API_KEY" in authStore.authVars()));
// cleanup the openrouter save from the guard test too
deleteKey("OPENROUTER_API_KEY");
assert.ok(!("OPENROUTER_API_KEY" in authStore.authVars()));
});
});
});
describe("resolveKeyStatuses (pure projection)", () => {
it("same value in env and file is NOT shadowed (redundant, not misleading)", () => {
const row = resolveKeyStatuses(
{ BRAVE_API_KEY: "BSA-same-value-throwaway-01234" },
{ BRAVE_API_KEY: "BSA-same-value-throwaway-01234" },
new Set(["BRAVE_API_KEY"]),
).find((s) => s.id === "BRAVE_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");
});
});