#!/usr/bin/env node // Regression gate for the server.js module-extraction refactor (see // /Users/renefichtmueller/.claude/plans/linked-riding-newell.md). Not shipped // to prod -- local dev tool only. // // Usage: // node server/__smoke__/compare-responses.js capture # save baseline (run once, before Phase A) // node server/__smoke__/compare-responses.js diff # run after every extraction step const http = require("http"); const fs = require("fs"); const path = require("path"); const { spawn, execFileSync } = require("child_process"); // server.js writes real snapshots into these git-tracked files at startup // (ASPA adoption history) and at runtime (hijack alerts, webhook subs) -- // snapshot + restore around every local boot so smoke-testing never leaves // lasting mutations on data that's supposed to hold genuine production // history, not local test noise. const REPO_ROOT = path.join(__dirname, "..", ".."); const MUTABLE_TRACKED_FILES = ["aspa-adoption-history.json", "hijack-alerts.json", "webhook-subs.json"]; function restoreTrackedFiles() { try { execFileSync("git", ["checkout", "--", ...MUTABLE_TRACKED_FILES], { cwd: REPO_ROOT, stdio: "ignore" }); } catch { // fine if a file has no uncommitted changes (git checkout on a clean path is a no-op anyway) } } const PORT = 3199; const BASE = `http://127.0.0.1:${PORT}`; const BASELINE_DIR = path.join(__dirname, "baseline"); const ASN = 13335; // Cloudflare -- stable, public, always has real data const ASN2 = 15169; // Google -- for compare/relationships // One representative, read-only (GET) request per route from the exploration // map. POST/DELETE mutating routes (feedback POST, webhooks POST/DELETE, // hijacks resolve) are intentionally excluded -- not idempotent-safe to // re-run repeatedly across every extraction step. const ENDPOINTS = [ ["root", "/"], ["search", `/api/search?q=cloudflare`], ["visitors", "/api/visitors"], ["health", "/api/health"], ["aspa-verify", `/api/aspa/verify?asn=${ASN}`], ["aspa-legacy", `/api/aspa?asn=${ASN}`], ["bgp", `/api/bgp?asn=${ASN}`], ["validate", `/api/validate?asn=${ASN}`], ["lookup", `/api/lookup?asn=${ASN}`], ["relationships", `/api/relationships?asn=${ASN}`], ["compare", `/api/compare?asn1=${ASN}&asn2=${ASN2}`], ["quick-ix", `/api/quick-ix?asn=${ASN}`], ["peers-find", `/api/peers/find?asn=${ASN}`], ["prefix-detail", "/api/prefix/detail?prefix=1.1.1.0/24"], ["ix-detail", "/api/ix/detail?asn=${ASN}"], ["topology", `/api/topology?asn=${ASN}`], ["whois", `/api/whois?asn=${ASN}`], ["enrich", `/api/enrich?asn=${ASN}`], ["changelog", "/changelog"], ["webhooks-list", "/api/webhooks"], ["hijacks-list", "/api/hijacks"], ["hijack-alerts", "/api/hijack-alerts"], ["aspa-adoption-page", "/aspa-adoption"], ["aspa-adoption-stats", "/api/aspa-adoption-stats?period=30d"], ["ipv6-adoption-stats", "/api/ipv6-adoption-stats"], ["atlas-coverage", "/api/atlas/coverage"], // proxy stubs -- expected to fail identically (peercortex-api not running // locally either); still worth diffing so the *error shape* stays stable ["submarine-cables", "/api/submarine-cables"], ["communities", "/api/communities?asn=" + ASN], ]; // Fields that legitimately change between runs -- excluded from the diff. // Two sources of real (non-bug) drift observed empirically by diffing an // unmodified server.js against itself: (1) process-local counters (uptime, // memory), and (2) live third-party data that changes minute-to-minute // (RIPE Atlas probe counts/IDs, Cloudflare RPKI feed size, health_score // insofar as it's derived from live external checks like MANRS that can // time out differently run to run). const NONDETERMINISTIC_KEY_PATTERN = /^(generated_at|timestamp|checked_at|fetched_at|query_time|cache_age|age_seconds|age_minutes|uptime_seconds|memory_mb|response_time_ms|duration_ms|last_updated|health_score|delta_last|history_samples|total_probes|total_connected|unique_asns_with_probes|atlas_asns_total|atlas_asns_with_aspa|coverage_pct_atlas|asns_with_probes|roa_count|aspa_objects)$/i; // Whole sub-trees excluded by dotted path (relative to the endpoint's body), // found empirically by diffing an unmodified server.js against itself twice. // Each of these depends on a live third-party call that can legitimately // time out or return fresh-but-different data run to run (RIPE Stat routing // status, RIS prefix history, PeeringDB unauthenticated rate limits, RIPE // Atlas probe churn, source-fetch timing diagnostics) -- none of it reflects // server.js's own logic, so a diff here is noise, not a regression signal. const VOLATILE_PATHS = new Set([ "lookup.source_timing", "validate.validations.visibility", // redundant summary of validations.* (already diffed in detail) that also // embeds the visibility check's flaky pass/info status "validate.score_breakdown", "prefix-detail.origins", "prefix-detail.first_seen", "aspa-adoption-stats.forecast", "quick-ix.ix_connections", "quick-ix.name", "atlas-coverage.by_country", // live Cloudflare RPKI feed object count ticks between runs; date-rollover // (midnight) shifts the adoption tracker's day-over-day delta too "health.caches.aspa_map.entries", "health.aspa_adoption.total_objects", "aspa-adoption-stats.current.delta_from_previous", // RIR delegation files (RIPE/ARIN/APNIC/AFRINIC/LACNIC) update near-real-time "ipv6-adoption-stats.global_ipv6_pct", "ipv6-adoption-stats.total_ipv4_records", "ipv6-adoption-stats.total_ipv6_records", "ipv6-adoption-stats.by_rir", // local hijack-alerts.json keeps accumulating real detector events between // runs (not a live third-party feed, but still grows independent of code // changes); the dashboard's byte length shifts with the daily rollover too "hijacks-list.total", "hijacks-list.events", "hijack-alerts.alerts", "aspa-adoption-page.bodyLength", ]); function stripNondeterministic(obj, pathPrefix) { if (Array.isArray(obj)) return obj.map((v) => stripNondeterministic(v, pathPrefix)); if (obj && typeof obj === "object") { const out = {}; for (const [k, v] of Object.entries(obj)) { if (NONDETERMINISTIC_KEY_PATTERN.test(k)) continue; const fullPath = pathPrefix ? `${pathPrefix}.${k}` : k; if (VOLATILE_PATHS.has(fullPath)) continue; out[k] = stripNondeterministic(v, fullPath); } return out; } return obj; } function fetchOne(name, pathAndQuery) { return new Promise((resolve) => { const req = http.get(BASE + pathAndQuery, { timeout: 20000 }, (res) => { let body = ""; res.on("data", (c) => (body += c)); res.on("end", () => { let parsed; try { parsed = JSON.parse(body); } catch { parsed = { __non_json__: true, status: res.statusCode, bodyLength: body.length }; } resolve({ status: res.statusCode, body: stripNondeterministic(parsed, name) }); }); }); req.on("timeout", () => { req.destroy(); resolve({ status: 0, body: { __timeout__: true } }); }); req.on("error", (err) => resolve({ status: 0, body: { __error__: err.message } })); }); } function waitForServer(maxWaitMs = 100000) { // Startup does real, sequential network fetches (Cloudflare RPKI/ROA feed // ~972k entries, full paginated RIPE Atlas probe list, IPv6 RIR delegation // stats) before calling server.listen() -- observed ~60s locally. const start = Date.now(); return new Promise((resolve, reject) => { const tryOnce = () => { const req = http.get(`${BASE}/api/health`, { timeout: 2000 }, (res) => { res.resume(); resolve(); }); req.on("error", () => { if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time")); setTimeout(tryOnce, 500); }); req.on("timeout", () => { req.destroy(); if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time")); setTimeout(tryOnce, 500); }); }; tryOnce(); }); } async function runAgainstLiveServer() { const results = {}; for (const [name, pathAndQuery] of ENDPOINTS) { results[name] = await fetchOne(name, pathAndQuery); } return results; } function leafDiffs(a, b, pathPrefix, out) { if (JSON.stringify(a) === JSON.stringify(b)) return; const bothPlainObjects = a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b); if (bothPlainObjects) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { leafDiffs(a[k], b[k], pathPrefix + "." + k, out); } } else { out.push(`${pathPrefix}: ${JSON.stringify(a)} -> ${JSON.stringify(b)}`); } } function diffResults(baseline, current) { const diffs = []; for (const [name] of ENDPOINTS) { const b = JSON.stringify(baseline[name]); const c = JSON.stringify(current[name]); if (b !== c) { const leaves = []; leafDiffs(baseline[name], current[name], name, leaves); diffs.push({ name, leaves }); } } return diffs; } async function main() { const mode = process.argv[2]; if (mode !== "capture" && mode !== "diff") { console.error("Usage: node compare-responses.js "); process.exit(1); } restoreTrackedFiles(); const serverEntry = path.join(__dirname, "..", "..", "server.js"); const child = spawn(process.execPath, [serverEntry], { cwd: path.join(__dirname, "..", ".."), env: { ...process.env, PORT: String(PORT), // Node 22's stricter header validation throws on Authorization: // undefined, which server.js's fetchPdbOrgCountries() produces when // PEERINGDB_API_KEY is unset (line ~6689). This is a real bug (tracked // separately, fixed during the pdb-org-countries.js extraction in // Phase B) but it blocks the server from even booting for local // testing -- work around it here at the test-harness level only, so // the baseline reflects real behavior rather than a crash. PEERINGDB_API_KEY: process.env.PEERINGDB_API_KEY || "smoke-test-placeholder", // Force the proxy-to-peercortex-api path to genuinely fail closed // instead of cross-talking with whatever unrelated dev server happens // to be listening on the real default port (3102) on this machine -- // observed proxying straight into an unrelated Next.js app's 404 page. API_SERVER_PORT: "39102", }, stdio: ["ignore", "pipe", "pipe"], }); let stderrBuf = ""; child.stderr.on("data", (c) => (stderrBuf += c)); const cleanup = () => { try { child.kill("SIGTERM"); } catch {} }; process.on("exit", cleanup); try { await waitForServer(); console.log(`Server up on port ${PORT}, running ${ENDPOINTS.length} requests...`); const results = await runAgainstLiveServer(); if (mode === "capture") { fs.mkdirSync(BASELINE_DIR, { recursive: true }); fs.writeFileSync(path.join(BASELINE_DIR, "baseline.json"), JSON.stringify(results, null, 2)); console.log(`Baseline captured: ${ENDPOINTS.length} endpoints -> ${BASELINE_DIR}/baseline.json`); } else { const baselinePath = path.join(BASELINE_DIR, "baseline.json"); if (!fs.existsSync(baselinePath)) { console.error("No baseline found -- run `capture` first."); process.exit(1); } const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8")); const diffs = diffResults(baseline, results); if (diffs.length === 0) { console.log(`PASS -- all ${ENDPOINTS.length} endpoints match baseline.`); } else { console.log(`FAIL -- ${diffs.length}/${ENDPOINTS.length} endpoint(s) differ from baseline:`); for (const d of diffs) { console.log(`\n=== ${d.name} (${d.leaves.length} leaf change(s)) ===`); for (const line of d.leaves.slice(0, 20)) console.log(" " + line); if (d.leaves.length > 20) console.log(` ... and ${d.leaves.length - 20} more`); } process.exitCode = 1; } } } catch (err) { console.error("Smoke test failed to run:", err.message); if (stderrBuf) console.error("--- server stderr ---\n" + stderrBuf.slice(0, 3000)); process.exitCode = 1; } finally { cleanup(); restoreTrackedFiles(); } } main();