diff --git a/tests/integration/search-providers-catalog.test.ts b/tests/integration/search-providers-catalog.test.ts new file mode 100644 index 0000000000..ba62e2f2d3 --- /dev/null +++ b/tests/integration/search-providers-catalog.test.ts @@ -0,0 +1,397 @@ +/** + * Integration tests for GET /api/search/providers — extended catalog (F4). + * + * Tests: + * - Returns 15 items total (12 search + 3 fetch providers). + * - Each item carries the correct `kind` field. + * - Status reflects actual DB credential state: + * - "configured" when an active, non-rate-limited connection exists. + * - "missing" when no connection exists for the provider. + * - "rate_limited" when all connections are rate-limited (rateLimitedUntil in future). + * - Back-compat: `data` field is present and uses legacy shape + * {id, object, created, name, search_types}. + * - Unauthenticated requests receive 401. + * - Error responses do not leak stack traces (Hard Rule #12). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + createManagementSessionHeaders, + TEST_MANAGEMENT_JWT_SECRET, +} from "../helpers/managementSession.ts"; + +// --------------------------------------------------------------------------- +// Isolated temp DB for this test suite +// --------------------------------------------------------------------------- +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-search-providers-catalog-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-search-catalog"; +// Disable dashboard password requirement by default +process.env.INITIAL_PASSWORD = ""; +process.env.DASHBOARD_PASSWORD = ""; +process.env.JWT_SECRET = TEST_MANAGEMENT_JWT_SECRET; + +// --------------------------------------------------------------------------- +// Module imports (after env setup so DB initialises in the right dir) +// --------------------------------------------------------------------------- +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); + +// Import route AFTER env is configured +const route = await import("../../src/app/api/search/providers/route.ts"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EXPECTED_SEARCH_COUNT = 12; +const EXPECTED_FETCH_COUNT = 3; +const EXPECTED_TOTAL = EXPECTED_SEARCH_COUNT + EXPECTED_FETCH_COUNT; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a Request with valid management session cookie. */ +async function buildAuthRequest(url = "http://localhost/api/search/providers"): Promise { + const headers = await createManagementSessionHeaders(); + return new Request(url, { method: "GET", headers }); +} + +/** Build an unauthenticated request. */ +function buildUnauthRequest(url = "http://localhost/api/search/providers"): Request { + return new Request(url, { method: "GET" }); +} + +/** Seed an active provider connection. */ +async function seedActiveConnection(provider: string) { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-test`, + apiKey: `sk-test-${provider}-${Date.now()}`, + isActive: true, + testStatus: "active", + rateLimitedUntil: null, + providerSpecificData: {}, + }); +} + +/** Seed a rate-limited provider connection (rateLimitedUntil in future). */ +async function seedRateLimitedConnection(provider: string) { + const future = new Date(Date.now() + 60_000).toISOString(); + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-ratelimited`, + apiKey: `sk-test-${provider}-rl-${Date.now()}`, + isActive: false, // rate-limited connections are set inactive + testStatus: "unavailable", + rateLimitedUntil: future, + providerSpecificData: {}, + }); +} + +/** Reset DB state between tests. */ +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +// --------------------------------------------------------------------------- +// Lifecycle hooks +// --------------------------------------------------------------------------- + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("search-providers-catalog: returns 401 for unauthenticated requests when auth is required", async () => { + // Enable auth: set a password in DB settings and INITIAL_PASSWORD so isAuthRequired() → true + const settingsDb = await import("../../src/lib/db/settings.ts"); + await settingsDb.updateSettings({ requireLogin: true, password: "hashed-pw-test" }); + + const req = buildUnauthRequest(); + const res = await route.GET(req); + + assert.equal(res.status, 401); + const body = await res.json(); + // Hard Rule #12: error body should not leak stack traces + const bodyStr = JSON.stringify(body); + assert.ok(!bodyStr.includes(" at /"), "error body must not contain stack trace"); +}); + +test("search-providers-catalog: returns 15 providers (12 search + 3 fetch)", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + + assert.equal(res.status, 200); + const body = await res.json(); + + assert.ok(Array.isArray(body.providers), "`providers` array must be present"); + assert.equal( + body.providers.length, + EXPECTED_TOTAL, + `Expected ${EXPECTED_TOTAL} providers, got ${body.providers.length}` + ); +}); + +test("search-providers-catalog: correct count of search and fetch kinds", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const searchItems = body.providers.filter((p: { kind: string }) => p.kind === "search"); + const fetchItems = body.providers.filter((p: { kind: string }) => p.kind === "fetch"); + + assert.equal( + searchItems.length, + EXPECTED_SEARCH_COUNT, + `Expected ${EXPECTED_SEARCH_COUNT} search-kind items, got ${searchItems.length}` + ); + assert.equal( + fetchItems.length, + EXPECTED_FETCH_COUNT, + `Expected ${EXPECTED_FETCH_COUNT} fetch-kind items, got ${fetchItems.length}` + ); +}); + +test("search-providers-catalog: every item has a valid kind value", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + for (const item of body.providers) { + assert.ok( + item.kind === "search" || item.kind === "fetch", + `item.kind must be 'search' or 'fetch', got '${item.kind}' for id=${item.id}` + ); + } +}); + +test("search-providers-catalog: status=missing when no DB credentials exist", async () => { + // No connections seeded — all providers should be "missing" + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + for (const item of body.providers) { + assert.equal( + item.status, + "missing", + `provider ${item.id} should be 'missing', got '${item.status}'` + ); + } +}); + +test("search-providers-catalog: status=configured when active credentials seeded", async () => { + // Seed an active connection for serper-search + await seedActiveConnection("serper-search"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const serper = body.providers.find((p: { id: string }) => p.id === "serper-search"); + assert.ok(serper, "serper-search must be in response"); + assert.equal( + serper.status, + "configured", + `serper-search should be 'configured' after seeding active credentials` + ); + + // Other providers (no creds) should still be "missing" + const missingItems = body.providers.filter( + (p: { id: string; status: string }) => p.id !== "serper-search" && p.id !== "perplexity-search" + ); + for (const item of missingItems) { + assert.equal(item.status, "missing", `${item.id} should be 'missing'`); + } +}); + +test("search-providers-catalog: status=rate_limited when all connections are rate-limited", async () => { + // Seed a rate-limited connection for brave-search (no active connections) + await seedRateLimitedConnection("brave-search"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const brave = body.providers.find((p: { id: string }) => p.id === "brave-search"); + assert.ok(brave, "brave-search must be in response"); + assert.equal( + brave.status, + "rate_limited", + `brave-search should be 'rate_limited' when all connections have future rateLimitedUntil` + ); +}); + +test("search-providers-catalog: mixed status across providers", async () => { + // serper → configured, brave → rate_limited, rest → missing + await seedActiveConnection("serper-search"); + await seedRateLimitedConnection("brave-search"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const serper = body.providers.find((p: { id: string }) => p.id === "serper-search"); + assert.equal(serper?.status, "configured", "serper-search should be configured"); + + const brave = body.providers.find((p: { id: string }) => p.id === "brave-search"); + assert.equal(brave?.status, "rate_limited", "brave-search should be rate_limited"); + + const exa = body.providers.find((p: { id: string }) => p.id === "exa-search"); + assert.equal(exa?.status, "missing", "exa-search should be missing"); +}); + +test("search-providers-catalog: back-compat data field has legacy shape", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + // Legacy shape: { id, object, created, name, search_types } + assert.ok(Array.isArray(body.data), "`data` array must be present for back-compat"); + assert.equal( + body.data.length, + EXPECTED_TOTAL, + "data array should have same length as providers" + ); + + for (const item of body.data) { + assert.ok(typeof item.id === "string", "data item must have id"); + assert.equal(item.object, "search_provider", "data item object must be 'search_provider'"); + assert.ok(typeof item.created === "number", "data item must have numeric created timestamp"); + assert.ok(typeof item.name === "string", "data item must have name"); + assert.ok(Array.isArray(item.search_types), "data item must have search_types array"); + } +}); + +test("search-providers-catalog: fetch providers have correct metadata", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const fetchProviders = body.providers.filter((p: { kind: string }) => p.kind === "fetch"); + const ids = fetchProviders.map((p: { id: string }) => p.id); + assert.ok(ids.includes("firecrawl"), "firecrawl must be present"); + assert.ok(ids.includes("jina-reader"), "jina-reader must be present"); + assert.ok(ids.includes("tavily-search"), "tavily-search must be present"); + + const firecrawl = fetchProviders.find((p: { id: string }) => p.id === "firecrawl"); + assert.equal(firecrawl.name, "Firecrawl"); + assert.equal(firecrawl.costPerQuery, 0.002); + assert.equal(firecrawl.freeMonthlyQuota, 500); + assert.ok(Array.isArray(firecrawl.fetchFormats), "fetchFormats must be an array"); + assert.ok( + firecrawl.fetchFormats.includes("markdown"), + "firecrawl fetchFormats must include markdown" + ); + assert.ok( + firecrawl.fetchFormats.includes("screenshot"), + "firecrawl fetchFormats must include screenshot" + ); + + const jina = fetchProviders.find((p: { id: string }) => p.id === "jina-reader"); + assert.equal(jina.name, "Jina Reader"); + assert.equal(jina.costPerQuery, 0.0005); + assert.ok(jina.fetchFormats.includes("text"), "jina fetchFormats must include text"); + + const tavily = fetchProviders.find((p: { id: string }) => p.id === "tavily-search"); + assert.equal(tavily.name, "Tavily Extract"); + assert.equal(tavily.costPerQuery, 0.001); +}); + +test("search-providers-catalog: search providers have correct fields", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const searchProviders = body.providers.filter((p: { kind: string }) => p.kind === "search"); + for (const item of searchProviders) { + assert.ok(typeof item.id === "string", "search item must have id"); + assert.ok(typeof item.name === "string", "search item must have name"); + assert.ok(typeof item.costPerQuery === "number", "search item must have costPerQuery"); + assert.ok( + typeof item.freeMonthlyQuota === "number", + "search item must have freeMonthlyQuota" + ); + assert.ok(Array.isArray(item.searchTypes), "search item must have searchTypes array"); + assert.equal( + item.configureHref, + "/dashboard/providers", + "configureHref must be /dashboard/providers" + ); + } + + // Spot check: serper-search + const serper = searchProviders.find((p: { id: string }) => p.id === "serper-search"); + assert.ok(serper, "serper-search must be in search providers"); + assert.ok(serper.searchTypes.includes("web"), "serper must support web search"); + assert.equal(serper.kind, "search"); +}); + +test("search-providers-catalog: response validates against SearchProviderCatalogResponseSchema", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const { SearchProviderCatalogResponseSchema } = await import( + "../../src/shared/schemas/searchTools.ts" + ); + + const result = SearchProviderCatalogResponseSchema.safeParse({ providers: body.providers }); + assert.ok( + result.success, + `Schema validation failed: ${(result as { error?: { message: string } }).error?.message ?? "unknown"}` + ); +}); + +test("search-providers-catalog: fetch providers have configureHref set", async () => { + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const fetchProviders = body.providers.filter((p: { kind: string }) => p.kind === "fetch"); + for (const item of fetchProviders) { + assert.equal( + item.configureHref, + "/dashboard/providers", + `fetch provider ${item.id} must have configureHref='/dashboard/providers'` + ); + } +}); + +test("search-providers-catalog: perplexity-search uses credential fallback", async () => { + // perplexity-search can use perplexity (chat) credentials as fallback + // Seed the fallback provider "perplexity" (not "perplexity-search") + await seedActiveConnection("perplexity"); + + const req = await buildAuthRequest(); + const res = await route.GET(req); + const body = await res.json(); + + const perplexity = body.providers.find((p: { id: string }) => p.id === "perplexity-search"); + assert.ok(perplexity, "perplexity-search must be in response"); + assert.equal( + perplexity.status, + "configured", + "perplexity-search should be 'configured' via fallback to perplexity credentials" + ); +});