20260718 Explorer 接入 VRP 检索查询
This commit is contained in:
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user