Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions tests/api-rate-limit-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,119 @@
// 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<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",
});

expect(rpc).toHaveBeenCalledTimes(2);

Check failure on line 175 in tests/api-rate-limit-fallback.test.ts

View workflow job for this annotation

GitHub Actions / Unit coverage

tests/api-rate-limit-fallback.test.ts > document_upload fail-closed limiter > enforces a global anonymous upload quota as well as the caller quota

AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times ❯ tests/api-rate-limit-fallback.test.ts:175:17
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<string, unknown>) => {
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);

Check failure on line 237 in tests/api-rate-limit-fallback.test.ts

View workflow job for this annotation

GitHub Actions / Unit coverage

tests/api-rate-limit-fallback.test.ts > document_upload fail-closed limiter > reports the smaller of the caller and global remaining counts when both allow the request

AssertionError: expected 2 to be 1 // Object.is equality - Expected + Received - 1 + 2 ❯ tests/api-rate-limit-fallback.test.ts:237:30
});

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<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_read",
});

expect(rpc).toHaveBeenCalledTimes(1);
expect(rpc.mock.calls[0]?.[1]).toMatchObject({ p_subject_key: "anon:caller" });
});
});
44 changes: 44 additions & 0 deletions tests/document-viewer-navigation-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";

import { documentPageHref } from "../src/lib/document-viewer-navigation";

Check failure on line 3 in tests/document-viewer-navigation-source.test.ts

View workflow job for this annotation

GitHub Actions / Unit coverage

tests/document-viewer-navigation-source.test.ts

Error: Cannot find module '../src/lib/document-viewer-navigation' imported from /home/runner/work/Database/Database/tests/document-viewer-navigation-source.test.ts ❯ tests/document-viewer-navigation-source.test.ts:3:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }

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",
);
});
});
117 changes: 117 additions & 0 deletions tests/production-metadata-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";

import { resolveMetadataBase } from "../src/lib/metadata-base";

Check failure on line 3 in tests/production-metadata-source.test.ts

View workflow job for this annotation

GitHub Actions / Unit coverage

tests/production-metadata-source.test.ts

Error: Cannot find module '../src/lib/metadata-base' imported from /home/runner/work/Database/Database/tests/production-metadata-source.test.ts ❯ tests/production-metadata-source.test.ts:3:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }

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/");
});
});
76 changes: 76 additions & 0 deletions tests/public-api-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
});
}

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"));
Expand All @@ -24,4 +33,71 @@

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);

Check failure on line 41 in tests/public-api-access.test.ts

View workflow job for this annotation

GitHub Actions / Unit coverage

tests/public-api-access.test.ts > anonymous API rate-limit identity > ignores caller-controlled identity entries before Railway's appended address

AssertionError: expected 'anon:4486f6066fbb206e1db1becfb98b113e' to be 'anon:631f08140b24b7274d12df3c37a1a80c' // Object.is equality Expected: "anon:631f08140b24b7274d12df3c37a1a80c" Received: "anon:4486f6066fbb206e1db1becfb98b113e" ❯ tests/public-api-access.test.ts:41:20
});

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);

Check failure on line 48 in tests/public-api-access.test.ts

View workflow job for this annotation

GitHub Actions / Unit coverage

tests/public-api-access.test.ts > anonymous API rate-limit identity > keeps distinct Railway-appended client addresses separate

AssertionError: expected 'anon:631f08140b24b7274d12df3c37a1a80c' not to be 'anon:631f08140b24b7274d12df3c37a1a80c' // Object.is equality ❯ tests/public-api-access.test.ts:48:24
});

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);

Check failure on line 95 in tests/public-api-access.test.ts

View workflow job for this annotation

GitHub Actions / Unit coverage

tests/public-api-access.test.ts > anonymous API rate-limit identity > ignores cf-connecting-ip entirely, even without any forwarded-for header

AssertionError: expected 'anon:3b5cd36857cbc87d59a841d2d375222d' to be 'anon:75e12fad3cac7ea7444b58ee13dba30a' // Object.is equality Expected: "anon:75e12fad3cac7ea7444b58ee13dba30a" Received: "anon:3b5cd36857cbc87d59a841d2d375222d" ❯ tests/public-api-access.test.ts:95:32
});

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);
});
});
Loading