diff --git a/app/handler/url-safety.ts b/app/handler/url-safety.ts index 897efb1..6de4b2b 100644 --- a/app/handler/url-safety.ts +++ b/app/handler/url-safety.ts @@ -24,10 +24,6 @@ function isHostAllowed(hostname: string): boolean { function isPrivateIpv4(hostname: string): boolean { const parts = hostname.split(".").map((part) => Number.parseInt(part, 10)); - if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) { - return false; - } - const [a, b] = parts; if (a === 10 || a === 127) return true; if (a === 169 && b === 254) return true; @@ -38,6 +34,8 @@ function isPrivateIpv4(hostname: string): boolean { function isBlockedHostname(hostname: string): boolean { const lower = hostname.toLowerCase(); + const normalizedIpHost = + lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower; if ( lower === "localhost" || lower.endsWith(".local") || @@ -46,17 +44,17 @@ function isBlockedHostname(hostname: string): boolean { return true; } - const ipVersion = isIP(lower); + const ipVersion = isIP(normalizedIpHost); if (ipVersion === 4) { - return isPrivateIpv4(lower); + return isPrivateIpv4(normalizedIpHost); } if (ipVersion === 6) { return ( - lower === "::1" || - lower.startsWith("fc") || - lower.startsWith("fd") || - lower.startsWith("fe80:") + normalizedIpHost === "::1" || + normalizedIpHost.startsWith("fc") || + normalizedIpHost.startsWith("fd") || + normalizedIpHost.startsWith("fe80:") ); } diff --git a/package.json b/package.json index e5a765e..e5e926d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "license": "MIT", "author": "Luke Kosner", "scripts": { + "test": "bun test", + "test:coverage": "bun test --coverage", "dev": "next dev", "build": "next build", "vercel:build": "bash ./scripts/vercel-build.sh", diff --git a/tests/handler_and_monitoring.test.ts b/tests/handler_and_monitoring.test.ts new file mode 100644 index 0000000..260226f --- /dev/null +++ b/tests/handler_and_monitoring.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +import { + parseHttpsTargetUrl, + parseHttpsTargetUrlFromRequest, +} from "../app/handler/url-safety"; +import { buildCorsHeaders, withCorsHeaders } from "../app/handler/cors"; +import { + StreamErrorDetector, + createStreamingError, +} from "../lib/monitoring/stream-error-detection"; + +describe("url-safety", () => { + const originalAllowedHosts = process.env.PROXY_ALLOWED_HOSTS; + + beforeEach(() => { + delete process.env.PROXY_ALLOWED_HOSTS; + }); + + afterEach(() => { + if (originalAllowedHosts === undefined) { + delete process.env.PROXY_ALLOWED_HOSTS; + return; + } + process.env.PROXY_ALLOWED_HOSTS = originalAllowedHosts; + }); + + it("accepts valid https URL when no allowlist exists", () => { + const parsed = parseHttpsTargetUrl("https://example.com/path?q=1"); + expect(parsed?.hostname).toBe("example.com"); + expect(parsed?.pathname).toBe("/path"); + }); + + it("rejects empty, malformed, and non-https URLs", () => { + expect(parseHttpsTargetUrl(null)).toBeNull(); + expect(parseHttpsTargetUrl("not-a-url")).toBeNull(); + expect(parseHttpsTargetUrl("http://example.com")).toBeNull(); + }); + + it("blocks localhost/internal/private IPv4/private IPv6 URLs", () => { + expect(parseHttpsTargetUrl("https://localhost")).toBeNull(); + expect(parseHttpsTargetUrl("https://api.local")).toBeNull(); + expect(parseHttpsTargetUrl("https://service.internal")).toBeNull(); + expect(parseHttpsTargetUrl("https://10.0.0.1")).toBeNull(); + expect(parseHttpsTargetUrl("https://172.16.0.3")).toBeNull(); + expect(parseHttpsTargetUrl("https://192.168.1.9")).toBeNull(); + expect(parseHttpsTargetUrl("https://169.254.1.2")).toBeNull(); + expect(parseHttpsTargetUrl("https://127.0.0.1")).toBeNull(); + expect(parseHttpsTargetUrl("https://[::1]")).toBeNull(); + expect(parseHttpsTargetUrl("https://[fc00::1]")).toBeNull(); + expect(parseHttpsTargetUrl("https://[fd00::abcd]")).toBeNull(); + expect(parseHttpsTargetUrl("https://[fe80::1]")).toBeNull(); + }); + + it("allows public IP hosts", () => { + const parsed = parseHttpsTargetUrl("https://8.8.8.8"); + expect(parsed?.hostname).toBe("8.8.8.8"); + }); + + it("enforces explicit host allowlist and wildcard patterns", () => { + process.env.PROXY_ALLOWED_HOSTS = "allowed.com, *.trusted.org"; + + expect(parseHttpsTargetUrl("https://allowed.com")?.hostname).toBe( + "allowed.com" + ); + expect(parseHttpsTargetUrl("https://trusted.org")?.hostname).toBe( + "trusted.org" + ); + expect(parseHttpsTargetUrl("https://api.trusted.org")?.hostname).toBe( + "api.trusted.org" + ); + expect(parseHttpsTargetUrl("https://example.com")).toBeNull(); + }); + + it("reads target URL from request query params", () => { + process.env.PROXY_ALLOWED_HOSTS = "example.com"; + const request = new Request( + "https://proxy.test/?url=https%3A%2F%2Fexample.com%2Fhello" + ); + + const parsed = parseHttpsTargetUrlFromRequest(request); + expect(parsed?.toString()).toBe("https://example.com/hello"); + }); + + it("supports custom query param name", () => { + process.env.PROXY_ALLOWED_HOSTS = "example.com"; + const request = new Request( + "https://proxy.test/?target=https%3A%2F%2Fexample.com%2Fcustom" + ); + + const parsed = parseHttpsTargetUrlFromRequest(request, "target"); + expect(parsed?.toString()).toBe("https://example.com/custom"); + }); + + it("returns null when request query param is missing", () => { + const request = new Request("https://proxy.test/"); + expect(parseHttpsTargetUrlFromRequest(request)).toBeNull(); + }); + + it("allows public IPv6 hosts", () => { + const parsed = parseHttpsTargetUrl("https://[2606:4700:4700::1111]"); + expect(parsed?.hostname).toBe("[2606:4700:4700::1111]"); + }); +}); + +describe("cors helpers", () => { + it("buildCorsHeaders uses request origin and requested headers", () => { + const request = new Request("https://app.test", { + headers: { + origin: "https://client.example", + "access-control-request-headers": "x-custom, authorization", + }, + }); + + const headers = buildCorsHeaders(request); + + expect(headers.get("Access-Control-Allow-Origin")).toBe( + "https://client.example" + ); + expect(headers.get("Access-Control-Allow-Methods")).toBe( + "GET, POST, OPTIONS" + ); + expect(headers.get("Access-Control-Allow-Headers")).toBe( + "x-custom, authorization" + ); + expect(headers.get("Access-Control-Max-Age")).toBe("86400"); + expect(headers.get("Vary")).toContain("Origin"); + }); + + it("buildCorsHeaders falls back to defaults", () => { + const request = new Request("https://app.test", { + headers: { + "access-control-request-headers": " ", + }, + }); + + const headers = buildCorsHeaders(request); + expect(headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(headers.get("Access-Control-Allow-Headers")).toContain( + "content-type" + ); + expect(headers.get("Access-Control-Allow-Headers")).toContain( + "mcp-protocol-version" + ); + }); + + it("withCorsHeaders merges CORS values while preserving body/status", async () => { + const request = new Request("https://app.test", { + headers: { origin: "https://client.example" }, + }); + const response = new Response("payload", { + status: 202, + statusText: "Accepted", + headers: { + "x-original": "keep-me", + }, + }); + + const merged = withCorsHeaders(request, response); + + expect(merged.status).toBe(202); + expect(merged.statusText).toBe("Accepted"); + expect(merged.headers.get("x-original")).toBe("keep-me"); + expect(merged.headers.get("Access-Control-Allow-Origin")).toBe( + "https://client.example" + ); + expect(await merged.text()).toBe("payload"); + }); +}); + +describe("stream error detection", () => { + it("processes normal chunks and strips ctrl46 tokens", () => { + const detector = new StreamErrorDetector(); + const result = detector.processChunk("helloworld"); + + expect(result.hasError).toBe(false); + expect(result.shouldStop).toBe(false); + expect(result.processedText).toBe("helloworld"); + expect(detector.getStats().ctrl46Count).toBe(1); + }); + + it("flags repeated ctrl46 patterns and suggests stopping", () => { + const detector = new StreamErrorDetector(); + detector.processChunk(""); + detector.processChunk(""); + const result = detector.processChunk("value"); + + expect(result.hasError).toBe(true); + expect(result.errorType).toBe("REPEATED_CTRL46"); + expect(result.shouldStop).toBe(true); + expect(result.processedText).toBe("value"); + }); + + it("limits internal buffer growth and supports reset", () => { + const detector = new StreamErrorDetector(); + const oversizedChunk = "a".repeat(1500); + detector.processChunk(oversizedChunk); + + const beforeReset = detector.getStats(); + expect(beforeReset.bufferLength).toBe(1000); + + detector.reset(); + expect(detector.getStats()).toEqual({ ctrl46Count: 0, bufferLength: 0 }); + }); + + it("creates typed streaming errors with known and fallback messages", () => { + const knownError = createStreamingError("REPEATED_CTRL46"); + expect(knownError.name).toBe("StreamingError"); + expect(knownError.message).toContain("repeatedly streaming "); + + const unknownError = createStreamingError("UNEXPECTED"); + expect(unknownError.name).toBe("StreamingError"); + expect(unknownError.message).toBe("Unknown streaming error"); + }); + + it("can clean text helper output directly for completeness", () => { + const detector = new StreamErrorDetector(); + expect((detector as any).cleanText("abc")).toBe("abc"); + }); +}); diff --git a/tests/shared_navigation_and_ingestion.test.ts b/tests/shared_navigation_and_ingestion.test.ts new file mode 100644 index 0000000..c57f9a0 --- /dev/null +++ b/tests/shared_navigation_and_ingestion.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; + +import { cn } from "../lib/shared/cn-utils"; +import { + getEnvValue, + getMonitoringConfig, + isProduction, +} from "../lib/shared/utils"; +import { + getLanguageCode, + getTestimonyLanguage, + getTestimonyMetadata, +} from "../lib/ingestion/processors/testimony-languages"; +import { + NAVIGATION_SECTIONS, + ROUTES, + generateBreadcrumb, + getAllSections, + getNavigationItemByPath, + getNavigationItemsBySection, +} from "../lib/navigation/navigation"; +import { + DEFAULT_URL_CONFIG, + ENV_CONFIG, + MONITORING_CONFIG, + getGCPCredentials, +} from "../lib/shared/config"; + +describe("shared utils", () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalBaseUrl = process.env.NEXT_PUBLIC_BASE_URL; + const originalGcpPrivateKey = process.env.GCP_PRIVATE_KEY; + const originalGcpEmail = process.env.GCP_SERVICE_ACCOUNT_EMAIL; + const originalGcpProjectId = process.env.GCP_PROJECT_ID; + + beforeEach(() => { + delete process.env.NODE_ENV; + delete process.env.NEXT_PUBLIC_BASE_URL; + delete process.env.GCP_PRIVATE_KEY; + delete process.env.GCP_SERVICE_ACCOUNT_EMAIL; + delete process.env.GCP_PROJECT_ID; + }); + + afterEach(() => { + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + + if (originalBaseUrl === undefined) delete process.env.NEXT_PUBLIC_BASE_URL; + else process.env.NEXT_PUBLIC_BASE_URL = originalBaseUrl; + + if (originalGcpPrivateKey === undefined) delete process.env.GCP_PRIVATE_KEY; + else process.env.GCP_PRIVATE_KEY = originalGcpPrivateKey; + + if (originalGcpEmail === undefined) + delete process.env.GCP_SERVICE_ACCOUNT_EMAIL; + else process.env.GCP_SERVICE_ACCOUNT_EMAIL = originalGcpEmail; + + if (originalGcpProjectId === undefined) delete process.env.GCP_PROJECT_ID; + else process.env.GCP_PROJECT_ID = originalGcpProjectId; + }); + + it("returns env-specific values and production booleans", () => { + expect(getEnvValue("prod", "dev", "production")).toBe("prod"); + expect(getEnvValue("prod", "dev", "development")).toBe("dev"); + + expect(isProduction("production")).toBe(true); + expect(isProduction("test")).toBe(false); + }); + + it("uses default environment when NODE_ENV is missing", () => { + expect(getEnvValue("prod", "dev")).toBe("dev"); + expect(isProduction()).toBe(false); + }); + + it("builds monitoring config for prod and dev", () => { + const prod = getMonitoringConfig("production"); + expect(prod.enableMetrics).toBe(true); + expect(prod.enableErrorTracking).toBe(true); + expect(prod.enableAlerts).toBe(true); + expect(prod.logLevel).toBe("warn"); + expect(prod.sensitiveDataPatterns).toBe(MONITORING_CONFIG.SENSITIVE_PATTERNS); + + const dev = getMonitoringConfig("development"); + expect(dev.enableMetrics).toBe(false); + expect(dev.enableAlerts).toBe(false); + expect(dev.logLevel).toBe("debug"); + }); + + it("exports stable shared config defaults and URL config", () => { + expect(ENV_CONFIG.DEFAULT_ENV).toBe("development"); + expect(DEFAULT_URL_CONFIG.AUDIO_PATH).toBe("/audio"); + expect(DEFAULT_URL_CONFIG.LEXICON_PATH).toBe("/lexicon/pdf"); + }); + + it("returns empty GCP credentials in local mode and structured credentials in env mode", () => { + expect(getGCPCredentials()).toEqual({}); + + process.env.GCP_PRIVATE_KEY = "private-key"; + process.env.GCP_SERVICE_ACCOUNT_EMAIL = "svc@project.iam.gserviceaccount.com"; + process.env.GCP_PROJECT_ID = "project-id"; + + expect(getGCPCredentials()).toEqual({ + credentials: { + client_email: "svc@project.iam.gserviceaccount.com", + private_key: "private-key", + }, + projectId: "project-id", + }); + }); +}); + +describe("testimony language helpers", () => { + it("looks up testimony language by last name", () => { + expect(getTestimonyLanguage("David Bondy")).toBe("English"); + expect(getTestimonyLanguage("Chaim Lea")).toBe("German & Spanish"); + expect(getTestimonyLanguage("SingleName")).toBeUndefined(); + }); + + it("maps language names to ISO codes with fallback", () => { + expect(getLanguageCode("German")).toBe("de"); + expect(getLanguageCode("Yiddish")).toBe("yi"); + expect(getLanguageCode("English")).toBe("en"); + expect(getLanguageCode("Spanish")).toBe("es"); + expect(getLanguageCode("Polish")).toBe("pl"); + expect(getLanguageCode("German & Spanish")).toBe("de"); + expect(getLanguageCode("Polish & German")).toBe("de"); + expect(getLanguageCode("Unknown Language")).toBe("en"); + }); + + it("returns testimony metadata for known and unknown names", () => { + expect(getTestimonyMetadata("David Bondy")).toEqual({ + language: "English", + languageCode: "en", + lastName: "bondy", + }); + + expect(getTestimonyMetadata("")).toEqual({ + language: undefined, + languageCode: undefined, + lastName: "", + }); + }); +}); + +describe("cn utility", () => { + it("combines and merges tailwind classes", () => { + expect(cn("p-2", "p-4", "text-sm", undefined, false && "hidden")).toBe( + "p-4 text-sm" + ); + }); +}); + +describe("navigation helpers", () => { + it("finds navigation entries for direct and redirect paths", () => { + const chat = getNavigationItemByPath(ROUTES.CHAT); + expect(chat?.label).toBe("Chat"); + expect(chat?.section).toBe(NAVIGATION_SECTIONS.EVERYONE); + + const developers = getNavigationItemByPath(ROUTES.DEVELOPERS); + expect(developers?.href).toBe(ROUTES.DEVELOPERS_MCP); + }); + + it("resolves parent navigation entry for sub-pages", () => { + const subPath = getNavigationItemByPath("/classroom/teacher/details"); + expect(subPath?.href).toBe(ROUTES.CLASSROOM_TEACHER); + + const unknownPath = getNavigationItemByPath("/does/not/exist"); + expect(unknownPath).toBeUndefined(); + }); + + it("generates breadcrumb values for known, unknown, and empty paths", () => { + expect(generateBreadcrumb(ROUTES.SOURCES)).toEqual({ + section: NAVIGATION_SECTIONS.EVERYONE, + label: "Sources", + }); + + expect(generateBreadcrumb("/new-learning-module")).toEqual({ + section: NAVIGATION_SECTIONS.UNKNOWN, + label: "New Learning Module", + }); + + expect(generateBreadcrumb("")).toBeNull(); + }); + + it("returns section-scoped navigation items and section list", () => { + const education = getNavigationItemsBySection(NAVIGATION_SECTIONS.EDUCATION); + expect(education.map((item) => item.href)).toEqual([ + ROUTES.CLASSROOM_STUDENT, + ROUTES.CLASSROOM_TEACHER, + ]); + + expect(getAllSections()).toEqual([ + NAVIGATION_SECTIONS.PROJECT, + NAVIGATION_SECTIONS.EVERYONE, + NAVIGATION_SECTIONS.EDUCATION, + NAVIGATION_SECTIONS.DEVELOPERS, + ]); + }); +}); diff --git a/tests/static_configs.test.ts b/tests/static_configs.test.ts new file mode 100644 index 0000000..1caaaa4 --- /dev/null +++ b/tests/static_configs.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "bun:test"; + +import { + CHAT_RATE_REFRESH_EVENT, + CHAT_SESSION_STORAGE_KEY, + CHAT_THREAD_STORAGE_KEY, + CHAT_THREAD_UPDATED_EVENT, + MAIN_CHAT_SUGGESTIONS, +} from "../app/chat/constants"; +import { + errorMessages, + mcpConstants, + mcpUsageInstructions, + nextStepsInstructions, +} from "../app/handler/[mcp]/config"; +import { + ANIMATION_CONFIG, + CONTACT, + EXTERNAL_LINKS, + MCP_CAPABILITIES, + MCP_SERVER_CONFIG, + PAGE_STYLING, + sectionVariants, +} from "../app/mcp/config"; +import { sourcesPageConstants } from "../app/sources/config"; + +describe("chat constants", () => { + it("exports storage keys, events, and suggestions", () => { + expect(CHAT_SESSION_STORAGE_KEY).toBe("zekher_chat_session_id"); + expect(CHAT_THREAD_STORAGE_KEY).toBe("zekher_chat_thread_id"); + expect(CHAT_THREAD_UPDATED_EVENT).toBe("zekher_chat_thread_updated"); + expect(CHAT_RATE_REFRESH_EVENT).toBe("zekher_chat_rate_refresh"); + expect(MAIN_CHAT_SUGGESTIONS.length).toBeGreaterThan(5); + expect(MAIN_CHAT_SUGGESTIONS[0]).toContain("Holocaust"); + }); +}); + +describe("mcp route config", () => { + it("exports usage instructions and tool metadata", () => { + expect(mcpUsageInstructions.disclaimer).toContain("cannot guarantee"); + expect(mcpUsageInstructions.citationGuidelines).toContain( + "EXACT citations" + ); + expect(mcpConstants.maxTerms).toBe(6); + expect(mcpConstants.toolName).toBe("yad_vashem_holocaust_lexicon"); + expect(mcpConstants.detailToolName).toBe("yad_vashem_lexicon_entry_detail"); + expect(mcpConstants.pdfBytesToolName).toBe( + "yad_vashem_lexicon_read_pdf_bytes" + ); + expect(mcpConstants.appResourceUri).toBe("ui://zekher/lexicon-explorer.html"); + }); + + it("exports route-level error and next-step messages", () => { + expect(errorMessages.noTerms).toBe("No search terms provided"); + expect(errorMessages.noResults).toBe("No results found"); + expect(errorMessages.systemError).toContain("searching the lexicon"); + + expect(nextStepsInstructions.noResults).toBe("Try different search terms."); + expect(nextStepsInstructions.noResultsLexicon).toContain("historical"); + expect(nextStepsInstructions.noSearchTerms).toContain("search terms"); + expect(nextStepsInstructions.lexicon).toContain("exact citations"); + }); +}); + +describe("mcp page config", () => { + it("exports animation and endpoint settings", () => { + expect(ANIMATION_CONFIG.duration).toBe(0.8); + expect(ANIMATION_CONFIG.delays.section5).toBe(1.0); + expect(EXTERNAL_LINKS.chatgpt).toBe("https://chatgpt.com"); + expect(MCP_SERVER_CONFIG.protocol).toBe("Streamable HTTP"); + expect(MCP_SERVER_CONFIG.endpoints.primary).toBe("/mcp"); + expect(CONTACT.email).toBe("support@zekher.com"); + }); + + it("exports MCP capabilities and styling primitives", () => { + expect(MCP_CAPABILITIES.tools.maxResults).toBe(6); + expect(MCP_CAPABILITIES.tools.maxTerms).toBe(6); + expect(MCP_CAPABILITIES.appTools.detailName).toBe( + "yad_vashem_lexicon_entry_detail" + ); + expect(MCP_CAPABILITIES.appResource.uri).toBe( + "ui://zekher/lexicon-explorer.html" + ); + expect(MCP_CAPABILITIES.prompts.name).toBe("holocaust_education_context"); + + expect(PAGE_STYLING.container).toContain("max-w-4xl"); + expect(PAGE_STYLING.codeBlockClasses).toContain("font-mono"); + expect(PAGE_STYLING.inlineCodeClasses).toContain("rounded"); + + expect(sectionVariants.hidden.opacity).toBe(0); + expect(sectionVariants.visible.transition.duration).toBe( + ANIMATION_CONFIG.duration + ); + }); +}); + +describe("sources page constants", () => { + it("exports copy, pagination, and metadata defaults", () => { + expect(sourcesPageConstants.pageContent.title).toBe("Source Library"); + expect(sourcesPageConstants.tabs.lexicon).toBe("Lexicon"); + expect(sourcesPageConstants.sections.featuredLexicon).toContain("Lexicon"); + expect(sourcesPageConstants.pagination.defaultLimit).toBe(10); + expect(sourcesPageConstants.pagination.maxLimit).toBe(50); + expect(sourcesPageConstants.cardText.lexicon.textPreview).toBe( + "Text Preview" + ); + expect(sourcesPageConstants.metadata.testimony.notFoundTitle).toBe( + "Testimony Not Found" + ); + }); +});