fix(web): validate ontology pagination (#138)
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

This commit is contained in:
RissRIce 2026-08-10 19:59:16 -06:00 committed by GitHub
parent edf9a1b063
commit 1cfec322ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 63 additions and 4 deletions

View file

@ -102,6 +102,32 @@ describe("reads", () => {
expect(payload.total).toBeGreaterThan(3);
});
it("falls back safely for negative and malformed pagination values", async () => {
const entityResponse = await listEntities(
request(`/api/ontologies/${ONTOLOGY}/entities?limit=-1&offset=invalid`),
params({ ontologyId: ONTOLOGY })
);
const entityPayload = await body<{ limit: number; offset: number }>(entityResponse);
expect(entityPayload.limit).toBe(50);
expect(entityPayload.offset).toBe(0);
const claimResponse = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?limit=1.5`),
params({ ontologyId: ONTOLOGY })
);
expect((await body<{ limit: number }>(claimResponse)).limit).toBe(100);
const defaultEvents = await events(
request(`/api/ontologies/${ONTOLOGY}/events`),
params({ ontologyId: ONTOLOGY })
);
const invalidEvents = await events(
request(`/api/ontologies/${ONTOLOGY}/events?limit=-1`),
params({ ontologyId: ONTOLOGY })
);
expect(await body(invalidEvents)).toEqual(await body(defaultEvents));
});
it("returns ranked matches with evidence when searching", async () => {
const response = await listEntities(
request(`/api/ontologies/${ONTOLOGY}/entities?q=Avery`),

View file

@ -1,4 +1,5 @@
import { handle, apiJson } from "@/lib/ontology-service";
import { parseBoundedIntegerParam } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@ -6,7 +7,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto
const { ontologyId } = await params;
const url = new URL(request.url);
const statusParam = url.searchParams.get("status") ?? "asserted";
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500);
return handle(request, ontologyId, (engine) => {
const claims = engine.store.listClaims({

View file

@ -1,4 +1,5 @@
import { handle, apiJson } from "@/lib/ontology-service";
import { parseBoundedIntegerParam } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@ -8,8 +9,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto
const url = new URL(request.url);
const type = url.searchParams.get("type") ?? undefined;
const q = url.searchParams.get("q") ?? undefined;
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
const offset = Math.max(Number(url.searchParams.get("offset") ?? 0), 0);
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 50, 1, 200);
const offset = parseBoundedIntegerParam(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER);
return handle(request, ontologyId, (engine) => {
if (q) {

View file

@ -1,4 +1,5 @@
import { getService, apiError, apiJson, engineFor } from "@/lib/ontology-service";
import { parseBoundedIntegerParam } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@ -27,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto
const engine = bound.engine;
if (!wantsStream) {
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500);
return apiJson({ events: engine.listEvents({ limit }) });
}

View file

@ -0,0 +1,17 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { parseBoundedIntegerParam } from "./pagination.ts";
describe("pagination parameters", () => {
test("uses the fallback for negative, fractional, and malformed values", () => {
for (const value of [null, "-1", "1.5", "invalid", "9007199254740992"]) {
assert.equal(parseBoundedIntegerParam(value, 50, 1, 200), 50);
}
});
test("accepts valid values and caps the upper bound", () => {
assert.equal(parseBoundedIntegerParam(" 1 ", 50, 1, 200), 1);
assert.equal(parseBoundedIntegerParam("500", 50, 1, 200), 200);
assert.equal(parseBoundedIntegerParam("0", 0, 0, Number.MAX_SAFE_INTEGER), 0);
});
});

View file

@ -0,0 +1,13 @@
export function parseBoundedIntegerParam(
value: string | null,
fallback: number,
minimum: number,
maximum: number
): number {
const text = value?.trim() ?? "";
if (!/^\d+$/.test(text)) return fallback;
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed < minimum) return fallback;
return Math.min(parsed, maximum);
}