20260718 Explorer 接入 VRP 检索查询

This commit is contained in:
yuyr 2026-07-20 10:05:00 +08:00
parent 6d9eedd680
commit 06f4b086b4
12 changed files with 554 additions and 22 deletions

View File

@ -23,7 +23,12 @@ not change validation logic or RP state.
- **Validation** — result distribution, reject-reason distribution with
click-to-filter, paginated issue list (`/issues`).
- **Search** — unified lookup: exact object URI, SHA-256 (full or ≥8 hex
prefix), repository host/URI substring, PP URI substring.
prefix), repository host/URI substring, PP URI substring — plus **VRP
lookup**: enter an IPv4/IPv6 address or CIDR prefix to list the covering
VRPs (prefix queries use covering semantics: VRP prefix must cover the whole
queried prefix with `maxLength` ≥ queried length), with an optional ASN
filter and cursor paging. ASN-only queries are not supported by the API and
the page says so.
- **Exports** — export job list with live polling and tarball download.
- **Runs** — indexed run history; pin any run as the browsing context via the
run selector in the top bar (`?run=` URL param, deep-linkable everywhere).
@ -41,8 +46,9 @@ query-db built by `rpki_query_indexer`. Some features need extra service flags:
The service has **no CORS headers and no authentication** — the UI must be
served same-origin with the API behind a proxy (dev: Vite proxy; prod: reverse
proxy). VRP IP/prefix/ASN lookup is not available yet (backend feature #070);
the search page says so explicitly when such a query is detected.
proxy). VRP lookup needs runs indexed at query-db schema version 2 (`#122`);
re-index older runs with `rpki_query_indexer` (or the service
`--watch-run-root` backfill), otherwise lookup results are empty.
## Development

View File

