Files
learnharness/tests/custom-provider-boundaries.test.ts
T

218 lines
16 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, beforeEach, test, mock } from "node:test";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-boundaries-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" };
const route = await import("../app/api/custom-provider/route.ts");
const keys = await import("../src/server/keys.ts");
const { authEnvRecord } = await import("../src/core/auth-env.ts");
const { CUSTOM_PROVIDER } = await import("../src/core/auth-keys.ts");
const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts");
const { readCustomAuth } = await import("../src/store/auth.ts");
const { persistCustomAuth } = await import("../src/server/custom-provider-auth.ts");
const settingsFile = path.join(scratch, "settings.json");
const seed = (cp: import("../src/core/settings.ts").CustomProviderSettings = { baseUrl: "http://old/v1", authMode: "keyless", models: [{ id: "m" }] }, key?: string) => fs.writeFileSync(settingsFile, JSON.stringify({ version: 1, customProvider: authorizeFixture(cp, key) }));
const setFileKey = (key: string) => { const auth = readCustomAuth(); persistCustomAuth({ ...auth.binding!, fileKeyBaseUrl: auth.binding!.baseUrl }, key); };
const current = () => JSON.parse(fs.readFileSync(settingsFile, "utf8"));
const get = (inspect = false) => route.GET(new Request(`http://localhost/api/custom-provider${inspect ? "?authMode=apiKey" : ""}`));
function mutate(body: unknown, method = "POST", headers: Record<string, string> = {}) {
return route.POST(new Request("http://localhost/api/custom-provider", { method, headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get().headers.get("etag")!, ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }));
}
let requests: { url: string; authorization: string | null }[];
beforeEach(() => {
mock.restoreAll();
process.env.LH_IGNORE_ENV_KEYS = "1";
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); seed();
requests = [];
globalThis.fetch = async (url, options) => {
requests.push({ url: String(url), authorization: new Headers(options?.headers).get("authorization") });
assert.equal(options?.redirect, "manual");
assert.ok(options?.signal instanceof AbortSignal);
return Response.json({ data: [{ id: "m" }] });
};
});
after(() => { mock.restoreAll(); process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); });
test("keyless probe never forwards a stored key, and clearing removes authentication", async () => {
setFileKey("private-old");
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "keyless", key: "do-not-use-this" })).status, 200);
assert.equal(requests[0]?.authorization, null);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), undefined);
assert.equal(current().customProvider.authMode, "keyless");
});
test("new keys have no arbitrary minimum; the exact probed key is stored", async () => {
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: " k " })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer k");
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k");
const body = await get().text(); assert.ok(!body.includes("Bearer"));
});
test("actual GET /api/keys fully redacts every custom key length and preserves other providers", async () => {
const inventory = await import("../app/api/keys/route.ts");
const rows = async () => (await (await inventory.GET()).json()).keys as import("../src/core/auth-keys.ts").AuthKeyStatus[];
assert.equal(keys.saveKey("OPENAI_API_KEY", "ordinary-provider-key-abcd").ok, true);
const ordinary = (await rows()).find(row => row.id === "OPENAI_API_KEY")!;
assert.equal(ordinary.masked, "…abcd (26 chars)");
try {
for (const value of ["1", "12", "123", "1234", "synthetic-custom-secret-longer-than-four"]) {
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: value })).status, 200);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), value, "short keys still save unchanged");
const statuses = await rows();
const custom = statuses.find(row => row.id === CUSTOM_PROVIDER.envVar)!;
assert.equal(custom.masked, "••••"); assert.equal(custom.present, true); assert.equal(custom.source, "file");
assert.ok(!JSON.stringify(custom).includes(value));
assert.deepEqual(statuses.find(row => row.id === "OPENAI_API_KEY"), ordinary);
}
process.env.LH_IGNORE_ENV_KEYS = "0";
process.env[CUSTOM_PROVIDER.envVar] = "765"; authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar);
const custom = (await rows()).find(row => row.id === CUSTOM_PROVIDER.envVar)!;
assert.equal(custom.masked, "••••"); assert.equal(custom.source, "env"); assert.equal(custom.present, true); assert.equal(custom.shadowedByEnv, true);
assert.ok(!JSON.stringify(custom).includes("765"));
} finally {
authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; process.env.LH_IGNORE_ENV_KEYS = "1";
keys.deleteKey("OPENAI_API_KEY");
}
});
test("changing destination cannot silently reuse an existing credential", async () => {
setFileKey("private-old");
const payload = { baseUrl: "http://new/v1", authMode: "apiKey" };
assert.equal((await mutate(payload)).status, 400); assert.equal(requests.length, 0);
assert.equal((await mutate({ ...payload, reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer private-old");
});
test("same-URL unbound development key needs existing explicit reuse consent", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "legacy-key");
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
const file = path.join(scratch, "auth.json"); const map = JSON.parse(fs.readFileSync(file, "utf8")); delete map[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(map));
assert.equal((await get().json()).keyReuseRequired, true);
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 400); assert.equal(requests.length, 0);
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey", reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer legacy-key"); assert.equal((await get().json()).keyReuseRequired, false);
});
test("generic custom/reserved key writes cannot bypass the atomic binding seam", async () => {
const ordinary = await import("../app/api/keys/[id]/route.ts");
const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts");
const { authStore } = await import("../src/store/auth.ts");
const before = fs.readFileSync(path.join(scratch, "auth.json"), "utf8");
for (const id of [CUSTOM_PROVIDER.envVar, CUSTOM_AUTH_BINDING]) {
const context = { params: Promise.resolve({ id }) };
assert.equal((await ordinary.PUT(new Request("http://localhost", { method: "PUT", body: JSON.stringify({ value: "bypass" }) }), context)).status, 404);
assert.equal((await ordinary.DELETE(new Request("http://localhost", { method: "DELETE" }), context)).status, 404);
assert.equal(keys.saveKey(id, "bypass").ok, false); assert.throws(() => keys.deleteKey(id));
assert.throws(() => authStore.setAuthVar(id, "bypass")); assert.throws(() => authStore.deleteAuthVar(id));
}
assert.equal(fs.readFileSync(path.join(scratch, "auth.json"), "utf8"), before);
assert.ok(!keys.keyStatuses().some(row => row.id === CUSTOM_AUTH_BINDING)); assert.equal(process.env[CUSTOM_AUTH_BINDING], undefined);
});
test("same target can retain its effective key", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "retained");
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer retained");
});
test("environment ownership is shown; consent uses the environment key in probe and inference", async () => {
process.env.LH_IGNORE_ENV_KEYS = "0";
process.env[CUSTOM_PROVIDER.envVar] = "environment-secret";
authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar);
try {
assert.equal((await get(true).json()).keySource, "env");
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: "shadowed" })).status, 400);
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", reuseKey: true })).status, 200);
assert.equal(requests[0]?.authorization, "Bearer environment-secret");
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "environment-secret");
assert.ok(!(await get().text()).includes("environment-secret"));
assert.equal((await mutate({ baseUrl: "http://other/v1", authMode: "keyless" })).status, 200);
assert.equal(requests.at(-1)?.authorization, null);
} finally { authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; }
});
test("cross-origin/opaque/missing-origin and non-JSON writes cannot probe or mutate", async () => {
for (const origin of ["http://evil", "null", ""]) assert.equal((await mutate({ baseUrl: "http://new", authMode: "keyless" }, "POST", { origin })).status, 403);
assert.equal((await mutate({}, "POST", { "content-type": "text/plain" })).status, 415);
assert.equal((await mutate({}, "POST", { "sec-fetch-site": "cross-site" })).status, 403);
assert.equal(requests.length, 0);
});
test("standalone internal URL uses the addressed Host origin, never forwarded origins", async () => {
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, host: "127.0.0.1:3317", origin: "http://127.0.0.1:3317" };
assert.equal((await route.DELETE(new Request("http://localhost:3317/api/custom-provider", { method: "DELETE", headers }))).status, 200);
assert.equal((await mutate({}, "DELETE", { "x-forwarded-host": "evil", origin: "http://evil" })).status, 403);
});
test("empty DELETE streams normalize consistently without accepting malformed JSON", async () => {
const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, origin: "http://localhost" };
const init: RequestInit & { duplex: "half" } = { method: "DELETE", headers, body: new ReadableStream({ start(controller) { controller.close(); } }), duplex: "half" };
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", init))).status, 200);
assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", { method: "DELETE", headers: { ...headers, "if-match": get().headers.get("etag")! }, body: "{" }))).status, 400);
assert.equal((await route.POST(new Request("http://localhost/api/custom-provider", { method: "POST", headers, body: "" }))).status, 400);
});
test("desktop capability remains mandatory in desktop mode", async () => {
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "x".repeat(64);
assert.equal((await mutate({}, "DELETE")).status, 403);
assert.equal((await mutate(undefined, "DELETE", { origin: "null", "x-learnharness-desktop": "x".repeat(64) })).status, 200);
});
test("forbidden URL components are rejected before probing", async () => {
for (const baseUrl of ["ftp://h", "https://user:pass@h/v1", "http://h/v1?q=secret", "http://h/v1#x", "http://h/v1?", "http://h/v1#"]) assert.equal((await mutate({ baseUrl, authMode: "keyless" })).status, 400);
assert.equal(requests.length, 0);
});
test("redirects/auth failures/model and byte limits cannot be bypassed with typed IDs", async () => {
for (const answer of [
() => new Response(null, { status: 302, headers: { location: "http://other" } }),
() => new Response("private response", { status: 401 }),
() => new Response("private response", { status: 403 }),
() => Response.json({ data: Array(10001).fill({ id: "m" }) }),
() => new Response("x".repeat(8 * 1024 * 1024 + 1)),
() => new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } }),
]) {
globalThis.fetch = async () => answer();
const before = current();
const res = await mutate({ baseUrl: "http://h/v1", authMode: "keyless", manualModels: "m" });
assert.equal(res.status, 502); assert.deepEqual(current(), before);
assert.ok(!(await res.text()).includes("private response"));
}
});
test("write size bounds are explicit for streamed bodies", async () => {
assert.equal((await mutate({ baseUrl: "x".repeat(65536), authMode: "keyless" })).status, 413);
assert.equal((await mutate({ padding: "x".repeat(65536) }, "DELETE")).status, 413);
assert.equal(requests.length, 0);
});
test("stale save, pin edit and disconnect all return reload guidance without writing", async () => {
const etag = get().headers.get("etag")!;
seed({ baseUrl: "http://other/v1", authMode: "keyless", models: [{ id: "different" }] });
const before = current();
for (const method of ["POST", "PATCH", "DELETE"]) {
const res = await mutate({ baseUrl: "http://h", authMode: "keyless", overrides: {} }, method, { "if-match": etag });
assert.equal(res.status, 409); assert.match(await res.text(), /Reload/);
}
assert.equal(requests.length, 0); assert.deepEqual(current(), before);
});
test("after discovery preserves unrelated edits and rejects intervening custom edits", async () => {
globalThis.fetch = async () => { fs.writeFileSync(settingsFile, JSON.stringify({ ...current(), appearance: { mode: "dark" }, claudeCode: { enabled: true } })); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 200);
assert.deepEqual(current().appearance, { mode: "dark" }); assert.equal(current().claudeCode.enabled, true);
globalThis.fetch = async () => { seed({ baseUrl: "http://changed", authMode: "keyless", models: [] }); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 409);
assert.equal(current().customProvider.baseUrl, "http://changed");
});
test("after discovery rejects a changed effective key", async () => {
seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "before");
globalThis.fetch = async () => { setFileKey("rotated"); return Response.json({ data: [{ id: "m" }] }); };
assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 409);
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "rotated");
});
test("offline pins accept 512/1, retain support declaration, and do not probe", async () => {
const res = await mutate({ overrides: { m: { context: 1, maxTokens: 512, reasoning: false } } }, "PATCH");
assert.equal(res.status, 200); assert.equal(requests.length, 0);
assert.deepEqual(current().customProvider.overrides.m, { context: 1, maxTokens: 512, reasoning: false });
});
test("settings-write failure restores previous key/config and reports failure", async () => {
setFileKey("old-key");
const before = current();
const write = fs.writeFileSync;
mock.method(fs, "writeFileSync", (...args: Parameters<typeof write>) => { if (args[0] === settingsFile) throw new Error("private-io-details"); return write(...args); });
const res = await mutate({ baseUrl: "http://new", authMode: "apiKey", key: "new-key" });
assert.equal(res.status, 500); assert.ok(!(await res.text()).includes("private-io-details"));
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "old-key"); assert.deepEqual(current(), before);
});