import { describe, it, expect, beforeAll, afterAll, afterEach, vi, } from "vitest"; import type { AddressInfo } from "node:net"; import type { Server } from "node:http"; // The /providers routes are admin-gated. These tests exercise the route logic // itself, not the Clerk allowlist, so we stub the auth middleware to grant // admin access. Auth enforcement is covered separately. vi.mock("../middlewares/auth", () => ({ getAdminAllowlist: () => ["admin@test.local"], resolveAuth: async () => ({ userId: "test-admin", email: "admin@test.local", isAdmin: true, }), requireAdmin: ( _req: unknown, _res: unknown, next: () => void, ) => next(), })); import app from "../app"; import { db, pool, aiProvidersTable } from "@workspace/db"; import { inArray } from "drizzle-orm"; const createdProviderIds: number[] = []; let server: Server; let baseUrl: string; // The test client itself uses fetch to reach the in-process server, so we must // only intercept upstream provider calls and let localhost requests pass through // to the real implementation. const realFetch = globalThis.fetch.bind(globalThis); beforeAll(async () => { await new Promise((resolve) => { server = app.listen(0, () => resolve()); }); const { port } = server.address() as AddressInfo; baseUrl = `http://127.0.0.1:${port}`; }); afterEach(async () => { vi.restoreAllMocks(); if (createdProviderIds.length > 0) { await db .delete(aiProvidersTable) .where(inArray(aiProvidersTable.id, createdProviderIds.splice(0))); } }); afterAll(async () => { await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }); await pool.end(); }); type FetchResponseInit = { ok: boolean; status?: number; jsonBody?: unknown; textBody?: string; }; function mockFetch(resp: FetchResponseInit): ReturnType { const fn = vi.fn( async (input: unknown, init?: RequestInit): Promise => { const url = typeof input === "string" ? input : String(input); // Let the test client's own request to the in-process server through. if (url.startsWith(baseUrl)) { return realFetch(input as never, init as never); } return { ok: resp.ok, status: resp.status ?? (resp.ok ? 200 : 500), json: async () => resp.jsonBody ?? {}, text: async () => resp.textBody ?? "", } as unknown as Response; }, ); vi.spyOn(globalThis, "fetch").mockImplementation( fn as unknown as typeof fetch, ); return fn; } // The spy records both the test client's localhost request and the upstream // provider request. Pick the upstream one (anything not aimed at our server). function upstreamCall( fn: ReturnType, ): [string, RequestInit] { const call = fn.mock.calls.find( (c) => !String(c[0]).startsWith(baseUrl), ); if (!call) throw new Error("no upstream fetch call was recorded"); return [String(call[0]), call[1] as RequestInit]; } async function listModels(body: Record): Promise<{ status: number; json: { ok: boolean; models: string[]; message?: string | null }; }> { const res = await fetch(`${baseUrl}/api/providers/list-models`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const json = (await res.json()) as { ok: boolean; models: string[]; message?: string | null; }; return { status: res.status, json }; } async function insertProvider(apiToken: string | null): Promise { const [row] = await db .insert(aiProvidersTable) .values({ name: `list-models-test ${Math.random().toString(36).slice(2)}`, apiType: "openai", baseUrl: "https://api.example.test/v1", model: "", apiToken, enabled: true, }) .returning(); createdProviderIds.push(row.id); return row.id; } describe("POST /api/providers/list-models", () => { it("returns ok=false with a clear message when no token is supplied", async () => { const upstream = mockFetch({ ok: true, jsonBody: { data: [] } }); const { status, json } = await listModels({ apiType: "openai", baseUrl: "https://api.example.test/v1", apiToken: "", }); expect(status).toBe(200); expect(json.ok).toBe(false); expect(json.models).toEqual([]); expect(json.message).toBe("Kein API-Token angegeben."); // It must short-circuit before ever calling the upstream provider. const upstreamCalls = upstream.mock.calls.filter( (c) => !String(c[0]).startsWith(baseUrl), ); expect(upstreamCalls).toHaveLength(0); }); it("falls back to the stored token when providerId is given and apiToken is empty", async () => { const storedToken = "stored-secret-token-1234567890"; const providerId = await insertProvider(storedToken); const upstream = mockFetch({ ok: true, jsonBody: { data: [{ id: "gpt-4o" }] }, }); const { json } = await listModels({ apiType: "openai", baseUrl: "https://api.example.test/v1", apiToken: "", providerId, }); expect(json.ok).toBe(true); expect(json.models).toEqual(["gpt-4o"]); // The stored token was used for the Bearer header. const [, init] = upstreamCall(upstream); const headers = init.headers as Record; expect(headers.Authorization).toBe(`Bearer ${storedToken}`); }); it("normalizes the OpenAI-compatible response into a deduped, sorted list of model IDs", async () => { const upstream = mockFetch({ ok: true, jsonBody: { data: [ { id: "gpt-4o" }, { id: "gpt-3.5-turbo" }, { id: "gpt-4o" }, // duplicate { id: "claude-ignored-by-shape" }, // still a string id { id: 12345 }, // non-string id is dropped ], }, }); const { json } = await listModels({ apiType: "openai", baseUrl: "https://api.example.test/v1", apiToken: "tok-abc", }); expect(json.ok).toBe(true); expect(json.models).toEqual([ "claude-ignored-by-shape", "gpt-3.5-turbo", "gpt-4o", ]); // OpenAI-compatible path: GET /models with a Bearer header. const [url, init] = upstreamCall(upstream); expect(url).toBe("https://api.example.test/v1/models"); expect(init.method).toBe("GET"); const headers = init.headers as Record; expect(headers.Authorization).toBe("Bearer tok-abc"); expect(headers["x-api-key"]).toBeUndefined(); }); it("uses Anthropic headers (x-api-key + anthropic-version) and reads the models array", async () => { const upstream = mockFetch({ ok: true, jsonBody: { models: [ { id: "claude-3-5-sonnet" }, { name: "claude-3-haiku" }, // name fallback when id is absent { id: "claude-3-5-sonnet" }, // duplicate ], }, }); const { json } = await listModels({ apiType: "anthropic", baseUrl: "https://api.anthropic.test/v1", apiToken: "anthropic-key-xyz", }); expect(json.ok).toBe(true); expect(json.models).toEqual(["claude-3-5-sonnet", "claude-3-haiku"]); const [url, init] = upstreamCall(upstream); expect(url).toBe("https://api.anthropic.test/v1/models"); expect(init.method).toBe("GET"); const headers = init.headers as Record; expect(headers["x-api-key"]).toBe("anthropic-key-xyz"); expect(headers["anthropic-version"]).toBe("2023-06-01"); expect(headers.Authorization).toBeUndefined(); }); it("returns ok=false (not a 500) and never leaks the token when the upstream call fails", async () => { const token = "leaky-secret-token-abcdef123456"; mockFetch({ ok: false, status: 401, // The upstream error body echoes the token back to us. textBody: `{"error":"invalid api key ${token}, Bearer ${token}"}`, }); const { status, json } = await listModels({ apiType: "openai", baseUrl: "https://api.example.test/v1", apiToken: token, }); // Errors are surfaced gracefully, not as a 500. expect(status).toBe(200); expect(json.ok).toBe(false); expect(json.models).toEqual([]); expect(typeof json.message).toBe("string"); // The token must be redacted from the error message. expect(json.message).not.toContain(token); expect(json.message).toContain("[REDACTED]"); expect(json.message).toContain("HTTP 401"); }); it("returns ok=false without leaking the token when fetch itself throws", async () => { const token = "throwy-secret-token-zzz999"; vi.spyOn(globalThis, "fetch").mockImplementation((async ( input: unknown, init?: RequestInit, ) => { const url = typeof input === "string" ? input : String(input); if (url.startsWith(baseUrl)) { return realFetch(input as never, init as never); } throw new Error("network down"); }) as unknown as typeof fetch); const { status, json } = await listModels({ apiType: "openai", baseUrl: "https://api.example.test/v1", apiToken: token, }); expect(status).toBe(200); expect(json.ok).toBe(false); expect(json.models).toEqual([]); expect(json.message).not.toContain(token); }); });