diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts index db44d8786..a0ce75871 100644 --- a/tests/api-rate-limit-fallback.test.ts +++ b/tests/api-rate-limit-fallback.test.ts @@ -177,4 +177,91 @@ describe("document_upload fail-closed limiter", () => { expect.arrayContaining(["anon:caller", "anon:document_upload:global"]), ); }); + + it("uses the document_upload bucket's own authenticated allowance as the global ceiling", 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", + }); + + const globalCall = rpc.mock.calls.find(([, args]) => args.p_subject_key === "anon:document_upload:global"); + // document_upload's authenticated allowance (12/60s), not answer's (30/60s), bounds the + // aggregate anonymous ceiling for this bucket. + expect(globalCall?.[1]).toMatchObject({ p_limit: 12, p_window_seconds: 60 }); + }); + + 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); + const calls = rpc.mock.calls as unknown as Array<[string, Record]>; + expect(calls[0]?.[1]).toMatchObject({ p_subject_key: "anon:caller" }); + }); + + it("does not apply the dual-quota global ceiling to buckets other than answer/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: "registry", + }); + + // A single per-caller check is still sufficient for buckets that aren't fail-closed, + // expensive paid/ingestion paths. + 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 index 3b4574e38..fbfd480f2 100644 --- a/tests/document-viewer-navigation-source.test.ts +++ b/tests/document-viewer-navigation-source.test.ts @@ -17,4 +17,16 @@ describe("document viewer useful-page navigation", () => { "/documents/document-id?page=1#pdf-preview-section", ); }); + + it("normalizes negative page numbers to the first page", () => { + expect(documentPageHref("document-id", -5)).toBe("/documents/document-id?page=1#pdf-preview-section"); + }); + + it("truncates fractional page numbers toward zero", () => { + expect(documentPageHref("document-id", 3.9)).toBe("/documents/document-id?page=3#pdf-preview-section"); + }); + + it("encodes documentId characters that are meaningful in a URL", () => { + expect(documentPageHref("doc id&value", 2)).toBe("/documents/doc%20id%26value?page=2#pdf-preview-section"); + }); }); diff --git a/tests/production-metadata-source.test.ts b/tests/production-metadata-source.test.ts index 6ac2a2c54..f8f2d9ef8 100644 --- a/tests/production-metadata-source.test.ts +++ b/tests/production-metadata-source.test.ts @@ -49,4 +49,48 @@ describe("production metadata origin", () => { expect(resolveMetadataBase(requestHeaders)).toBeUndefined(); }); + + it("uses the trusted deployment domain as-is when it already carries an explicit scheme", () => { + expect(resolveMetadataBase(new Headers(), { trustedDeploymentDomain: "http://staging.up.railway.app" })?.href).toBe( + "http://staging.up.railway.app/", + ); + }); + + it("rejects a configured origin with a non-HTTP(S) protocol and falls through", () => { + expect( + resolveMetadataBase(new Headers(), { + configuredSiteUrl: "ftp://clinical.test", + trustedDeploymentDomain: "clinical-kb.up.railway.app", + })?.href, + ).toBe("https://clinical-kb.up.railway.app/"); + }); + + it("treats a whitespace-only configured site URL as absent", () => { + expect( + resolveMetadataBase(new Headers(), { + configuredSiteUrl: " ", + trustedDeploymentDomain: "clinical-kb.up.railway.app", + })?.href, + ).toBe("https://clinical-kb.up.railway.app/"); + }); + + 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 not http/https", () => { + const missingProto = new Headers({ host: "clinical.example.org" }); + const invalidProto = new Headers({ host: "clinical.example.org", "x-forwarded-proto": "ws" }); + + expect(resolveMetadataBase(missingProto, { allowRequestOrigin: true })?.href).toBe("https://clinical.example.org/"); + expect(resolveMetadataBase(invalidProto, { 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(); + }); }); diff --git a/tests/public-api-access.test.ts b/tests/public-api-access.test.ts index e7c15d3da..2d8378ee7 100644 --- a/tests/public-api-access.test.ts +++ b/tests/public-api-access.test.ts @@ -19,6 +19,10 @@ function forwardedRequest(forwardedFor: string, cloudflareIp: string) { }); } +function headersOnlyRequest(headers: Record) { + return new Request("http://localhost/api/upload", { headers }); +} + 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")); @@ -47,4 +51,42 @@ describe("anonymous API rate-limit identity", () => { expect(second).not.toBe(first); }); + + it("never trusts cf-connecting-ip: callers sharing only that header collapse to the unknown-ip bucket", () => { + const first = anonymousApiSubjectKey(headersOnlyRequest({ "cf-connecting-ip": "203.0.113.10" })); + const second = anonymousApiSubjectKey(headersOnlyRequest({ "cf-connecting-ip": "203.0.113.99" })); + + // Prior to trusting only the deployment proxy's entry, distinct cf-connecting-ip values + // would have produced distinct keys. Now both fall through to the shared "unknown-ip" signal. + expect(second).toBe(first); + }); + + it("falls back to a shared unknown-ip signal when no proxy header is present", () => { + const first = anonymousApiSubjectKey(headersOnlyRequest({ "user-agent": "client-a" })); + const second = anonymousApiSubjectKey(headersOnlyRequest({ "user-agent": "client-b" })); + + expect(second).toBe(first); + }); + + it("prefers x-forwarded-for over x-real-ip when both are present", () => { + const request = new Request("http://localhost/api/upload", { + headers: { + "x-forwarded-for": "192.0.2.10, 198.51.100.20", + "x-real-ip": "203.0.113.55", + }, + }); + const usesForwardedFor = anonymousApiSubjectKey(request); + const matchesForwardedForTail = anonymousApiSubjectKey(forwardedRequest("203.0.113.10, 198.51.100.20", "unused")); + const matchesRealIpOnly = anonymousApiSubjectKey(headersOnlyRequest({ "x-real-ip": "203.0.113.55" })); + + expect(usesForwardedFor).toBe(matchesForwardedForTail); + expect(usesForwardedFor).not.toBe(matchesRealIpOnly); + }); + + it("trims whitespace and ignores empty entries in x-forwarded-for", () => { + const padded = anonymousApiSubjectKey(headersOnlyRequest({ "x-forwarded-for": " 198.51.100.20 , " })); + const clean = anonymousApiSubjectKey(headersOnlyRequest({ "x-forwarded-for": "198.51.100.20" })); + + expect(padded).toBe(clean); + }); });