@ -209,6 +209,15 @@ export const searchResultSchema = z
})
.passthrough();
export const vrpRecordSchema = z
.object({
runId: z.string(),
asn: z.number(),
prefix: z.string(),
maxLength: z.number(),
})
.passthrough();
export const healthSchema = z
.object({
status: z.string(),
@ -295,6 +304,7 @@ export type ValidationStage = z.infer<typeof validationStageSchema>;
export type ValidationSummary = z.infer<typeof validationSummarySchema>;
export type ExplainRecord = z.infer<typeof explainRecordSchema>;
export type SearchResult = z.infer<typeof searchResultSchema>;
export type VrpRecord = z.infer<typeof vrpRecordSchema>;
export type HealthStatus = z.infer<typeof healthSchema>;
export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
export type ManifestFileEntry = z.infer<typeof manifestFileEntrySchema>;

View File

@ -22,6 +22,7 @@ import {
serviceInfoSchema,
statsMapSchema,
validationSummarySchema,
vrpRecordSchema,
type ChainEdgeRecord,
type ExportJobRecord,
type ExplainRecord,
@ -38,6 +39,7 @@ import {
type SearchResult,
type ServiceInfo,
type ValidationSummary,
type VrpRecord,
} from "./schemas";
const API = "/api/v1";
@ -387,6 +389,26 @@ export function searchRun(
return getData(`${runBase(runId)}/search`, searchResultSchema, { q, limit });
}
/* ------------------------------------------------------------------ */
/* VRP lookup */
/* ------------------------------------------------------------------ */
export type VrpLookupParams = PageParams & {
/** Exact IPv4/IPv6 address (mutually exclusive with `prefix`). */
ip?: string;
/** CIDR; returns VRPs covering the whole prefix (maxLength semantics). */
prefix?: string;
/** Optional ASN filter applied on top of ip/prefix. */
asn?: number;
};
export function lookupVrps(
runId: string,
params: VrpLookupParams,
): Promise<ListResult<VrpRecord>> {
return getList(`${runBase(runId)}/vrps/lookup`, vrpRecordSchema, params);
}
/* ------------------------------------------------------------------ */
/* Stats */
/* ------------------------------------------------------------------ */

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
classifyNetworkQuery,
parseAsn,
parseIpAddress,
parsePrefix,
} from "./vrpQuery";
describe("parseAsn", () => {
it("accepts bare and AS-prefixed numbers", () => {
expect(parseAsn("45090")).toBe(45090);
expect(parseAsn("AS45090")).toBe(45090);
expect(parseAsn("as0")).toBe(0);
expect(parseAsn(" AS64496 ")).toBe(64496);
});
it("rejects out-of-range and malformed ASNs", () => {
expect(parseAsn("4294967295")).toBe(4294967295);
expect(parseAsn("4294967296")).toBeNull();
expect(parseAsn("AS123abc")).toBeNull();
expect(parseAsn("AS")).toBeNull();
expect(parseAsn("-1")).toBeNull();
});
});
describe("parseIpAddress", () => {
it("accepts IPv4 addresses", () => {
expect(parseIpAddress("81.68.1.1")).toBe("81.68.1.1");
expect(parseIpAddress("0.0.0.0")).toBe("0.0.0.0");
expect(parseIpAddress("255.255.255.255")).toBe("255.255.255.255");
});
it("rejects IPv4 with bad octets or CIDR suffix", () => {
expect(parseIpAddress("256.0.0.1")).toBeNull();
expect(parseIpAddress("192.0.2.1/24")).toBeNull();
expect(parseIpAddress("192.0.2")).toBeNull();
});
it("accepts IPv6 addresses", () => {
expect(parseIpAddress("2001:7fa:0:1::1")).toBe("2001:7fa:0:1::1");
expect(parseIpAddress("::")).toBe("::");
expect(parseIpAddress("::1")).toBe("::1");
expect(parseIpAddress("2001:db8:0:0:0:0:0:1")).toBe("2001:db8:0:0:0:0:0:1");
expect(parseIpAddress("1:2:3:4:5:6:7::")).toBe("1:2:3:4:5:6:7::"); // "::" = one :0 group
expect(parseIpAddress("::ffff:192.0.2.1")).toBe("::ffff:192.0.2.1");
});
it("rejects malformed IPv6", () => {
expect(parseIpAddress("2001:::1")).toBeNull();
expect(parseIpAddress("2001:db8::g1")).toBeNull();
expect(parseIpAddress("1:2:3:4:5:6:7:8:9")).toBeNull();
expect(parseIpAddress("1:2:3:4:5:6:7:8::")).toBeNull(); // no room for "::"
expect(parseIpAddress("12345::")).toBeNull();
});
});
describe("parsePrefix", () => {
it("accepts IPv4 and IPv6 CIDR", () => {
expect(parsePrefix("192.0.2.0/24")).toBe("192.0.2.0/24");
expect(parsePrefix("192.0.2.1/24")).toBe("192.0.2.1/24"); // host bits: backend normalizes
expect(parsePrefix("0.0.0.0/0")).toBe("0.0.0.0/0");
expect(parsePrefix("2001:db8::/32")).toBe("2001:db8::/32");
expect(parsePrefix("2001:db8::1/128")).toBe("2001:db8::1/128");
});
it("rejects bad lengths and addresses", () => {
expect(parsePrefix("192.0.2.0/33")).toBeNull();
expect(parsePrefix("2001:db8::/129")).toBeNull();
expect(parsePrefix("192.0.2.0/")).toBeNull();
expect(parsePrefix("192.0.2.0/24/1")).toBeNull();
expect(parsePrefix("999.0.0.0/8")).toBeNull();
expect(parsePrefix("/24")).toBeNull();
});
});
describe("classifyNetworkQuery", () => {
it("prefix wins over ip, ip over asn", () => {
expect(classifyNetworkQuery("81.68.0.0/14")).toEqual({
kind: "prefix",
prefix: "81.68.0.0/14",
});
expect(classifyNetworkQuery("81.68.1.1")).toEqual({ kind: "ip", ip: "81.68.1.1" });
expect(classifyNetworkQuery("2001:7fa:0:1::1")).toEqual({
kind: "ip",
ip: "2001:7fa:0:1::1",
});
expect(classifyNetworkQuery("AS45090")).toEqual({ kind: "asn", asn: 45090 });
expect(classifyNetworkQuery("45090")).toEqual({ kind: "asn", asn: 45090 });
});
it("falls back to none for text queries", () => {
expect(classifyNetworkQuery("repo-a.example")).toEqual({ kind: "none" });
expect(classifyNetworkQuery("rsync://repo.example/ca/roa.roa")).toEqual({ kind: "none" });
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
expect(classifyNetworkQuery("")).toEqual({ kind: "none" });
});
});

View File

@ -0,0 +1,104 @@
/**
* Classify a free-text search query into a VRP lookup target.
*
* The backend VRP lookup (`GET /runs/{id}/vrps/lookup`) accepts exactly one of
* `ip` / `prefix`, plus an optional `asn` filter. An ASN-only query cannot be
* served by the API the UI surfaces that explicitly instead of guessing.
*/
export type NetworkQuery =
| { kind: "ip"; ip: string }
| { kind: "prefix"; prefix: string }
| { kind: "asn"; asn: number }
| { kind: "none" };
const ASN_MAX = 4_294_967_295;
/** "AS45090" / "as45090" / "45090" → ASN, else null. */
export function parseAsn(input: string): number | null {
const match = /^(?:as)?(\d{1,10})$/i.exec(input.trim());
if (!match) return null;
const asn = Number(match[1]);
return Number.isSafeInteger(asn) && asn <= ASN_MAX ? asn : null;
}
function isIpv4Octets(addr: string): boolean {
const parts = addr.split(".");
return (
parts.length === 4 &&
parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
);
}
function isIpv6(addr: string): boolean {
if (!/^[0-9a-fA-F:.]+$/.test(addr)) return false;
if ((addr.match(/::/g) ?? []).length > 1) return false;
const isGroup = (g: string, isLast: boolean) =>
isLast && g.includes(".")
? isIpv4Octets(g) // embedded IPv4 tail, e.g. "::ffff:192.0.2.1"
: /^[0-9a-fA-F]{1,4}$/.test(g);
const ipv4TailWeight = (groups: string[]) =>
groups.length > 0 && groups[groups.length - 1].includes(".") ? 1 : 0;
if (addr.includes("::")) {
const [head, tail] = addr.split("::");
const headGroups = head ? head.split(":") : [];
const tailGroups = tail ? tail.split(":") : [];
// "::" compresses at least one group; an IPv4 tail counts as two groups.
const effective =
headGroups.length + tailGroups.length + ipv4TailWeight(tailGroups);
if (effective > 7) return false;
return [...headGroups, ...tailGroups].every((g, i, all) =>
isGroup(g, i === all.length - 1),
);
}
const groups = addr.split(":");
const expected = groups[groups.length - 1]?.includes(".") ? 7 : 8;
if (groups.length !== expected) return false;
return groups.every((g, i) => isGroup(g, i === groups.length - 1));
}
/** Bare IPv4/IPv6 address (no "/") → normalized string, else null. */
export function parseIpAddress(input: string): string | null {
const s = input.trim();
if (s.includes("/")) return null;
if (isIpv4Octets(s)) return s;
if (s.includes(":") && isIpv6(s)) return s;
return null;
}
function parsePrefixParts(s: string): [string, number] | null {
const slash = s.indexOf("/");
if (slash < 0 || slash !== s.lastIndexOf("/")) return null;
const addr = s.slice(0, slash);
const lenRaw = s.slice(slash + 1);
if (!/^\d{1,3}$/.test(lenRaw)) return null;
return [addr, Number(lenRaw)];
}
/** "addr/len" CIDR with family-correct length → normalized string, else null. */
export function parsePrefix(input: string): string | null {
const s = input.trim();
const parts = parsePrefixParts(s);
if (!parts) return null;
const [addr, len] = parts;
if (isIpv4Octets(addr)) return len <= 32 ? `${addr}/${len}` : null;
if (addr.includes(":") && isIpv6(addr)) return len <= 128 ? `${addr}/${len}` : null;
return null;
}
/**
* Classify a query string. Precedence: CIDR prefix bare IP ASN none.
* ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so
* no overlap. A bare number is always treated as an ASN.)
*/
export function classifyNetworkQuery(input: string): NetworkQuery {
const s = input.trim();
if (!s) return { kind: "none" };
const prefix = parsePrefix(s);
if (prefix) return { kind: "prefix", prefix };
const ip = parseIpAddress(s);
if (ip) return { kind: "ip", ip };
const asn = parseAsn(s);
if (asn !== null) return { kind: "asn", asn };
return { kind: "none" };
}

View File

@ -22,6 +22,7 @@ const ENDPOINTS: { method: "GET" | "POST"; path: string; note: string }[] = [
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/raw", note: "Raw DER bytes (needs repo-bytes-db)" },
{ method: "GET", path: "/api/v1/runs/{run}/issues", note: "Rejected/errored objects (filters: reason,type,repoId,ppId)" },
{ method: "GET", path: "/api/v1/runs/{run}/search?q=", note: "Unified search: URI / sha256 / repo host / PP URI" },
{ method: "GET", path: "/api/v1/runs/{run}/vrps/lookup?ip=|prefix=[&asn=]", note: "VRP lookup (covering semantics, cursor paged)" },
{ method: "GET", path: "/api/v1/runs/{run}/stats/{validation,object-types,reasons}", note: "Aggregated counters" },
{ method: "POST", path: "/api/v1/runs/{run}/exports", note: "Start export job (repo / publication_point / object_set)" },
{ method: "GET", path: "/api/v1/runs/{run}/exports", note: "Export job list (process memory)" },

View File

@ -1,22 +1,140 @@
import { useEffect, useState, type FormEvent } from "react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { FileBox, FolderTree, Server } from "lucide-react";
import { searchRun } from "../api/service";
import { lookupVrps, searchRun } from "../api/service";
import type { VrpRecord } from "../api/schemas";
import { CursorPagerControls } from "../components/CursorPagerControls";
import { DataTable, type Column } from "../components/DataTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock";
import { useCursorPager } from "../lib/cursor";
import { objectTypeLabel } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
import { classifyNetworkQuery, parseAsn, type NetworkQuery } from "../lib/vrpQuery";
const VRP_PAGE_SIZE = 50;
/**
* VRP lookup results for IP / CIDR queries, backed by
* `GET /runs/{id}/vrps/lookup` (backend feature #122). Optional ASN filter
* rides in the `?asn=` URL param so results stay deep-linkable.
*/
function VrpResults({ network }: { network: NetworkQuery & { kind: "ip" | "prefix" } }) {
const { runId } = useRun();
const [searchParams, setSearchParams] = useSearchParams();
const asnParam = searchParams.get("asn") ?? "";
const asn = asnParam ? parseAsn(asnParam) : null;
const [asnDraft, setAsnDraft] = useState(asnParam);
const [asnError, setAsnError] = useState<string | null>(null);
const pager = useCursorPager(`vrp|${runId}|${network.kind}|${network.kind === "ip" ? network.ip : network.prefix}|${asn ?? ""}`);
useEffect(() => setAsnDraft(asnParam), [asnParam]);
const target = network.kind === "ip" ? { ip: network.ip } : { prefix: network.prefix };
const vrpQuery = useQuery({
queryKey: ["vrp-lookup", runId, target, asn, pager.cursor],
queryFn: () =>
lookupVrps(runId, { ...target, asn: asn ?? undefined, limit: VRP_PAGE_SIZE, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const applyAsnFilter = (event: FormEvent) => {
event.preventDefault();
const draft = asnDraft.trim();
if (!draft) {
setAsnError(null);
const params = new URLSearchParams(searchParams);
params.delete("asn");
setSearchParams(params, { preventScrollReset: true });
return;
}
const parsed = parseAsn(draft);
if (parsed === null) {
setAsnError(`${draft}” is not a valid ASN (digits, optionally AS-prefixed).`);
return;
}
setAsnError(null);
const params = new URLSearchParams(searchParams);
params.set("asn", String(parsed));
setSearchParams(params, { preventScrollReset: true });
};
const columns: Column<VrpRecord>[] = [
{
key: "asn",
header: "ASN",
render: (vrp) => <span className="cell-main mono">AS{vrp.asn}</span>,
},
{
key: "prefix",
header: "Prefix",
render: (vrp) => <span className="mono">{vrp.prefix}</span>,
},
{
key: "maxLength",
header: "Max length",
numeric: true,
render: (vrp) => <span className="mono">/{vrp.maxLength}</span>,
},
];
const targetLabel = network.kind === "ip" ? network.ip : network.prefix;
const emptyHint =
network.kind === "prefix"
? "Covering semantics: a VRP only matches when its prefix covers the whole queried prefix and its maxLength is at least the queried length."
: "No VRP prefix covers this address in the active run.";
/** Detect queries the backend cannot serve yet (VRP lookup = backlog #070). */
function looksLikeNetworkQuery(q: string): boolean {
return (
/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(q) || // IPv4 / prefix
/^[0-9a-fA-F:]*:[0-9a-fA-F:.]*(\/\d{1,3})?$/.test(q) || // IPv6-ish
/^(AS)?\d{1,10}$/i.test(q) // ASN
<Panel
title="VRP lookup"
subtitle={
network.kind === "ip"
? `VRPs whose prefix covers ${targetLabel}`
: `VRPs covering the whole prefix ${targetLabel} (maxLength ≥ queried length)`
}
tools={
<form className="vrp-asn-filter" onSubmit={applyAsnFilter} role="search">
<label htmlFor="vrp-asn">ASN filter</label>
<input
id="vrp-asn"
type="text"
inputMode="numeric"
value={asnDraft}
onChange={(e) => setAsnDraft(e.target.value)}
placeholder="e.g. AS45090 (optional)"
/>
<button type="submit" className="btn small">
Apply
</button>
</form>
}
>
{asnError ? <Notice kind="warning">{asnError}</Notice> : null}
<DataTable
columns={columns}
rows={vrpQuery.data?.items ?? []}
rowKey={(vrp) => `${vrp.asn}-${vrp.prefix}-${vrp.maxLength}`}
loading={vrpQuery.isPending}
error={vrpQuery.isError ? vrpQuery.error : undefined}
onRetry={() => vrpQuery.refetch()}
emptyTitle={`No VRPs cover ${targetLabel}${asn !== null ? ` for AS${asn}` : ""}`}
emptyHint={emptyHint}
caption={`VRP lookup results for ${targetLabel}`}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={Boolean(vrpQuery.data?.nextCursor)}
loading={vrpQuery.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(vrpQuery.data?.nextCursor ?? null)}
itemCount={vrpQuery.data?.items.length}
/>
</Panel>
);
}
@ -28,10 +146,13 @@ export default function SearchPage() {
useEffect(() => setDraft(q), [q]);
const network: NetworkQuery = useMemo(() => classifyNetworkQuery(q), [q]);
const isVrpLookup = network.kind === "ip" || network.kind === "prefix";
const searchQuery = useQuery({
queryKey: ["search", runId, q],
queryFn: () => searchRun(runId, q, 10),
enabled: q.trim().length > 0,
enabled: !isVrpLookup && q.trim().length > 0,
});
const onSubmit = (event: FormEvent) => {
@ -39,6 +160,7 @@ export default function SearchPage() {
const params = new URLSearchParams(searchParams);
if (draft.trim()) params.set("q", draft.trim());
else params.delete("q");
params.delete("asn");
setSearchParams(params, { preventScrollReset: true });
};
@ -52,7 +174,7 @@ export default function SearchPage() {
<div className="page">
<PageHeader
title="Search"
subtitle="Exact object URI, SHA-256 (full or ≥8 hex prefix), repository host, or publication point URI"
subtitle="Object URI, SHA-256, repository host, publication point URI — or an IP / prefix for VRP lookup"
/>
<form className="search-hero" role="search" onSubmit={onSubmit}>
@ -60,7 +182,7 @@ export default function SearchPage() {
type="search"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="rsync://… or 64-char sha256 or repository host…"
placeholder="rsync://… · sha256… · 81.68.1.1 · 2001:db8::/32 · AS45090"
aria-label="Search query"
autoFocus
/>
@ -69,19 +191,21 @@ export default function SearchPage() {
</button>
</form>
{q && looksLikeNetworkQuery(q) ? (
{network.kind === "asn" ? (
<Notice kind="info">
IP prefix and ASN lookup of VRPs requires backend feature #070 (VRP lookup),
which is not implemented yet. The results below only cover objects,
repositories and publication points.
The VRP API cannot list VRPs by ASN alone query an IP address or a
CIDR prefix first, then narrow the hits with the ASN filter. The
results below only cover objects, repositories and publication points.
</Notice>
) : null}
{!q ? (
<EmptyBlock
title="Enter a query"
hint="Paste an object URI to jump straight to its detail page, or a hash / host to find matching entities."
hint="Paste an object URI to jump straight to its detail page, an IP / prefix for a VRP lookup, or a hash / host to find matching entities."
/>
) : isVrpLookup ? (
<VrpResults network={network} />
) : searchQuery.isError ? (
<ErrorBlock error={searchQuery.error} onRetry={() => searchQuery.refetch()} title="Search failed" />
) : searchQuery.isPending ? (
@ -162,8 +286,9 @@ export default function SearchPage() {
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
<li><code>rsync://</code> / <code>https://</code> URI — exact object match</li>
<li>64 hex chars object by SHA-256; 8+ hex chars SHA-256 prefix scan</li>
<li>IPv4 / IPv6 address or CIDR prefix VRP lookup (covering VRPs), with optional ASN filter</li>
<li><code>AS</code> number alone not supported by the VRP API; combine with an IP or prefix</li>
<li>any other text substring match on repository host/URI and publication point URIs</li>
<li>IP / prefix / ASN VRP lookup is planned under backend feature #070</li>
</ul>
</Panel>
</div>

View File

@ -191,6 +191,23 @@ a.reason-row:hover {
font-size: 13px;
}
.vrp-asn-filter {
display: flex;
gap: var(--space-2);
align-items: center;
font-size: 12px;
color: var(--text-2);
}
.vrp-asn-filter input {
width: 180px;
padding: 5px 9px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-m);
font-family: var(--font-mono);
font-size: 12px;
}
/* Validation page */
.validation-summary {
display: grid;

View File

@ -319,3 +319,20 @@ export const HEALTH = {
};
export const SERVICE_INFO = { service: "rpki_query_service", version: 1 };
/**
* VRP lookup fixtures (backend feature #122). 61 rows match `ip=81.68.1.1`
* so the UI's 50-row page size paginates; the /16 row exercises covering
* semantics for prefix queries, the IPv6 row exercises the second family.
*/
export const VRPS = [
{ runId: "run_0002", asn: 45090, prefix: "81.68.0.0/14", maxLength: 14 },
{ runId: "run_0002", asn: 64496, prefix: "81.68.0.0/16", maxLength: 24 },
...Array.from({ length: 59 }, (_, i) => ({
runId: "run_0002",
asn: 64512 + i,
prefix: "81.68.0.0/14",
maxLength: 14,
})),
{ runId: "run_0002", asn: 0, prefix: "2001:7fa:0:1::/64", maxLength: 64 },
];

View File

@ -21,6 +21,7 @@ import {
STATS_REASONS,
STATS_VALIDATION,
VALIDATION_SUMMARY,
VRPS,
} from "./fixtures";
export interface MockJob {
@ -74,6 +75,63 @@ function paginate<T>(items: T[], url: URL, defaultLimit = 50): { page: T[]; next
return { page, next: nextOffset < items.length ? String(nextOffset) : null };
}
/** VRP lookup pagination with the backend's `vrp:<offset>` cursor format. */
function paginateVrp<T>(items: T[], url: URL): { page: T[]; next: string | null } {
const limit = Math.max(1, Number(url.searchParams.get("limit") ?? 50));
const raw = url.searchParams.get("cursor") ?? "";
const offset = raw.startsWith("vrp:") ? Number(raw.slice(4)) || 0 : 0;
const page = items.slice(offset, offset + limit);
const nextOffset = offset + limit;
return { page, next: nextOffset < items.length ? `vrp:${nextOffset}` : null };
}
function ipv4ToInt(addr: string): number | null {
const parts = addr.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
return ((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3];
}
/**
* Mock coverage semantics: VRP covers an IP when its prefix contains it;
* covers a queried prefix when the VRP prefix contains it (shorter or equal
* length) AND vrp.maxLength >= queried length. IPv6 uses a leading-hextet
* heuristic good enough for the fixture dataset.
*/
function vrpCovers(
vrp: { prefix: string; maxLength: number },
query: { ip?: string; prefix?: string },
): boolean {
const [net, lenRaw] = vrp.prefix.split("/");
const vlen = Number(lenRaw);
if (vrp.prefix.includes(":")) {
const head = net.split("::")[0].replace(/:$/, "");
const target = query.ip ?? query.prefix?.split("/")[0] ?? "";
if (!target.includes(":")) return false;
if (query.prefix) {
const qlen = Number(query.prefix.split("/")[1]);
if (!(vlen <= qlen && vrp.maxLength >= qlen)) return false;
}
return target === net || target.startsWith(`${head}:`);
}
if (query.ip) {
if (query.ip.includes(":")) return false;
const netInt = ipv4ToInt(net);
const ipInt = ipv4ToInt(query.ip);
if (netInt === null || ipInt === null) return false;
return vlen === 0 || netInt >>> (32 - vlen) === ipInt >>> (32 - vlen);
}
if (query.prefix) {
const [qnet, qlenRaw] = query.prefix.split("/");
const qlen = Number(qlenRaw);
const netInt = ipv4ToInt(net);
const qnetInt = ipv4ToInt(qnet);
if (netInt === null || qnetInt === null) return false;
if (!(vlen <= qlen && vrp.maxLength >= qlen)) return false;
return vlen === 0 || netInt >>> (32 - vlen) === qnetInt >>> (32 - vlen);
}
return false;
}
function applyObjectFilters(items: typeof OBJECTS, url: URL): typeof OBJECTS {
let out = items;
const type = url.searchParams.get("type")?.toLowerCase();
@ -280,6 +338,24 @@ export async function installMockApi(
return fulfillJson(route, pageItems, next);
}
if (section === "vrps" && a === "lookup") {
const ip = url.searchParams.get("ip") ?? undefined;
const prefix = url.searchParams.get("prefix") ?? undefined;
if (ip && prefix) return fulfillError(route, 400, "ip and prefix are mutually exclusive");
if (!ip && !prefix) return fulfillError(route, 400, "ip or prefix query parameter is required");
const asnRaw = url.searchParams.get("asn");
let items = VRPS.filter((v) => vrpCovers(v, { ip, prefix }));
if (asnRaw !== null) {
const asn = Number(asnRaw);
if (!Number.isInteger(asn) || asn < 0) {
return fulfillError(route, 400, `invalid ASN query parameter: ${asnRaw}`);
}
items = items.filter((v) => v.asn === asn);
}
const { page: vrpPage, next } = paginateVrp(items, url);
return fulfillJson(route, vrpPage, next);
}
if (section === "search") {
const q = (url.searchParams.get("q") ?? "").trim();
if (!q) return fulfillError(route, 400, "q query parameter is required");

View File

@ -48,9 +48,64 @@ test("search page: exact URI finds the object", async ({ page }) => {
await expect(page).toHaveURL(/\/objects\/obj-roa-0001/);
});
test("search page: ASN/prefix queries show the #070 capability notice", async ({ page }) => {
await page.goto("/search?q=AS65001");
await expect(page.getByRole("status").filter({ hasText: "feature #070" })).toBeVisible();
test("search page: IP query runs a VRP lookup with pagination", async ({ page }) => {
await page.goto("/search?q=81.68.1.1");
await expect(page.getByRole("heading", { name: "VRP lookup" })).toBeVisible();
// 61 matching fixture rows, page size 50 → first page shows row 1..50
await expect(page.getByRole("cell", { name: "AS45090" })).toBeVisible();
await expect(page.getByRole("cell", { name: "81.68.0.0/14" }).first()).toBeVisible();
await expect(page.getByText("Page 1 · 50 rows")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Page 2 · 11 rows")).toBeVisible();
await expect(page.getByRole("cell", { name: "AS64570" })).toBeVisible();
await page.getByRole("button", { name: "Previous" }).click();
await expect(page.getByText("Page 1 · 50 rows")).toBeVisible();
});
test("search page: prefix query applies covering semantics", async ({ page }) => {
// /14 VRPs have maxLength 14 < 16 and must NOT match a /16 query;
// only the 81.68.0.0/16 maxLength 24 VRP covers it.
await page.goto("/search?q=81.68.0.0/16");
await expect(page.getByRole("heading", { name: "VRP lookup" })).toBeVisible();
await expect(page.getByRole("cell", { name: "AS64496" })).toBeVisible();
await expect(page.getByRole("cell", { name: "81.68.0.0/16" })).toBeVisible();
await expect(page.getByRole("cell", { name: "/24" })).toBeVisible();
await expect(page.getByRole("cell", { name: "AS45090" })).toHaveCount(0);
});
test("search page: IPv6 query returns the v6 VRP", async ({ page }) => {
await page.goto("/search?q=2001:7fa:0:1::1");
await expect(page.getByRole("cell", { name: "AS0", exact: true })).toBeVisible();
await expect(page.getByRole("cell", { name: "2001:7fa:0:1::/64" })).toBeVisible();
});
test("search page: ASN filter narrows VRP results", async ({ page }) => {
await page.goto("/search?q=81.68.1.1");
await expect(page.getByRole("cell", { name: "AS45090" })).toBeVisible();
await page.getByLabel("ASN filter").fill("AS64496");
await page.getByRole("button", { name: "Apply" }).click();
await expect(page).toHaveURL(/asn=64496/);
await expect(page.getByRole("cell", { name: "AS64496" })).toBeVisible();
await expect(page.getByRole("cell", { name: "AS45090" })).toHaveCount(0);
await expect(page.getByText("Page 1 · 1 rows")).toBeVisible();
});
test("search page: invalid ASN filter shows an inline warning", async ({ page }) => {
await page.goto("/search?q=81.68.1.1");
await page.getByLabel("ASN filter").fill("AS123x");
await page.getByRole("button", { name: "Apply" }).click();
await expect(page.getByRole("status").filter({ hasText: "not a valid ASN" })).toBeVisible();
await expect(page).not.toHaveURL(/asn=/);
});
test("search page: ASN-only query shows the API limitation notice", async ({ page }) => {
await page.goto("/search?q=AS45090");
await expect(
page.getByRole("status").filter({ hasText: "cannot list VRPs by ASN alone" }),
).toBeVisible();
});
test("search page: empty result state", async ({ page }) => {

View File

@ -18,6 +18,8 @@ export default defineConfig(({ mode }) => {
}
},
preview: {
port: 5173,
strictPort: true,
proxy: {
"/api/v1": {
target: queryServiceTarget,