diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 6795da8bfa..92fb882c0b 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1064,6 +1064,9 @@ export async function POST(request: Request) { if (error instanceof PublicApiError) { return jsonError(error, error.status); } + if (error instanceof SyntaxError || error instanceof URIError) { + return jsonError(new PublicApiError("Invalid search request.", 400, { code: "invalid_request" }), 400); + } if (error instanceof Error && error.message.trim()) { const code = classifySearchFailure(error); const fallbackBody = body; @@ -1111,3 +1114,10 @@ export async function POST(request: Request) { return jsonError(error, 500); } } + +export async function GET() { + return jsonError( + new PublicApiError("Method Not Allowed. Search requires a POST request.", 405, { code: "method_not_allowed" }), + 405, + ); +} diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts index ed17b2a63d..3355ca27e4 100644 --- a/src/app/api/search/universal/route.ts +++ b/src/app/api/search/universal/route.ts @@ -7,7 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, PublicApiError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { buildServerTimingHeader, type ServerTimingEntry } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -40,6 +40,7 @@ const universalSearchQuerySchema = z.object({ domains: z .string() .trim() + .max(500) .optional() .transform((value) => { if (!value) return undefined; @@ -186,6 +187,24 @@ export async function GET(request: Request) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); } + if (error instanceof z.ZodError) { + return jsonError(error, 400); + } + if (error instanceof PublicApiError) { + return jsonError(error, error.status); + } + if (error instanceof SyntaxError || error instanceof URIError || error instanceof TypeError) { + return jsonError(new PublicApiError("Invalid universal search query.", 400, { code: "invalid_query" }), 400); + } return jsonError(error); } } + +export async function POST() { + return jsonError( + new PublicApiError("Method Not Allowed. Universal search requires a GET request.", 405, { + code: "method_not_allowed", + }), + 405, + ); +} diff --git a/src/components/clinical-dashboard/guide-progress.ts b/src/components/clinical-dashboard/guide-progress.ts index 671fcabf33..efadcbb8bf 100644 --- a/src/components/clinical-dashboard/guide-progress.ts +++ b/src/components/clinical-dashboard/guide-progress.ts @@ -20,8 +20,16 @@ export function parseGuideProgress(value: string | null): GuideProgress { if (!value) return emptyGuideProgress; try { const parsed = JSON.parse(value) as Record; - if (parsed.version !== 1 || !Array.isArray(parsed.completedStepIds)) return emptyGuideProgress; - const completedStepIdsRaw = parsed.completedStepIds; + if ( + typeof parsed !== "object" || + parsed === null || + parsed.version !== 1 || + !Array.isArray(parsed.completedStepIds) || + parsed.completedStepIds.some((id) => typeof id !== "string") + ) { + return emptyGuideProgress; + } + const completedStepIdsRaw = parsed.completedStepIds as unknown[]; const completedStepIds = guideTourStepIds.filter((id) => completedStepIdsRaw.includes(id)); const lastStepId = typeof parsed.lastStepId === "string" && guideTourStepIdSet.has(parsed.lastStepId) diff --git a/src/lib/api-client-error.ts b/src/lib/api-client-error.ts index 4cf3a87417..d6846cd602 100644 --- a/src/lib/api-client-error.ts +++ b/src/lib/api-client-error.ts @@ -11,6 +11,61 @@ export class ApiClientError extends Error { } } +type ApiErrorDetails = { + code?: string; + retryAfterSeconds?: number; +}; + +type ApiErrorPayload = { + message?: string; + error?: string; + code?: string; + details?: ApiErrorDetails; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonPayload(raw: string): ApiErrorPayload | null { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return null; + } + if (!isRecord(json)) return null; + const payload: ApiErrorPayload = {}; + if (json.message !== undefined) { + if (typeof json.message !== "string") return null; + payload.message = json.message; + } + if (json.error !== undefined) { + if (typeof json.error !== "string") return null; + payload.error = json.error; + } + if (json.code !== undefined) { + if (typeof json.code !== "string") return null; + payload.code = json.code; + } + if (json.details !== undefined) { + if (!isRecord(json.details)) return null; + const details: ApiErrorDetails = {}; + if (json.details.code !== undefined) { + if (typeof json.details.code !== "string") return null; + details.code = json.details.code; + } + if (json.details.retryAfterSeconds !== undefined) { + if (typeof json.details.retryAfterSeconds !== "number" || Number.isNaN(json.details.retryAfterSeconds)) { + return null; + } + details.retryAfterSeconds = json.details.retryAfterSeconds; + } + payload.details = details; + } + return payload; +} + function retryAfterMs(response: Response, now: number) { const raw = response.headers.get("retry-after")?.trim(); if (!raw) return null; @@ -24,16 +79,12 @@ function retryableStatus(status: number) { return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504; } -function sseErrorPayload(text: string) { +function sseErrorPayload(text: string): ApiErrorPayload | null { for (const block of text.split(/\r?\n\r?\n/)) { if (!/^event:\s*error\s*$/m.test(block)) continue; const data = block.match(/^data:\s*(.+)$/m)?.[1]; if (!data) continue; - try { - return JSON.parse(data) as Record; - } catch { - return null; - } + return parseJsonPayload(data); } return null; } @@ -41,13 +92,9 @@ function sseErrorPayload(text: string) { export async function parseApiErrorResponse(response: Response, now = Date.now()) { const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; const text = await response.text().catch(() => ""); - let payload: Record | null = null; + let payload: ApiErrorPayload | null = null; if (contentType.includes("json")) { - try { - payload = JSON.parse(text) as Record; - } catch { - payload = null; - } + payload = parseJsonPayload(text); } else if (contentType.includes("text/event-stream")) { payload = sseErrorPayload(text); } @@ -56,8 +103,7 @@ export async function parseApiErrorResponse(response: Response, now = Date.now() (typeof payload?.error === "string" && payload.error) || (text && !contentType.includes("text/event-stream") ? text.slice(0, 300) : "") || `Request failed (${response.status})`; - const details = - payload?.details && typeof payload.details === "object" ? (payload.details as Record) : null; + const details = payload?.details ?? null; const code = (typeof payload?.code === "string" && payload.code) || (typeof details?.code === "string" && details.code) || diff --git a/src/lib/document-detail.ts b/src/lib/document-detail.ts index 0b8e4f88a8..ea00e5004c 100644 --- a/src/lib/document-detail.ts +++ b/src/lib/document-detail.ts @@ -287,7 +287,7 @@ function loadDemoDocumentDetail(rawId: string, query: DocumentDetailQuery): Docu const rawPayload = getDemoDocumentPayload(rawId); if (!rawPayload) throw new PublicApiError("Demo document not found.", 404); - const payload = rawPayload as unknown as { + const payload = rawPayload as { document: ClinicalDocument; pages: DocumentDetailPage[]; images: DocumentDetailImage[]; @@ -296,6 +296,7 @@ function loadDemoDocumentDetail(rawId: string, query: DocumentDetailQuery): Docu indexHealth?: DocumentDetailPayload["indexHealth"]; }; const allChunks = payload.chunks ?? []; + const selectedChunk = query.chunk ? (allChunks.find((chunk) => chunk.id === query.chunk) ?? null) : null; const requestedPage = Math.min(query.page, Math.max(1, payload.document.page_count ?? 1)); const effectivePage = selectedChunk?.page_number ?? requestedPage; diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index ad49650e0c..126b151195 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -4,6 +4,30 @@ const maxDocumentIds = 25; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type StoredPrivateSearchScope = { version: 1; ownerId: string; documentIds: string[]; expiresAt: number }; + +function parseStoredPrivateSearchScope(raw: string): StoredPrivateSearchScope | null { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return null; + } + if (typeof json !== "object" || json === null) return null; + const value = json as Partial; + if (value.version !== 1) return null; + if (typeof value.ownerId !== "string" || value.ownerId.length === 0) return null; + if ( + !Array.isArray(value.documentIds) || + value.documentIds.length === 0 || + value.documentIds.length > maxDocumentIds || + value.documentIds.some((id) => typeof id !== "string" || !uuidPattern.test(id)) + ) { + return null; + } + if (typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) return null; + return { version: 1, ownerId: value.ownerId, documentIds: value.documentIds, expiresAt: value.expiresAt }; +} + export type PrivateSearchScopeRestore = | { kind: "restored"; documentIds: string[] } | { kind: "unavailable"; reason: "missing" | "invalid" | "expired" | "wrong_owner" }; @@ -48,15 +72,8 @@ export function restorePrivateSearchScope( const raw = storage.getItem(key); if (!raw) return { kind: "unavailable", reason: "missing" }; try { - const value = JSON.parse(raw) as Partial; - if ( - value.version !== 1 || - !Array.isArray(value.documentIds) || - value.documentIds.length === 0 || - value.documentIds.length > maxDocumentIds || - value.documentIds.some((id) => typeof id !== "string" || !uuidPattern.test(id)) || - typeof value.expiresAt !== "number" - ) { + const value = parseStoredPrivateSearchScope(raw); + if (!value) { storage.removeItem(key); return { kind: "unavailable", reason: "invalid" }; } diff --git a/src/lib/rag/rag-row-contracts.ts b/src/lib/rag/rag-row-contracts.ts index c868d7f82f..e6475c970c 100644 --- a/src/lib/rag/rag-row-contracts.ts +++ b/src/lib/rag/rag-row-contracts.ts @@ -19,10 +19,11 @@ import type { SearchResult } from "@/lib/types"; * * - **Strict on the ranking, citation, and evidence fields.** Every required field except * `source_metadata` is `not null` in `supabase/schema.sql`, so requiring it cannot reject a - * row that works today. `source_metadata` is the exception: `documents.metadata` is bare - * `jsonb`, which permits arrays and scalars, so pinning it to an object is guaranteed by the - * data rather than by a constraint. Measured 2026-08-15, all 2851 live documents are - * objects; a `check (jsonb_typeof(metadata) = 'object')` would make that structural. + * row that works today. `source_metadata` is the exception: `documents.metadata` is `not null` + * `jsonb default '{}'::jsonb` without a `check (jsonb_typeof(metadata) = 'object')` constraint, + * so bare `jsonb` in postgres permits arrays and scalars. Pinning it to an object via this + * contract guarantees structural object validation at runtime rather than relying solely on + * database column constraints. Measured 2026-08-15, all 2851 live documents are objects. * The four score fields are `.nullish()` — absent or * null already flows through the downstream `?? 0` handling unchanged — but a *string where * a number belongs* is rejected, which is precisely the silent-misranking case this exists @@ -40,6 +41,12 @@ const retrievalImageSchema = z.looseObject({ caption: z.string(), }); +const sourceMetadataSchema = z + .record(z.string(), z.unknown(), { + message: "source_metadata must be a JSON object", + }) + .nullable(); + const retrievalRowSchema = z.looseObject({ id: z.string().min(1), document_id: z.string().min(1), @@ -50,7 +57,7 @@ const retrievalRowSchema = z.looseObject({ section_heading: z.string().nullable(), content: z.string(), image_ids: z.array(z.string()), - source_metadata: z.record(z.string(), z.unknown()).nullable(), + source_metadata: sourceMetadataSchema, images: z.array(retrievalImageSchema), similarity: z.number().nullish(), text_rank: z.number().nullish(), diff --git a/src/lib/service-catalog-mapper.ts b/src/lib/service-catalog-mapper.ts index 46c5dede85..49a7647df6 100644 --- a/src/lib/service-catalog-mapper.ts +++ b/src/lib/service-catalog-mapper.ts @@ -354,7 +354,7 @@ export function catalogToServiceRecord(service: CatalogService): ServiceRecord { // Facet matching needs the typed tag dimensions only. Keeping the full // catalogue record here would inflate every registry response with unused // source text and metadata. - catalogPayload: { tags: service.tags } as unknown as Record, + catalogPayload: { tags: service.tags }, }; } diff --git a/src/lib/universal-search-stream.ts b/src/lib/universal-search-stream.ts index 33af25025f..8c4f7155b0 100644 --- a/src/lib/universal-search-stream.ts +++ b/src/lib/universal-search-stream.ts @@ -10,29 +10,107 @@ export type UniversalSearchStreamEvent = | { type: "complete"; response: UniversalSearchStreamResponse } | { type: "error"; code: "universal_search_failed" }; -function abortReason(signal: AbortSignal): Error { - return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); +type UniversalSearchItem = UniversalSearchGroup["items"][number]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -function throwIfAborted(signal?: AbortSignal) { - if (signal?.aborted) throw abortReason(signal); +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function optionalString(value: unknown) { + return value === undefined || typeof value === "string"; +} + +function optionalNumber(value: unknown) { + return value === undefined || isFiniteNumber(value); +} + +function optionalBoolean(value: unknown) { + return value === undefined || typeof value === "boolean"; +} + +function parseItem(value: unknown): UniversalSearchItem | null { + if (!isRecord(value)) return null; + if (typeof value.id !== "string") return null; + if (typeof value.kind !== "string") return null; + if (typeof value.title !== "string") return null; + if (!optionalString(value.subtitle)) return null; + if (typeof value.href !== "string") return null; + if (!optionalNumber(value.score)) return null; + if (!optionalString(value.badge)) return null; + if (!optionalString(value.meta)) return null; + if (!optionalBoolean(value.confident)) return null; + return value as unknown as UniversalSearchItem; +} + +function parseGroup(value: unknown): UniversalSearchGroup | null { + if (!isRecord(value)) return null; + if (typeof value.kind !== "string") return null; + if (!isFiniteNumber(value.total)) return null; + if (!Array.isArray(value.items)) return null; + const items = value.items.map(parseItem); + if (items.some((item) => item === null)) return null; + if (!isFiniteNumber(value.latencyMs)) return null; + if (!optionalBoolean(value.error)) return null; + return { ...value, items } as unknown as UniversalSearchGroup; +} + +function parseResponse(value: unknown): UniversalSearchStreamResponse | null { + if (!isRecord(value)) return null; + if (typeof value.query !== "string") return null; + if (!Array.isArray(value.groups)) return null; + const groups = value.groups.map(parseGroup); + if (groups.some((group) => group === null)) return null; + if (!isFiniteNumber(value.tookMs)) return null; + if ( + value.domainOrder !== undefined && + (!Array.isArray(value.domainOrder) || value.domainOrder.some((domain) => typeof domain !== "string")) + ) { + return null; + } + if (!optionalBoolean(value.demoMode)) return null; + if (!optionalBoolean(value.publicAccess)) return null; + return { ...value, groups } as unknown as UniversalSearchStreamResponse; } function parseEvent(line: string): UniversalSearchStreamEvent { - const parsed = JSON.parse(line) as Partial; - if (parsed.type === "group" && typeof parsed.query === "string" && parsed.group) { - return parsed as Extract; + let rawJson: unknown; + try { + rawJson = JSON.parse(line); + } catch { + throw new Error("Invalid universal-search NDJSON event."); } - if (parsed.type === "complete" && parsed.response) { - return parsed as Extract; + if (!isRecord(rawJson)) throw new Error("Invalid universal-search NDJSON event."); + if (rawJson.type === "group") { + if (typeof rawJson.query !== "string") throw new Error("Invalid universal-search NDJSON event."); + const group = parseGroup(rawJson.group); + if (!group) throw new Error("Invalid universal-search NDJSON event."); + return { type: "group", query: rawJson.query, group }; } - if (parsed.type === "error" && parsed.code === "universal_search_failed") { - return parsed as Extract; + if (rawJson.type === "complete") { + const response = parseResponse(rawJson.response); + if (!response) throw new Error("Invalid universal-search NDJSON event."); + return { type: "complete", response }; + } + if (rawJson.type === "error" && rawJson.code === "universal_search_failed") { + return { type: "error", code: "universal_search_failed" }; } throw new Error("Invalid universal-search NDJSON event."); } +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) throw abortReason(signal); +} + /** Consume split NDJSON chunks, surfacing groups immediately and returning final JSON parity. */ + export async function consumeUniversalSearchNdjson( response: Response, options: { diff --git a/src/lib/validation/query.ts b/src/lib/validation/query.ts index 24c132673f..cb0c49e166 100644 --- a/src/lib/validation/query.ts +++ b/src/lib/validation/query.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { parseSchema } from "@/lib/validation/http"; +import { parseSchema, validationError } from "@/lib/validation/http"; type QueryIntegerOptions = { fallback: number; @@ -51,6 +51,19 @@ export function parseRequestQuery( schema: TSchema, message = "Invalid query parameters.", ): z.infer { - const params = Object.fromEntries(new URL(request.url).searchParams.entries()); + let url: URL; + try { + url = new URL(request.url); + } catch { + throw validationError(message, "invalid_query"); + } + const params: Record = {}; + try { + for (const [key, value] of url.searchParams.entries()) { + params[key] = value; + } + } catch { + throw validationError(message, "invalid_query"); + } return parseSchema(schema, params, message, "invalid_query"); } diff --git a/tests/api-search.test.ts b/tests/api-search.test.ts new file mode 100644 index 0000000000..1527dfdc52 --- /dev/null +++ b/tests/api-search.test.ts @@ -0,0 +1,454 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const documentId = "11111111-1111-4111-8111-111111111111"; +const chunkId = "22222222-2222-4222-8222-222222222222"; + +function createSupabaseMock() { + const inserts: Array<{ table: string; payload: unknown }> = []; + const from = vi.fn((table: string) => { + const filters: Array<{ column: string; value: unknown }> = []; + const inFilters: Array<{ column: string; values: unknown[] }> = []; + const builder = { + select: vi.fn(() => builder), + eq: vi.fn((column: string, value: unknown) => { + filters.push({ column, value }); + return builder; + }), + is: vi.fn((column: string, value: unknown) => { + filters.push({ column, value }); + return builder; + }), + in: vi.fn((column: string, values: unknown[]) => { + inFilters.push({ column, values }); + return builder; + }), + order: vi.fn(() => builder), + range: vi.fn(() => builder), + limit: vi.fn(() => builder), + abortSignal: vi.fn(() => builder), + insert: vi.fn(async (payload: unknown) => { + inserts.push({ table, payload }); + return { data: null, error: null }; + }), + then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => { + const explicitIds = inFilters.find((filter) => filter.column === "id")?.values as string[] | undefined; + const ownerFilter = filters.find((filter) => filter.column === "owner_id"); + const data = + table === "documents" && ownerFilter + ? (explicitIds?.length ? explicitIds : [documentId]) + .filter((id) => ownerFilter.value !== null || id === documentId) + .map((id) => ({ + id, + metadata: {}, + import_batch_id: null, + })) + : []; + return Promise.resolve({ data, error: null }).then(onfulfilled); + }, + }; + return builder; + }); + + return { from, inserts }; +} + +function sampleSearchResult() { + return { + id: chunkId, + document_id: documentId, + title: "Clozapine monitoring guideline", + file_name: "clozapine.pdf", + page_number: 1, + chunk_index: 0, + section_heading: "Monitoring", + section_path: ["Monitoring"], + heading_level: 1, + parent_heading: null, + anchor_id: null, + content: "Clozapine monitoring source text.", + image_ids: [], + similarity: 0.92, + text_rank: 0.8, + hybrid_score: 0.93, + rrf_score: 0.03, + source_strength: "strong", + source_metadata: { + document_status: "current", + clinical_validation_status: "unverified", + extraction_quality: "good", + }, + document_labels: [], + images: [], + }; +} + +function mockRuntime(options: { demoMode?: boolean } = {}) { + vi.resetModules(); + + class MockAuthenticationError extends Error { + constructor() { + super("Authentication required."); + this.name = "AuthenticationError"; + } + } + + const supabase = createSupabaseMock(); + const createAdminClient = vi.fn(() => supabase); + const requireAuthenticatedUser = vi.fn(async (request: Request) => { + const id = request.headers.get("x-test-user"); + if (!id) throw new MockAuthenticationError(); + return { id }; + }); + const getOptionalAuthenticatedUser = vi.fn(async (request: Request) => { + const id = request.headers.get("x-test-user"); + return id ? { id } : null; + }); + const unauthorizedResponse = vi.fn(() => Response.json({ error: "Authentication required." }, { status: 401 })); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [sampleSearchResult()], + telemetry: { + query_class: "document_lookup", + retrieval_strategy: "hybrid", + retrieval_plan: "document_lookup:title_label_section_then_chunks", + retrieval_query_variant_count: 2, + search_cache_hit: false, + embedding_skipped: false, + embedding_skip_reason: null, + embedding_cache_hit: false, + embedding_prefetched: true, + text_fast_path_latency_ms: 0, + text_candidate_budget: 24, + text_candidate_count: 3, + text_fast_path_reason: null, + embedding_latency_ms: 0, + vector_candidate_count: 5, + embedding_field_count: 1, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + retrieval_provenance_counts: { chunk: 1, title: 1 }, + second_stage_rerank_used: true, + second_stage_rerank_latency_ms: 2, + visual_direct_image_count: 0, + memory_card_count: 0, + memory_top_score: 0, + weighted_top_score: 0.93, + rrf_top_score: 0.03, + }, + })); + const fetchRelatedDocuments = vi.fn(async () => []); + + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://mock.supabase.co", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "mock-key", + MAX_UPLOAD_MB: 150, + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_SEARCH_CACHE_SIZE: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_SIZE: 0, + RAG_AWAIT_QUERY_LOGS: false, + }, + isDemoMode: () => Boolean(options.demoMode), + isLocalNoAuthMode: () => false, + requireOpenAIEnv: () => undefined, + requireServerEnv: () => undefined, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: MockAuthenticationError, + requireAuthenticatedUser, + getOptionalAuthenticatedUser, + unauthorizedResponse, + })); + vi.doMock("@/lib/api-rate-limit", () => ({ + consumeSubjectApiRateLimit: vi.fn(async () => ({ + limited: false, + limit: 100, + remaining: 99, + retryAfterSeconds: 0, + resetAt: new Date(Date.now() + 60_000).toISOString(), + })), + allowRateLimitInMemoryFallbackOnUnavailable: () => true, + rateLimitJsonResponse: vi.fn(), + })); + vi.doMock("@/lib/demo-data", () => ({ + demoSearch: vi.fn(() => [sampleSearchResult()]), + demoAnswer: vi.fn(), + })); + vi.doMock("@/lib/rag/rag", () => ({ + searchChunksWithTelemetry, + })); + + vi.doMock("@/lib/document-enrichment", () => ({ + fetchRelatedDocuments, + toDocumentMatch: vi.fn((document: unknown) => document), + })); + vi.doMock("@/lib/evidence", () => ({ + buildSmartPanel: vi.fn(() => ({})), + buildVisualEvidence: vi.fn(() => []), + diversifySearchResults: vi.fn((results: unknown[]) => results), + })); + vi.doMock("@/lib/evidence-relevance", async (importOriginal) => ({ + ...(await importOriginal()), + annotateDocumentMatches: vi.fn((_query: string, documents: unknown[]) => documents), + annotateSearchResults: vi.fn((_query: string, results: unknown[]) => results), + })); + vi.doMock("@/lib/universal-search", () => ({ + universalSearchDomains: ["documents", "medications", "forms", "services", "differentials", "calculators"], + runUniversalSearch: vi.fn(async (args: { query: string; demo?: boolean }) => ({ + query: args.query, + groups: [ + { + kind: "medications", + title: "Medications", + items: [{ id: "lithium", title: "Lithium", href: "/medications/lithium" }], + total: 1, + latencyMs: 5, + }, + ], + tookMs: 6, + domainOrder: ["medications"], + })), + })); + + return { + createAdminClient, + fetchRelatedDocuments, + getOptionalAuthenticatedUser, + requireAuthenticatedUser, + searchChunksWithTelemetry, + supabase, + }; +} + +function jsonRequest(path: string, body: Record, authenticated = false) { + const headers = new Headers({ "Content-Type": "application/json" }); + if (authenticated) headers.set("x-test-user", ownerId); + + return new Request(`http://localhost${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +function request(path: string, init?: RequestInit) { + return new Request(`http://localhost${path}`, { + ...init, + headers: { + ...init?.headers, + }, + }); +} + +async function payload(response: Response) { + return (await response.json()) as Record; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("/api/search route defensive hardening (Task #342)", () => { + it("accepts a valid search request and returns 200 in demo mode", async () => { + mockRuntime({ demoMode: true }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "lithium" })); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.demoMode).toBe(true); + expect(Array.isArray(body.results)).toBe(true); + }); + + it("accepts a valid search request and returns 200 in live mode", async () => { + mockRuntime({ demoMode: false }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "lithium dosage", topK: 5 }, true)); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(Array.isArray(body.results)).toBe(true); + expect(body.results).toHaveLength(1); + }); + + it("rejects malformed JSON bodies with structured 400 JSON", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + request("/api/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ malformed json", + }), + ); + const body = await payload(response); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + error: "Invalid search request.", + code: "invalid_body", + }); + }); + + it("rejects empty or missing query parameter with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const responseEmpty = await POST(jsonRequest("/api/search", { query: " " })); + const bodyEmpty = await payload(responseEmpty); + + expect(responseEmpty.status).toBe(400); + expect(bodyEmpty).toMatchObject({ + error: "Invalid search request.", + code: "invalid_body", + }); + + const responseMissing = await POST(jsonRequest("/api/search", {})); + const bodyMissing = await payload(responseMissing); + + expect(responseMissing.status).toBe(400); + expect(bodyMissing).toMatchObject({ + error: "Invalid search request.", + code: "invalid_body", + }); + }); + + it("rejects queries exceeding the 2000 character length limit with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "a".repeat(2001) })); + const body = await payload(response); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ error: "Invalid search request.", code: "invalid_body" }); + }); + + it("rejects invalid topK or documentLimit with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const responseTopK = await POST(jsonRequest("/api/search", { query: "lithium", topK: 50 })); + expect(responseTopK.status).toBe(400); + + const responseDocLimit = await POST(jsonRequest("/api/search", { query: "lithium", documentLimit: 100 })); + expect(responseDocLimit.status).toBe(400); + }); + + it("rejects non-UUID documentId with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "lithium", documentId: "not-a-uuid" })); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ error: "Invalid search request.", code: "invalid_body" }); + }); + + it("rejects payloads exceeding the byte limit with 413 payload_too_large", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + request("/api/search", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "300000", + }, + body: JSON.stringify({ query: "lithium" }), + }), + ); + expect(response.status).toBe(413); + expect(await payload(response)).toMatchObject({ code: "payload_too_large" }); + }); + + it("rejects GET requests with structured 405 Method Not Allowed", async () => { + const { GET } = await import("../src/app/api/search/route"); + + const response = await GET(); + const body = await payload(response); + + expect(response.status).toBe(405); + expect(body).toMatchObject({ + code: "method_not_allowed", + }); + }); +}); + +describe("/api/search/universal route defensive hardening (Task #342)", () => { + it("accepts a valid universal search query and returns 200", async () => { + mockRuntime({ demoMode: true }); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(request("/api/search/universal?q=lithium")); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.query).toBe("lithium"); + expect(Array.isArray(body.groups)).toBe(true); + }); + + it("rejects missing or too short q parameter with structured 400 JSON", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const responseMissing = await GET(request("/api/search/universal")); + expect(responseMissing.status).toBe(400); + expect(await payload(responseMissing)).toMatchObject({ code: "invalid_query" }); + + const responseShort = await GET(request("/api/search/universal?q=a")); + expect(responseShort.status).toBe(400); + expect(await payload(responseShort)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects queries exceeding 200 chars with structured 400 JSON", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(request(`/api/search/universal?q=${"a".repeat(201)}`)); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects oversized domains parameter with structured 400 JSON", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(request(`/api/search/universal?q=lithium&domains=${"a".repeat(501)}`)); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects malformed URLs gracefully with structured 400 instead of unhandled 500", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const badRequest = { + url: "://malformed-url", + headers: new Headers(), + signal: new AbortController().signal, + } as unknown as Request; + + const response = await GET(badRequest); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects POST requests on universal search with structured 405 Method Not Allowed", async () => { + const { POST } = await import("../src/app/api/search/universal/route"); + + const response = await POST(); + const body = await payload(response); + + expect(response.status).toBe(405); + expect(body).toMatchObject({ + code: "method_not_allowed", + }); + }); +}); diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index 2ebf95c807..32438b3ab6 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -130,9 +130,26 @@ describe("retrieval row shape contract", () => { ); }); + it.each([ + ["array", [1, 2, 3]], + ["string", "invalid-string-metadata"], + ["number", 12345], + ["boolean", true], + ])("rejects non-object JSON structure for source_metadata (%s)", (_type, invalidMetadata) => { + let thrown: RetrievalRowShapeError | null = null; + try { + assertRetrievalRows([hybridRow({ source_metadata: invalidMetadata })], "match_document_chunks_hybrid"); + } catch (error) { + thrown = error as RetrievalRowShapeError; + } + expect(thrown).toBeInstanceOf(RetrievalRowShapeError); + expect(thrown?.message).toContain("source_metadata"); + }); + it("accepts absent or null scores, which downstream already coalesces to 0", () => { expect(() => assertRetrievalRows([withoutColumn("text_rank")], "match_document_chunks")).not.toThrow(); expect(() => assertRetrievalRows([hybridRow({ rrf_score: null })], "match_document_chunks")).not.toThrow(); + expect(() => assertRetrievalRows([hybridRow({ source_metadata: null })], "match_document_chunks")).not.toThrow(); expect(() => assertRetrievalRows([], "match_document_chunks")).not.toThrow(); });