341 lines
12 KiB
TypeScript
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 };
|
|
}
|