20260718 Explorer 接入 VRP 检索查询

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

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 }) => {