diff --git a/ui/rpki-explorer/README.md b/ui/rpki-explorer/README.md index 14c29ea..6ac8c88 100644 --- a/ui/rpki-explorer/README.md +++ b/ui/rpki-explorer/README.md @@ -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 diff --git a/ui/rpki-explorer/src/api/schemas.ts b/ui/rpki-explorer/src/api/schemas.ts index 21f2903..18c3348 100644 --- a/ui/rpki-explorer/src/api/schemas.ts +++ b/ui/rpki-explorer/src/api/schemas.ts @@ -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; export type ValidationSummary = z.infer; export type ExplainRecord = z.infer; export type SearchResult = z.infer; +export type VrpRecord = z.infer; export type HealthStatus = z.infer; export type ServiceInfo = z.infer; export type ManifestFileEntry = z.infer; diff --git a/ui/rpki-explorer/src/api/service.ts b/ui/rpki-explorer/src/api/service.ts index 76623bb..ccf4305 100644 --- a/ui/rpki-explorer/src/api/service.ts +++ b/ui/rpki-explorer/src/api/service.ts @@ -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> { + return getList(`${runBase(runId)}/vrps/lookup`, vrpRecordSchema, params); +} + /* ------------------------------------------------------------------ */ /* Stats */ /* ------------------------------------------------------------------ */ diff --git a/ui/rpki-explorer/src/lib/vrpQuery.test.ts b/ui/rpki-explorer/src/lib/vrpQuery.test.ts new file mode 100644 index 0000000..42f6570 --- /dev/null +++ b/ui/rpki-explorer/src/lib/vrpQuery.test.ts @@ -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" }); + }); +}); diff --git a/ui/rpki-explorer/src/lib/vrpQuery.ts b/ui/rpki-explorer/src/lib/vrpQuery.ts new file mode 100644 index 0000000..09f0d79 --- /dev/null +++ b/ui/rpki-explorer/src/lib/vrpQuery.ts @@ -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" }; +} diff --git a/ui/rpki-explorer/src/pages/ApiStatusPage.tsx b/ui/rpki-explorer/src/pages/ApiStatusPage.tsx index faa1e1a..e168102 100644 --- a/ui/rpki-explorer/src/pages/ApiStatusPage.tsx +++ b/ui/rpki-explorer/src/pages/ApiStatusPage.tsx @@ -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)" }, diff --git a/ui/rpki-explorer/src/pages/SearchPage.tsx b/ui/rpki-explorer/src/pages/SearchPage.tsx index 687f703..fd56b4e 100644 --- a/ui/rpki-explorer/src/pages/SearchPage.tsx +++ b/ui/rpki-explorer/src/pages/SearchPage.tsx @@ -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(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[] = [ + { + key: "asn", + header: "ASN", + render: (vrp) => AS{vrp.asn}, + }, + { + key: "prefix", + header: "Prefix", + render: (vrp) => {vrp.prefix}, + }, + { + key: "maxLength", + header: "Max length", + numeric: true, + render: (vrp) => /{vrp.maxLength}, + }, + ]; + + 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 + + + setAsnDraft(e.target.value)} + placeholder="e.g. AS45090 (optional)" + /> + + + } + > + {asnError ? {asnError} : null} + `${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}`} + /> + pager.goNext(vrpQuery.data?.nextCursor ?? null)} + itemCount={vrpQuery.data?.items.length} + /> + ); } @@ -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() {
@@ -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() {
- {q && looksLikeNetworkQuery(q) ? ( + {network.kind === "asn" ? ( - 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. ) : null} {!q ? ( + ) : isVrpLookup ? ( + ) : searchQuery.isError ? ( searchQuery.refetch()} title="Search failed" /> ) : searchQuery.isPending ? ( @@ -162,8 +286,9 @@ export default function SearchPage() {
  • rsync:// / https:// URI — exact object match
  • 64 hex chars — object by SHA-256; 8+ hex chars — SHA-256 prefix scan
  • +
  • IPv4 / IPv6 address or CIDR prefix — VRP lookup (covering VRPs), with optional ASN filter
  • +
  • AS number alone — not supported by the VRP API; combine with an IP or prefix
  • any other text — substring match on repository host/URI and publication point URIs
  • -
  • IP / prefix / ASN VRP lookup is planned under backend feature #070
diff --git a/ui/rpki-explorer/src/styles/pages.css b/ui/rpki-explorer/src/styles/pages.css index ce938b6..e1bd6d6 100644 --- a/ui/rpki-explorer/src/styles/pages.css +++ b/ui/rpki-explorer/src/styles/pages.css @@ -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; diff --git a/ui/rpki-explorer/tests/e2e/fixtures.ts b/ui/rpki-explorer/tests/e2e/fixtures.ts index 593cb9d..39ab90b 100644 --- a/ui/rpki-explorer/tests/e2e/fixtures.ts +++ b/ui/rpki-explorer/tests/e2e/fixtures.ts @@ -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 }, +]; diff --git a/ui/rpki-explorer/tests/e2e/mockApi.ts b/ui/rpki-explorer/tests/e2e/mockApi.ts index 23e1350..8cf4997 100644 --- a/ui/rpki-explorer/tests/e2e/mockApi.ts +++ b/ui/rpki-explorer/tests/e2e/mockApi.ts @@ -21,6 +21,7 @@ import { STATS_REASONS, STATS_VALIDATION, VALIDATION_SUMMARY, + VRPS, } from "./fixtures"; export interface MockJob { @@ -74,6 +75,63 @@ function paginate(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:` cursor format. */ +function paginateVrp(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"); diff --git a/ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts b/ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts index 5ea8e40..b5440b6 100644 --- a/ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts +++ b/ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts @@ -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 }) => { diff --git a/ui/rpki-explorer/vite.config.ts b/ui/rpki-explorer/vite.config.ts index e02aabd..b035ea4 100644 --- a/ui/rpki-explorer/vite.config.ts +++ b/ui/rpki-explorer/vite.config.ts @@ -18,6 +18,8 @@ export default defineConfig(({ mode }) => { } }, preview: { + port: 5173, + strictPort: true, proxy: { "/api/v1": { target: queryServiceTarget,