feat: finish donor-led custom endpoint integration
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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("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);
|
||||
});
|
||||
Reference in New Issue
Block a user