341 lines
12 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,
} 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 };
}
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 === "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 };
}