Skip to content
87 changes: 87 additions & 0 deletions tests/api-rate-limit-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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<string, unknown>]>;
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<string, unknown>) => ({
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" });
});
});
12 changes: 12 additions & 0 deletions tests/document-viewer-navigation-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
44 changes: 44 additions & 0 deletions tests/production-metadata-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
42 changes: 42 additions & 0 deletions tests/public-api-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ function forwardedRequest(forwardedFor: string, cloudflareIp: string) {
});
}

function headersOnlyRequest(headers: Record<string, string>) {
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"));
Expand Down Expand Up @@ -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);
});
});