417 lines
16 KiB
TypeScript

/**
* 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 {
CHAIN_EDGES,
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<string, MockJob>;
seedJob: (partial?: Partial<MockJob>) => 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<T>(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:<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();
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<string, string> = { 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<MockApi> {
const jobs = new Map<string, MockJob>();
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> = {}): 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);
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") {
return fulfillJson(route, [], null);
}
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 };
}