From d34e16b1236d2dd404b9a218177362268e067ea3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:12:38 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Generate Unit Tests for PR Changes --- tests/api-rate-limit-fallback.test.ts | 115 +++++++++++++++++ .../document-viewer-navigation-source.test.ts | 44 +++++++ tests/production-metadata-source.test.ts | 117 ++++++++++++++++++ tests/public-api-access.test.ts | 76 ++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 tests/document-viewer-navigation-source.test.ts create mode 100644 tests/production-metadata-source.test.ts diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts index cba820e8a..a82eadd6f 100644 --- a/tests/api-rate-limit-fallback.test.ts +++ b/tests/api-rate-limit-fallback.test.ts @@ -148,4 +148,119 @@ describe("document_upload fail-closed limiter", () => { // document_read degrades to the per-instance limiter instead of failing closed. expect(result.limited).toBe(false); }); + + it("enforces a global anonymous upload quota as well as the caller quota", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const rpc = vi.fn(async (_name: string, args: Record) => ({ + data: { + limited: false, + limit_value: args.p_limit, + remaining: Number(args.p_limit) - 1, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + error: null, + })); + + await consumeSubjectApiRateLimit({ + supabase: { rpc } as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + bucket: "document_upload", + }); + + expect(rpc).toHaveBeenCalledTimes(2); + expect(rpc.mock.calls.map(([, args]) => args.p_subject_key)).toEqual( + expect.arrayContaining(["anon:caller", "anon:document_upload:global"]), + ); + }); + + it("does not consume the global upload quota after the caller quota denies the request", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const rpc = vi.fn(async () => ({ + data: { + limited: true, + limit_value: 3, + remaining: 0, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + error: null, + })); + + const result = await consumeSubjectApiRateLimit({ + supabase: { rpc } as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + bucket: "document_upload", + }); + + expect(result.limited).toBe(true); + expect(rpc).toHaveBeenCalledTimes(1); + expect(rpc.mock.calls[0]?.[1]).toMatchObject({ p_subject_key: "anon:caller" }); + }); + + it("reports the smaller of the caller and global remaining counts when both allow the request", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const rpc = vi.fn(async (_name: string, args: Record) => { + const isGlobal = args.p_subject_key === "anon:document_upload:global"; + return { + data: { + limited: false, + limit_value: args.p_limit, + // Global ceiling is nearly exhausted while the per-caller quota still has headroom. + remaining: isGlobal ? 1 : 2, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + error: null, + }; + }); + + const result = await consumeSubjectApiRateLimit({ + supabase: { rpc } as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + bucket: "document_upload", + }); + + expect(result.limited).toBe(false); + expect(result.remaining).toBe(1); + }); + + it("does not apply a global ceiling to buckets other than answer and document_upload", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const rpc = vi.fn(async (_name: string, args: Record) => ({ + data: { + limited: false, + limit_value: args.p_limit, + remaining: Number(args.p_limit) - 1, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + error: null, + })); + + await consumeSubjectApiRateLimit({ + supabase: { rpc } as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + bucket: "document_read", + }); + + expect(rpc).toHaveBeenCalledTimes(1); + expect(rpc.mock.calls[0]?.[1]).toMatchObject({ p_subject_key: "anon:caller" }); + }); }); diff --git a/tests/document-viewer-navigation-source.test.ts b/tests/document-viewer-navigation-source.test.ts new file mode 100644 index 000000000..e5d256a8b --- /dev/null +++ b/tests/document-viewer-navigation-source.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { documentPageHref } from "../src/lib/document-viewer-navigation"; + +describe("document viewer useful-page navigation", () => { + it("creates a destination-page URL without carrying an unrelated citation chunk", () => { + const href = documentPageHref("document/id", 3); + + expect(href).toBe("/documents/document%2Fid?page=3#pdf-preview-section"); + expect(href).not.toContain("chunk="); + }); + + it("normalizes invalid page numbers to the first page", () => { + expect(documentPageHref("document-id", 0)).toBe("/documents/document-id?page=1#pdf-preview-section"); + expect(documentPageHref("document-id", Number.NaN)).toBe("/documents/document-id?page=1#pdf-preview-section"); + expect(documentPageHref("document-id", Number.POSITIVE_INFINITY)).toBe( + "/documents/document-id?page=1#pdf-preview-section", + ); + }); + + it("clamps negative page numbers to the first page", () => { + expect(documentPageHref("document-id", -5)).toBe("/documents/document-id?page=1#pdf-preview-section"); + expect(documentPageHref("document-id", Number.NEGATIVE_INFINITY)).toBe( + "/documents/document-id?page=1#pdf-preview-section", + ); + }); + + it("truncates fractional page numbers toward zero", () => { + expect(documentPageHref("document-id", 2.9)).toBe("/documents/document-id?page=2#pdf-preview-section"); + expect(documentPageHref("document-id", 2.1)).toBe("/documents/document-id?page=2#pdf-preview-section"); + }); + + it("preserves large integer page numbers", () => { + expect(documentPageHref("document-id", 1_000_000)).toBe( + "/documents/document-id?page=1000000#pdf-preview-section", + ); + }); + + it("encodes documentId characters that are meaningful in a URL", () => { + expect(documentPageHref("doc id&chunk=1", 1)).toBe( + "/documents/doc%20id%26chunk%3D1?page=1#pdf-preview-section", + ); + }); +}); diff --git a/tests/production-metadata-source.test.ts b/tests/production-metadata-source.test.ts new file mode 100644 index 000000000..4d9e0d6bb --- /dev/null +++ b/tests/production-metadata-source.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMetadataBase } from "../src/lib/metadata-base"; + +describe("production metadata origin", () => { + it.each(["http://clinical.test", "https://clinical.test"])( + "uses a configured HTTP(S) origin: %s", + (configuredOrigin) => { + expect(resolveMetadataBase(new Headers(), { configuredSiteUrl: configuredOrigin })?.href).toBe( + `${configuredOrigin}/`, + ); + }, + ); + + it("uses Railway's trusted deployment domain when configuration is absent", () => { + expect(resolveMetadataBase(new Headers(), { trustedDeploymentDomain: "clinical-kb.up.railway.app" })?.href).toBe( + "https://clinical-kb.up.railway.app/", + ); + }); + + it("uses request headers only when the caller explicitly allows a development fallback", () => { + const requestHeaders = new Headers({ + host: "internal.test:3000", + "x-forwarded-host": "clinical.example.org, proxy.internal", + "x-forwarded-proto": "https, http", + }); + + expect(resolveMetadataBase(requestHeaders, { allowRequestOrigin: true })?.href).toBe( + "https://clinical.example.org/", + ); + }); + + it("falls back to Railway's trusted domain when the configured value is malformed", () => { + const options = { + configuredSiteUrl: "not a valid URL", + trustedDeploymentDomain: "clinical-kb.up.railway.app", + }; + + expect(() => resolveMetadataBase(new Headers(), options)).not.toThrow(); + expect(resolveMetadataBase(new Headers(), options)?.href).toBe("https://clinical-kb.up.railway.app/"); + }); + + it("does not trust request-controlled host headers in production", () => { + const requestHeaders = new Headers({ + host: "internal.test:3000", + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "https", + }); + + expect(resolveMetadataBase(requestHeaders)).toBeUndefined(); + }); + + it("rejects a configured origin with a non-HTTP(S) protocol and falls back", () => { + expect( + resolveMetadataBase(new Headers(), { + configuredSiteUrl: "javascript:alert(1)", + trustedDeploymentDomain: "clinical-kb.up.railway.app", + })?.href, + ).toBe("https://clinical-kb.up.railway.app/"); + }); + + it("treats a blank or whitespace-only configured origin as absent", () => { + expect( + resolveMetadataBase(new Headers(), { + configuredSiteUrl: " ", + trustedDeploymentDomain: "clinical-kb.up.railway.app", + })?.href, + ).toBe("https://clinical-kb.up.railway.app/"); + }); + + it("uses a deployment domain that already includes an explicit scheme", () => { + expect(resolveMetadataBase(new Headers(), { trustedDeploymentDomain: "http://clinical-kb.internal" })?.href).toBe( + "http://clinical-kb.internal/", + ); + }); + + it("falls back past a deployment domain with a non-HTTP(S) scheme", () => { + const requestHeaders = new Headers({ host: "clinical.example.org" }); + + expect( + resolveMetadataBase(requestHeaders, { + trustedDeploymentDomain: "ftp://clinical-kb.internal", + allowRequestOrigin: true, + })?.href, + ).toBe("https://clinical.example.org/"); + }); + + it("returns undefined when the dev fallback is allowed but no host header is present", () => { + expect(resolveMetadataBase(new Headers(), { allowRequestOrigin: true })).toBeUndefined(); + }); + + it("falls back to the bare host header when x-forwarded-host is absent", () => { + const requestHeaders = new Headers({ host: "clinical.example.org" }); + + expect(resolveMetadataBase(requestHeaders, { allowRequestOrigin: true })?.href).toBe( + "https://clinical.example.org/", + ); + }); + + it("defaults to https when x-forwarded-proto is missing or unrecognized", () => { + const missingProto = new Headers({ host: "clinical.example.org" }); + expect(resolveMetadataBase(missingProto, { allowRequestOrigin: true })?.href).toBe( + "https://clinical.example.org/", + ); + + const invalidProto = new Headers({ host: "clinical.example.org", "x-forwarded-proto": "ftp" }); + expect(resolveMetadataBase(invalidProto, { allowRequestOrigin: true })?.href).toBe( + "https://clinical.example.org/", + ); + }); + + it("honors an explicit http x-forwarded-proto for the dev fallback", () => { + const requestHeaders = new Headers({ host: "localhost:3000", "x-forwarded-proto": "http" }); + + expect(resolveMetadataBase(requestHeaders, { allowRequestOrigin: true })?.href).toBe("http://localhost:3000/"); + }); +}); diff --git a/tests/public-api-access.test.ts b/tests/public-api-access.test.ts index edaec20df..813313d6b 100644 --- a/tests/public-api-access.test.ts +++ b/tests/public-api-access.test.ts @@ -10,6 +10,15 @@ function anonymousRequest(ip: string, userAgent: string) { }); } +function forwardedRequest(forwardedFor: string, cloudflareIp: string) { + return new Request("http://localhost/api/upload", { + headers: { + "x-forwarded-for": forwardedFor, + "cf-connecting-ip": cloudflareIp, + }, + }); +} + describe("anonymous API rate-limit identity", () => { it("does not let callers rotate the quota by changing user-agent", () => { const first = anonymousApiSubjectKey(anonymousRequest("198.51.100.10", "client-a")); @@ -24,4 +33,71 @@ describe("anonymous API rate-limit identity", () => { expect(second).not.toBe(first); }); + + it("ignores caller-controlled identity entries before Railway's appended address", () => { + const first = anonymousApiSubjectKey(forwardedRequest("192.0.2.10, 198.51.100.20", "203.0.113.10")); + const second = anonymousApiSubjectKey(forwardedRequest("192.0.2.99, 198.51.100.20", "203.0.113.99")); + + expect(second).toBe(first); + }); + + it("keeps distinct Railway-appended client addresses separate", () => { + const first = anonymousApiSubjectKey(forwardedRequest("192.0.2.10, 198.51.100.20", "203.0.113.10")); + const second = anonymousApiSubjectKey(forwardedRequest("192.0.2.10, 198.51.100.99", "203.0.113.10")); + + expect(second).not.toBe(first); + }); + + it("trims whitespace around the appended x-forwarded-for entry", () => { + const spaced = anonymousApiSubjectKey( + new Request("http://localhost/api/upload", { headers: { "x-forwarded-for": "192.0.2.10 , 198.51.100.20 " } }), + ); + const tight = anonymousApiSubjectKey( + new Request("http://localhost/api/upload", { headers: { "x-forwarded-for": "192.0.2.10,198.51.100.20" } }), + ); + + expect(spaced).toBe(tight); + }); + + it("falls back to x-real-ip when x-forwarded-for is absent", () => { + const first = anonymousApiSubjectKey(new Request("http://localhost/api/upload", { headers: { "x-real-ip": "198.51.100.30" } })); + const second = anonymousApiSubjectKey(new Request("http://localhost/api/upload", { headers: { "x-real-ip": "198.51.100.31" } })); + + expect(first).not.toBe(second); + }); + + it("prefers x-forwarded-for over x-real-ip when both are present", () => { + const withBoth = anonymousApiSubjectKey( + new Request("http://localhost/api/upload", { + headers: { "x-forwarded-for": "198.51.100.40", "x-real-ip": "198.51.100.41" }, + }), + ); + const forwardedOnly = anonymousApiSubjectKey( + new Request("http://localhost/api/upload", { headers: { "x-forwarded-for": "198.51.100.40" } }), + ); + + expect(withBoth).toBe(forwardedOnly); + }); + + it("shares a single conservative subject key when no trusted proxy IP is available at all", () => { + const first = anonymousApiSubjectKey(new Request("http://localhost/api/upload", { headers: { "user-agent": "client-a" } })); + const second = anonymousApiSubjectKey(new Request("http://localhost/api/upload", { headers: { "user-agent": "client-b" } })); + + expect(first).toBe(second); + }); + + it("ignores cf-connecting-ip entirely, even without any forwarded-for header", () => { + const withCloudflareOnly = anonymousApiSubjectKey( + new Request("http://localhost/api/upload", { headers: { "cf-connecting-ip": "203.0.113.50" } }), + ); + const noHeaders = anonymousApiSubjectKey(new Request("http://localhost/api/upload")); + + expect(withCloudflareOnly).toBe(noHeaders); + }); + + it("produces a subject key namespaced with the anon: prefix", () => { + const key = anonymousApiSubjectKey(new Request("http://localhost/api/upload", { headers: { "x-real-ip": "198.51.100.60" } })); + + expect(key.startsWith("anon:")).toBe(true); + }); });