import { afterEach, describe, expect, it, vi } from "vitest"; import { API_ENDPOINTS, fetchSetupModels, normalizeApiUrl, verifySetupCompletion } from "./cli-api-setup.ts"; const key = "sk-setup-fixture-secret"; const base = "https://provider.example/v1"; const reply = { choices: [{ message: { role: "assistant", content: "OK" } }] }; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); const mockFetch = (response: Response) => { const fn = vi.fn().mockResolvedValue(response); vi.stubGlobal("fetch", fn); return fn; }; afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); describe("API setup endpoint validation", () => { it("offers provider API URLs and key pages, not a stale model catalog", () => { expect(API_ENDPOINTS.map(({ id, url }) => [id, url])).toEqual([ ["openai", "https://api.openai.com/v1"], ["openrouter", "https://openrouter.ai/api/v1"], ["groq", "https://api.groq.com/openai/v1"], ["meta", "https://api.meta.ai/v1"], ]); for (const endpoint of API_ENDPOINTS) expect(endpoint.keyUrl).toMatch(/^https:\/\//u); }); it("normalizes HTTPS prefixes and permits only loopback HTTP", () => { expect(normalizeApiUrl(" https://PROVIDER.example:443/v1/// ")).toBe(base); expect(normalizeApiUrl("https://provider.example")).toBe("https://provider.example"); for (const host of ["localhost", "127.0.0.1", "[::1]"]) expect(normalizeApiUrl(`http://${host}:1234/v1/`)).toBe(`http://${host}:1234/v1`); }); it.each([ "not a URL", "file:///tmp/provider", "ftp://provider.example/v1", "http://provider.example/v1", "http://localhost.example/v1", "http://192.168.1.1/v1", "http://0.0.0.0/v1", `https://user:${key}@provider.example/v1`, "https://@provider.example/v1", `https://provider.example/v1?key=${key}`, "https://provider.example/v1?", "https://provider.example/v1#", "https://provider.example/v1#fragment", "https://provider.example\\v1", "https://provi\nder.example/v1", ])("rejects an unsafe URL without echoing it: case %#", (url) => { expect(() => normalizeApiUrl(url)).toThrow(/HTTPS API base URL/u); try { normalizeApiUrl(url); } catch (error) { expect(String(error)).not.toContain(key); } }); }); describe("API setup models", () => { it("authenticates only the direct /models GET and parses a live catalog", async () => { const fetch = mockFetch(json({ data: [ { id: "provider/model-b", name: "Model B" }, { id: "provider/model-a" }, { id: "provider/model-b", name: "Duplicate" }, null, { id: 5 }, { id: " " }, { id: "bad\u001bmodel" }, ] })); await expect(fetchSetupModels(`${base}/`, key)).resolves.toEqual([ { id: "provider/model-b", label: "Model B" }, { id: "provider/model-a", label: "provider/model-a" }, ]); expect(fetch).toHaveBeenCalledExactlyOnceWith(`${base}/models`, { method: "GET", headers: { authorization: `Bearer ${key}` }, signal: expect.any(AbortSignal), redirect: "error", }); }); it.each([{}, null, [], { models: ["wrong contract"] }, { data: [] }, { data: [{ id: "" }] }])("rejects unusable catalogs without persisting a fallback: case %#", async (body) => { mockFetch(json(body)); await expect(fetchSetupModels(base, key)).rejects.toThrow(/no usable models.*manually/u); }); it("does not expose malformed JSON or credentials", async () => { mockFetch(new Response(`provider echoed ${key}`)); const failure = await fetchSetupModels(base, key).catch((error: Error) => error); expect(failure).toBeInstanceOf(Error); expect(String(failure)).toContain("invalid JSON"); expect(String(failure)).not.toContain(key); }); it("stops after eight seconds and aborts the request, even if fetch never settles", async () => { vi.useFakeTimers(); const fetch = vi.fn(() => new Promise(() => {})); vi.stubGlobal("fetch", fetch); const failure = expect(fetchSetupModels(base, key)).rejects.toThrow(/timed out after 8 seconds/u); await vi.advanceTimersByTimeAsync(8_000); await failure; expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); expect(vi.getTimerCount()).toBe(0); }); it("includes reading the model-list body in the timeout", async () => { vi.useFakeTimers(); const response = json({}); vi.spyOn(response, "json").mockImplementation(() => new Promise(() => {})); mockFetch(response); const failure = expect(fetchSetupModels(base, key)).rejects.toThrow(/timed out after 8 seconds/u); await vi.advanceTimersByTimeAsync(8_000); await failure; }); }); describe("consented API setup completion", () => { it("sends one tiny non-streaming message without model-specific tuning flags", async () => { const fetch = mockFetch(json(reply)); await expect(verifySetupCompletion(base, key, "chosen/model")).resolves.toBeUndefined(); expect(fetch).toHaveBeenCalledOnce(); const [url, init] = fetch.mock.calls[0]; expect(url).toBe(`${base}/chat/completions`); expect(init).toMatchObject({ method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, redirect: "error" }); expect(JSON.parse(String(init?.body))).toEqual({ model: "chosen/model", messages: [{ role: "user", content: "Reply with exactly OK." }], stream: false }); expect(init?.signal?.aborted).toBe(false); }); it.each(["https://openrouter.ai/api/v1", "https://API.openrouter.ai/v1"])("mirrors the selected OpenRouter upstream without allowing fallback: %s", async (url) => { const fetch = mockFetch(json(reply)); await verifySetupCompletion(url, key, "chosen/model", "Selected upstream"); expect(JSON.parse(String(fetch.mock.calls[0][1]?.body))).toMatchObject({ model: "chosen/model", provider: { order: ["Selected upstream"], allow_fallbacks: false }, }); }); it.each([base, "https://api.groq.com/openai/v1", "https://openrouter.ai.other.example/api/v1", "http://localhost:1234/v1"])("does not send OpenRouter routing to other endpoints: %s", async (url) => { const fetch = mockFetch(json(reply)); await verifySetupCompletion(url, key, "chosen/model", "Selected upstream"); expect(JSON.parse(String(fetch.mock.calls[0][1]?.body))).not.toHaveProperty("provider"); }); it.each([undefined, ""])("does not inherit environment routing when the effective instance has none: case %#", async (provider) => { vi.stubEnv("OPENAI_COMPAT_PROVIDER", "Unrelated environment upstream"); const fetch = mockFetch(json(reply)); await verifySetupCompletion("https://openrouter.ai/api/v1", key, "chosen/model", provider); expect(JSON.parse(String(fetch.mock.calls[0][1]?.body))).not.toHaveProperty("provider"); }); it.each([ {}, { choices: [] }, { choices: [{ message: { role: "assistant", content: " " } }] }, { choices: [{ message: { role: "user", content: "OK" } }] }, { choices: [{ message: { role: "assistant", reasoning_content: "thinking" } }] }, { choices: [{ message: { role: "assistant", content: [{ text: "wrong contract" }] } }] }, ])("requires an actual nonempty assistant reply: case %#", async (body) => { mockFetch(json(body)); await expect(verifySetupCompletion(base, key, "model")).rejects.toThrow(/did not return an assistant text reply/u); }); it("aborts after thirty seconds without claiming the billable test succeeded", async () => { vi.useFakeTimers(); const fetch = vi.fn(() => new Promise(() => {})); vi.stubGlobal("fetch", fetch); const failure = expect(verifySetupCompletion(base, key, "model")).rejects.toThrow(/timed out after 30 seconds/u); await vi.advanceTimersByTimeAsync(30_000); await failure; expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); expect(fetch).toHaveBeenCalledOnce(); }); }); describe("safe API setup failures", () => { it.each([ [401, /key.*permissions/u], [403, /key.*permissions/u], [402, /credits.*billing/u], [429, /rate or quota limit/u], [400, /model ID.*Chat Completions/u], [404, /model ID.*Chat Completions/u], [422, /model ID.*Chat Completions/u], [503, /provider is unavailable/u], [302, /Redirects are not followed/u], ])("distinguishes HTTP %i without reading the provider's error body", async (status, expected) => { const response = json({ error: { message: `raw-secret ${key}` } }, status); const text = vi.spyOn(response, "text"); const parse = vi.spyOn(response, "json"); mockFetch(response); const failure = await verifySetupCompletion(base, key, "model").catch((error: Error) => error); expect(String(failure)).toContain(`HTTP ${status}`); expect(String(failure)).toMatch(expected); expect(String(failure)).not.toContain(key); expect(text).not.toHaveBeenCalled(); expect(parse).not.toHaveBeenCalled(); }); it("explains a missing models route instead of suggesting a model change", async () => { mockFetch(json({ error: key }, 404)); await expect(fetchSetupModels(base, key)).rejects.toThrow(/HTTP 404.*\/models endpoint/u); }); it.each(["network", "redirect"]) ("redacts raw %s fetch errors and their causes", async (kind) => { const fetch = vi.fn().mockRejectedValue(new TypeError(`${kind}: ${key}`, { cause: new Error(`https://${key}@provider.example`) })); vi.stubGlobal("fetch", fetch); const failure = await fetchSetupModels(base, key).catch((error: Error) => error); expect(String(failure)).toMatch(/network.*direct API URL.*redirects are not followed/u); expect(String(failure)).not.toContain(key); expect((failure as Error).cause).toBeUndefined(); expect(fetch).toHaveBeenCalledOnce(); }); it("rejects missing or newline-containing inputs before any request", async () => { const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); await expect(fetchSetupModels(base, " ")).rejects.toThrow(/API key/u); await expect(fetchSetupModels(base, `${key}\nInjected: value`)).rejects.toThrow(/API key/u); await expect(verifySetupCompletion(base, key, " ")).rejects.toThrow(/model ID/u); expect(fetch).not.toHaveBeenCalled(); }); it("does not validate a trimmed replacement for an existing runtime credential", async () => { const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); await expect(fetchSetupModels(base, ` ${key} `)).rejects.toThrow(/API key/u); await expect(verifySetupCompletion(base, `${key}\n`, "model")).rejects.toThrow(/API key/u); expect(fetch).not.toHaveBeenCalled(); }); });