morphit/apps/indexer/scripts/rpc-probe-failure-reason-smoke.ts

182 lines
7.8 KiB
TypeScript

#!/usr/bin/env tsx
/*
* rpc-probe-failure-reason — cp471 (tt.txt C) guard.
*
* The settings "RPC endpoints" card used to render a flat red "unreachable"
* for EVERY failure mode, because `probeOne` collapsed TLS errors, non-2xx
* answers, JSON-RPC errors, DNS failures and timeouts into a bare `ok:false`.
*
* That is not a cosmetic problem: rpc.blurt.blog's operator confirmed to Ken
* that every request returns 200 and that he had just renovated the balancer
* certificate — i.e. the node was FINE and our probe was failing the TLS
* handshake, while the card blamed the node for being "unreachable" and sent
* Ken chasing the wrong problem.
*
* This sentinel locks the diagnosis chain:
* 1. classifyProbeError maps each real Node/undici error code to the right
* stable reason (the actual function, called directly — functional, not
* a source scan);
* 2. the probe still reports a reason for the answered-but-bad cases
* (non-2xx / JSON-RPC error / unparseable body) rather than silently
* dropping to "unreachable";
* 3. the PUBLIC vocabulary contains no 'cors' member — the probe is
* server-side, so CORS can never be the cause, and claiming it would be
* a lie to the operator reading the card;
* 4. the card maps every reason to its own one-liner and only falls back to
* the generic "unreachable" label as a default;
* 5. every reason key exists in all 10 locales.
*
* Revert any of it and this fails.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { classifyProbeError } from '../src/api/rpcHealth';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..', '..', '..');
let pass = 0;
let fail = 0;
function ok(msg: string): void {
pass++;
console.log(`${msg}`);
}
function bad(scope: string, msg: string): void {
fail++;
console.log(`${scope}: ${msg}`);
}
// ── 1. Functional: the classifier maps real error shapes ────────────
// Node's fetch throws TypeError('fetch failed') with the true cause on
// `err.cause.code`; an abort surfaces via `name`.
const CASES: ReadonlyArray<{ label: string; err: unknown; want: string }> = [
{ label: 'expired cert', err: { cause: { code: 'CERT_HAS_EXPIRED' } }, want: 'tls' },
{
label: 'untrusted chain',
err: { cause: { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' } },
want: 'tls'
},
{ label: 'wrong host on cert', err: { cause: { code: 'ERR_TLS_CERT_ALTNAME_INVALID' } }, want: 'tls' },
{ label: 'self-signed', err: { cause: { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' } }, want: 'tls' },
{ label: 'DNS miss', err: { cause: { code: 'ENOTFOUND' } }, want: 'dns' },
{ label: 'DNS temp fail', err: { cause: { code: 'EAI_AGAIN' } }, want: 'dns' },
{ label: 'refused', err: { cause: { code: 'ECONNREFUSED' } }, want: 'refused' },
{ label: 'abort/deadline', err: { name: 'AbortError' }, want: 'timeout' },
{ label: 'undici headers timeout', err: { cause: { code: 'UND_ERR_HEADERS_TIMEOUT' } }, want: 'timeout' },
{ label: 'connection reset', err: { cause: { code: 'ECONNRESET' } }, want: 'network' },
{ label: 'unknown error', err: new Error('boom'), want: 'network' }
];
let classifyBad = 0;
for (const c of CASES) {
const got = classifyProbeError(c.err);
if (got !== c.want) {
classifyBad++;
bad('classify', `${c.label}: expected '${c.want}', got '${got}'`);
}
}
if (classifyBad === 0) ok(`classifyProbeError: all ${CASES.length} real error shapes map correctly`);
// ── 2-4. Source guards on the probe + the card ──────────────────────
const health = readFileSync(resolve(REPO, 'apps/indexer/src/api/rpcHealth.ts'), 'utf8');
const flatHealth = health.replace(/\s+/g, ' ');
const card = readFileSync(resolve(REPO, 'apps/web/src/lib/components/EndpointList.svelte'), 'utf8');
const flatCard = card.replace(/\s+/g, ' ');
// The answered-but-bad cases must each carry their own reason.
for (const [reason, why] of [
['http', 'a non-2xx answer (e.g. a 403 from a WAF in front of the node)'],
['rpc_error', 'HTTP 200 with a JSON-RPC error'],
['bad_body', 'HTTP 200 with an unparseable body']
] as const) {
if (new RegExp(`reason: '${reason}'`).test(flatHealth)) {
ok(`probe reports '${reason}' for ${why}`);
} else {
bad('probe', `no longer reports '${reason}' — ${why} would collapse back into a flat "unreachable" (cp471, tt.txt C)`);
}
}
// The HTTP status must ride along, or "blocked by a security policy" is unprovable.
if (/httpStatus: res\.status/.test(flatHealth)) {
ok('probe carries the HTTP status alongside an http failure');
} else {
bad('probe', 'the HTTP status is no longer captured — the card cannot distinguish 403 (blocked) from 502 (bad gateway).');
}
// PRIVACY/honesty: no 'cors' in the public vocabulary.
// Strip comments first — the source deliberately EXPLAINS in prose that a
// 'cors' code can never exist here, and that explanation must not trip the
// guard. We only want real code (a union member / a switch case).
function stripComments(src: string): string {
return src
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '');
}
const codeHealth = stripComments(health).replace(/\s+/g, ' ');
const codeCard = stripComments(card).replace(/\s+/g, ' ');
if (/'cors'/.test(codeHealth) || /'cors'/.test(codeCard)) {
bad(
'vocabulary',
"a 'cors' failure reason appeared. The probe runs SERVER-SIDE (the browser never pings a node — privacy #1, locked by endpoint-error-classify-smoke), so CORS cannot be the cause and must never be shown."
);
} else {
ok("no 'cors' reason in the public vocabulary (the probe is server-side; CORS is impossible here)");
}
// The card maps each reason to its own line, with the generic label as default.
const CARD_KEYS = [
'err_timeout',
'err_tls',
'err_dns',
'err_refused',
'err_rpc',
'err_body',
'err_http',
'err_http_blocked',
'err_http_rate'
] as const;
const missingCard = CARD_KEYS.filter((k) => !flatCard.includes(`settings.endpoints.${k}`));
if (missingCard.length === 0) {
ok(`card renders a distinct one-liner for all ${CARD_KEYS.length} failure reasons`);
} else {
bad('card', `missing reason line(s): ${missingCard.join(', ')} — those failures would fall back to the useless flat label.`);
}
if (/default: return \$_\('settings\.endpoints\.unreachable'\)/.test(flatCard)) {
ok('card falls back to the plain "Unreachable" label only as the default');
} else {
bad('card', 'the generic "unreachable" fallback is gone — an unclassified failure would render blank.');
}
// ── 5. EVERY shipped locale carries every reason key ────────────────
// Derived from the on-disk locale files, never a hardcoded list: an inline
// array silently under-covers the day an 11th locale graduates, which is
// exactly what locale-source-of-truth-smoke forbids (it caught this).
const LOCALES = readdirSync(resolve(REPO, 'apps/web/src/lib/i18n/locales'))
.filter((f) => f.endsWith('.json') && !f.endsWith('_meta.json'))
.map((f) => f.slice(0, -'.json'.length))
.sort();
let localeBad = 0;
for (const loc of LOCALES) {
const json = JSON.parse(
readFileSync(resolve(REPO, `apps/web/src/lib/i18n/locales/${loc}.json`), 'utf8')
) as { settings?: { endpoints?: Record<string, unknown> } };
const ep = json.settings?.endpoints ?? {};
const missing = CARD_KEYS.filter((k) => typeof ep[k] !== 'string' || (ep[k] as string).length === 0);
if (missing.length > 0) {
localeBad++;
bad(`locale ${loc}`, `missing endpoint reason key(s): ${missing.join(', ')}`);
}
}
if (localeBad === 0) ok(`all ${LOCALES.length} locales carry every failure-reason string`);
console.log('\n' + '─'.repeat(56));
if (fail === 0) {
console.log(`✓ all ${pass} scenarios passed`);
process.exit(0);
} else {
console.log(`${fail} of ${pass + fail} scenarios FAILED`);
process.exit(1);
}