/** * Mocked rpki_query_service for Playwright: intercepts every /api/v1 request * and answers from the fixture dataset. Filter/query params are honored so * tests can assert both request construction and rendered results. */ import type { Page, Route } from "@playwright/test"; import { CER_PROJECTION, CHAIN_EDGES, CRL_PROJECTION, CRL_REVOKED, EXPLAIN_RECORD, HEALTH, MFT_FILES, MFT_PROJECTION, OBJECTS, PPS, REPOS, ROA_PROJECTION, RUNS, RUN_2, SERVICE_INFO, STATS_OBJECT_TYPES, STATS_REASONS, STATS_VALIDATION, VALIDATION_SUMMARY, VRPS, } from "./fixtures"; export interface MockJob { jobId: string; runId: string; scope: string; status: string; createdAt: string; finishedAt: string | null; objectCount: number; bytesWritten: number; error: string | null; polls: number; } export interface MockApi { jobs: Map; seedJob: (partial?: Partial) => MockJob; } function envelope(data: unknown, nextCursor: string | null = null, limit = 100) { return { data, page: data === null || !Array.isArray(data) ? null : { nextCursor, limit }, meta: { runId: "run_0002", schemaVersion: 1 }, }; } function fulfillJson(route: Route, data: unknown, nextCursor: string | null = null, status = 200) { return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data, nextCursor)), }); } function fulfillError(route: Route, status: number, message: string) { return route.fulfill({ status, contentType: "application/json", body: JSON.stringify({ error: message }), }); } /** Offset-cursor pagination honoring ?limit / ?cursor. */ function paginate(items: T[], url: URL, defaultLimit = 50): { page: T[]; next: string | null } { const limit = Math.max(1, Number(url.searchParams.get("limit") ?? defaultLimit)); const offset = Number(url.searchParams.get("cursor") ?? 0); const page = items.slice(offset, offset + limit); const nextOffset = offset + limit; 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(); const result = url.searchParams.get("result")?.toLowerCase(); const rejected = url.searchParams.get("rejected"); const reason = url.searchParams.get("reason")?.toLowerCase(); const q = url.searchParams.get("q")?.toLowerCase(); if (type) { const alias: Record = { mft: "manifest", cer: "certificate", asa: "aspa" }; const wanted = alias[type] ?? type; out = out.filter((o) => o.objectType.toLowerCase() === wanted); } if (result) out = out.filter((o) => o.result.toLowerCase() === result); if (rejected === "true") out = out.filter((o) => o.rejected); if (rejected === "false") out = out.filter((o) => !o.rejected); if (reason) out = out.filter((o) => o.rejectReason?.toLowerCase().includes(reason)); if (q) out = out.filter((o) => o.uri.toLowerCase().includes(q)); return out; } export async function installMockApi( page: Page, options: { failLatestRun?: boolean } = {}, ): Promise { const jobs = new Map(); let jobSeq = 0; /** Simulate async completion: a running job completes on its second read. */ const advanceJob = (job: MockJob) => { job.polls += 1; if (job.polls >= 2 && job.status === "running") { job.status = "complete"; job.finishedAt = "2026-07-17T00:10:05Z"; job.objectCount = 1; job.bytesWritten = 2048; } }; const seedJob = (partial: Partial = {}): MockJob => { jobSeq += 1; const job: MockJob = { jobId: `job-${String(jobSeq).padStart(4, "0")}`, runId: "run_0002", scope: "object_set", status: "running", createdAt: "2026-07-17T00:10:00Z", finishedAt: null, objectCount: 0, bytesWritten: 0, error: null, polls: 0, ...partial, }; jobs.set(job.jobId, job); return job; }; await page.route("**/api/v1/**", async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname.replace(/^\/api\/v1\/?/, ""); const segments = path.split("/").filter(Boolean).map(decodeURIComponent); // Service-level if (segments.length === 0) return fulfillJson(route, SERVICE_INFO); if (path === "healthz") return fulfillJson(route, HEALTH); if (segments[0] === "runs" || segments[0] === "latest_run") { const isLatestAlias = segments[0] === "latest_run"; const rawRun = isLatestAlias ? "latest" : segments[1]; const rest = isLatestAlias ? [] : segments.slice(2); if (!isLatestAlias && segments.length === 1) { const { page: items, next } = paginate(RUNS, url); return fulfillJson(route, items, next); } const run = rawRun === "latest" || rawRun === "latest_run" ? RUN_2 : RUNS.find((r) => r.runId === rawRun); if (!run) return fulfillError(route, 404, "run not found"); if (isLatestAlias || rest.length === 0) { if (options.failLatestRun) return fulfillError(route, 500, "index unavailable"); return fulfillJson(route, run); } const [section, a, b, c] = rest; if (section === "artifacts") return fulfillJson(route, run.artifactPaths); if (section === "summary") { return fulfillJson(route, { publicationPoints: run.counts.publicationPoints, objects: run.counts.objects, repos: REPOS.length, vrps: run.counts.vrps, aspas: run.counts.aspas, }); } if (section === "repos" && !a) { const { page: items, next } = paginate(REPOS, url); return fulfillJson(route, items, next); } if (section === "repos" && a) { const repo = REPOS.find((r) => r.repoId === a); if (!repo) return fulfillError(route, 404, "repo not found"); if (!b) return fulfillJson(route, repo); if (b === "stats") { return fulfillJson(route, { publicationPoints: repo.publicationPoints, objects: repo.objects, rejectedObjects: repo.rejectedObjects, syncDurationMsTotal: repo.syncDurationMsTotal, phases: repo.phases, terminalStates: repo.terminalStates, }); } if (b === "publication-points") { const { page: items, next } = paginate(PPS.filter((p) => p.repoId === a), url); return fulfillJson(route, items, next); } if (b === "objects") { const filtered = applyObjectFilters(OBJECTS.filter((o) => o.repoId === a), url); const { page: items, next } = paginate(filtered, url); return fulfillJson(route, items, next); } } if (section === "publication-points" && !a) { const { page: items, next } = paginate(PPS, url); return fulfillJson(route, items, next); } if (section === "publication-points" && a) { const pp = PPS.find((p) => p.ppId === a); if (!pp) return fulfillError(route, 404, "publication point not found"); if (!b) return fulfillJson(route, pp); if (b === "stats") { return fulfillJson(route, { objects: pp.objects, rejectedObjects: pp.rejectedObjects, warnings: pp.warnings, repoSyncSource: pp.repoSyncSource, repoSyncPhase: pp.repoSyncPhase, repoSyncDurationMs: pp.repoSyncDurationMs, repoTerminalState: pp.repoTerminalState, }); } if (b === "objects") { const filtered = applyObjectFilters(OBJECTS.filter((o) => o.ppId === a), url); const { page: items, next } = paginate(filtered, url); return fulfillJson(route, items, next); } } if (section === "objects" && a === "by-uri") { const uri = url.searchParams.get("uri"); const obj = OBJECTS.find((o) => o.uri === uri); if (!obj) return fulfillError(route, 404, "object not found"); return fulfillJson(route, obj); } if (section === "objects" && !a) { const filtered = applyObjectFilters(OBJECTS, url); const { page: items, next } = paginate(filtered, url); return fulfillJson(route, items, next); } if (section === "objects" && a) { const obj = OBJECTS.find((o) => o.objectInstanceId === a); if (!obj) return fulfillError(route, 404, "object not found"); if (!b) return fulfillJson(route, obj); if (b === "parsed" && !c) { if (obj.objectType === "roa") return fulfillJson(route, ROA_PROJECTION); if (obj.objectType === "manifest") return fulfillJson(route, MFT_PROJECTION); if (obj.objectType === "certificate") return fulfillJson(route, CER_PROJECTION); if (obj.objectType === "crl") return fulfillJson(route, CRL_PROJECTION); return fulfillJson(route, null); } if (b === "parsed" && c === "manifest-files") { const { page: items, next } = paginate(MFT_FILES, url, 100); return fulfillJson(route, items, next); } if (b === "parsed" && c === "revoked-certs") { const { page: items, next } = paginate(obj.objectType === "crl" ? CRL_REVOKED : [], url, 100); return fulfillJson(route, items, next); } if (b === "validation" && !c) return fulfillJson(route, VALIDATION_SUMMARY); if (b === "validation" && c === "explain" && request.method() === "POST") { return fulfillJson(route, EXPLAIN_RECORD); } if (b === "chain") return fulfillJson(route, CHAIN_EDGES); if (b === "raw") { return route.fulfill({ status: 200, contentType: "application/octet-stream", body: Buffer.from([0x30, 0x82, 0x01, 0x01, 0x02, 0x01, 0x05]), }); } } if (section === "issues") { let items = OBJECTS.filter((o) => o.rejected || o.result === "error"); const reason = url.searchParams.get("reason")?.toLowerCase(); const type = url.searchParams.get("type")?.toLowerCase(); const repoId = url.searchParams.get("repoId"); if (reason) items = items.filter((o) => o.rejectReason?.toLowerCase().includes(reason)); if (type) items = items.filter((o) => o.objectType.toLowerCase() === type); if (repoId) items = items.filter((o) => o.repoId === repoId); const { page: pageItems, next } = paginate(items, url); 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"); const lower = q.toLowerCase(); const byUri = OBJECTS.find((o) => o.uri === q); const byHash = OBJECTS.find((o) => o.sha256 === lower); const objects = byUri ? [byUri] : byHash ? [byHash] : []; const repos = REPOS.filter( (r) => r.host.toLowerCase().includes(lower) || r.uri.toLowerCase().includes(lower), ); const pps = PPS.filter((p) => [p.manifestRsyncUri, p.publicationPointRsyncUri, p.rrdpNotificationUri] .filter(Boolean) .some((u) => u!.toLowerCase().includes(lower)), ); return fulfillJson(route, { objects, repos, publicationPoints: pps }); } if (section === "stats") { if (a === "validation") return fulfillJson(route, STATS_VALIDATION); if (a === "object-types") return fulfillJson(route, STATS_OBJECT_TYPES); if (a === "reasons") return fulfillJson(route, STATS_REASONS); if (a === "downloads") return fulfillJson(route, { requests: 42, bytes: 1234567 }); return fulfillJson(route, {}); } if (section === "exports" && !a) { if (request.method() === "POST") { const job = seedJob({ objectCount: 0 }); return fulfillJson(route, job); } const list = [...jobs.values()].sort((x, y) => y.createdAt.localeCompare(x.createdAt)); list.forEach(advanceJob); return fulfillJson(route, list, null); } if (section === "exports" && a) { const job = jobs.get(a); if (!job) return fulfillError(route, 404, "export job not found"); if (b === "download") { if (job.status !== "complete") return fulfillError(route, 409, "export not complete"); return route.fulfill({ status: 200, contentType: "application/x-tar", body: Buffer.from("mock-tar-bytes"), }); } advanceJob(job); return fulfillJson(route, job); } return fulfillError(route, 404, `no mock for ${url.pathname}`); } return fulfillError(route, 404, `no mock for ${url.pathname}`); }); return { jobs, seedJob }; }