sipher/tests/integration/proxy-chain.ts
Nixyan 660c17b319 feat: add client-side identity system, rate limiting, proxy hardening, and full test suite
### Major changes

- **Client-side identity** — New session key store (`sessionKey.ts`) backed by
  `sessionStorage` with a module-level caching, a `crypto.subtle` cache, a `useIdentityLock`
  hook for decrypt-once signing, `followSignature.ts` for signed follows, and
  two new UI modals (`IdentityBackup.tsx`, `UnlockIdentityModal.tsx`).
  `CreateIdentity.tsx` is rewritten to generate BIP-39 mnemonics and encrypt the
  Ed25519 keypair with AES-256-GCM via PBKDF2 (600k iterations) before storing
  in IndexedDB.

- **Rate limiting** — New `rate-limit-config.ts` and `rate-limit.ts` provide a
  per-IP sliding-window rate limiter backed by Redis. All external-facing routes
  (`/discover`, `/discover/rotate/*`, `/proxy`, social API endpoints) now have
  conservative defaults wired into the custom HTTP server before requests reach
  Next.js handlers.

- **Proxy route hardening** — The `/proxy` route now enforces a 256 KB payload
  limit (HTTP 413), validates JSON before parsing, applies a per-origin rate
  limit (100 req/min), and imports the `blocks` table to reject requests from
  blocked servers.

- **Docker integration-test cluster** — New `Dockerfile`, `.dockerignore`, and
  `tests/docker-compose.yml` orchestrate three SiPher instances (A, B, C) plus
  shared PostgreSQL and Redis. Key generation (`generate-keys.ts`) and discovery
  setup (`setup-discovery.ts`) scripts automate cluster bootstrap. Three example
  env files document required per-instance configuration.

- **Full test suite overhaul** — Replaces the old attack/auth/discover/key/proxy
  tests with a structured suite:
  * `tests/federation/` — Keytools unit tests + key-rotation e2e test
  * `tests/proxy/` — Proxy relay e2e tests (single-server validation)
  * `tests/integration/` — Multi-instance integration tests for discover,
    proxy-chain relay, and federated post delivery via BullMQ
  * `tests/helpers/` — Reusable DB, identity, and auth-user utilities
  * Playwright config updated to match new file conventions
  * Unused helpers (`tests/helpers/queue.ts`) removed

- **Social plugin endpoints** — Rewritten `follows.ts`, `blocks.ts`, `mutes.ts`,
  and `posts.ts` with proper federation integration. `social.ts` gains helpers
  for looking up posts by federation URL.

### Minor changes

- **README** — Expanded from a 42-line stub to a full architecture guide with
  tables for every layer (auth, DB, queues, storage, real-time), API route
  documentation, setup instructions, environment variables, test coverage, and
  the updated roadmap.

- **Federation helpers** — `keytools.ts` refactors imports and cleans up the public surface.
  `fetch.ts`, `registry.ts`, and `proxy-helpers/federated-post.ts` pick up small
  improvements. `PostFederationSchema` simplifies its encryption type assertion.

- **Plugin infrastructure** — Oven plugin schema and server index gain minor
  refactors. Social client adds a `muteUser` method.

- **UI components** — `switch.tsx` and `tooltip.tsx` rewritten for Radix v2 /
  Tailwind 4; `accordion.tsx`, `dropdown-menu.tsx`, `form`, `button`, `card` get
  minor consistency fixes. `dialog.tsx` removes unused `DialogHeader`.

- **Server bootstrap** — `server.ts` imports DB schema before `instrumentation`
  for correct Drizzle initialization, rate-limiting routes are wired, and CORS
  allows federation origins. `auth.ts` regenerates Oven and social plugin schemas.

- **Dependencies** — Added `@noble/ciphers` and `@noble/hashes` (crypto
  primitives). Removed `@signalapp/libsignal-client`, `base58-js`, `nanostores`,
  `tweetnacl-util`, `dexie-react-hooks`, `socket.io-client`. Updated all Better
  Auth packages to 1.6.11, BullMQ to 5.76.10, and various dev deps across the
  board.

- **.gitignore** — Added `/audits` and `tests/docker/*.env` to prevent secret
  leakage.

- **DB schema** — `blocks` table imported in `src/lib/db/schema/index.ts`.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 09:48:42 -03:00

591 lines
22 KiB
TypeScript

/**
* Proxy chain integration test.
*
* Exercises the full A → B → C → B → A proxy relay against real federation
* instances. This test focuses on the encrypted-routing layer and uses
* `method: "PING"` envelopes so we can validate decrypt + signature + registry
* checks without provisioning end-user accounts (post / follow scenarios that
* require Better Auth users live in `federation-post-delivery.ts`, which now
* auto-creates its own users via Better Auth).
*
* Run inside the Docker test cluster:
*
* docker compose -f tests/docker-compose.yml run --rm test-runner \
* tests/integration/proxy-chain.ts \
* --proxy http://sipher-b:3001 --target http://sipher-c:3002
*
* `--proxy` and `--target` default to the docker service names if omitted.
*
* Tests:
* 1. Full proxy relay (A → B → C → B → A) round-trips a PING envelope.
* 2. Direct TARGETED (A → C) decrypts on C and echoes the nonce.
* 3. TARGETED from an unregistered sender → C rejects (sender trust enforced).
* 4. PROXY with mismatched signing key → B rejects (key match enforced).
* 5. PROXY from an unknown federation origin → B rejects (registry enforced).
* 6. Real failover: A's direct fetch to C fails → `federationFetch` falls back
* to B as proxy → the round-trip completes through the real proxy code path
* on every hop (no stubs, no manually-crafted envelopes from the script).
*/
import { serverRegistry } from "@/lib/db/schema";
import { federationFetch } from "@/lib/federation/fetch";
import { encryptPayload, fingerprintKey, signMessage } from "@/lib/federation/keytools";
import { config } from "dotenv";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import nacl from "tweetnacl";
import { createSipherUser } from "../helpers/auth-users";
config({ path: ".env.local" });
const FETCH_TIMEOUT_MS = 15_000;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface FedKeys {
signingPublicKey: string;
signingSecretKey: string;
encryptionPublicKey: string;
encryptionSecretKey: string;
}
function generateEnvKeyPair(): FedKeys {
const signing = nacl.sign.keyPair();
const encryption = nacl.box.keyPair();
return {
signingPublicKey: Buffer.from(signing.publicKey).toString("base64"),
signingSecretKey: Buffer.from(signing.secretKey).toString("base64"),
encryptionPublicKey: Buffer.from(encryption.publicKey).toString("base64"),
encryptionSecretKey: Buffer.from(encryption.secretKey).toString("base64"),
};
}
async function readErrorBody(response: Response): Promise<string> {
try {
const body = await response.json();
return body?.error ?? body?.message ?? JSON.stringify(body);
} catch {
try {
return await response.text();
} catch {
return response.statusText;
}
}
}
interface TestResult {
name: string;
passed: boolean;
message: string;
}
const results: TestResult[] = [];
function pass(name: string, message = "OK") {
console.log(`${name}`);
if (message !== "OK") console.log(` ${message}`);
results.push({ name, passed: true, message });
}
function fail(name: string, message: string) {
console.error(`${name}`);
console.error(` ${message}`);
results.push({ name, passed: false, message });
}
// ---------------------------------------------------------------------------
// Validate environment
// ---------------------------------------------------------------------------
const REQUIRED_ENV = [
"FEDERATION_PUBLIC_KEY",
"FEDERATION_PRIVATE_KEY",
"FEDERATION_ENCRYPTION_PUBLIC_KEY",
"FEDERATION_ENCRYPTION_PRIVATE_KEY",
"BETTER_AUTH_URL",
] as const;
const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
if (missing.length > 0) {
console.error("Missing required environment variables:");
missing.forEach((k) => console.error(` - ${k}`));
console.error("Run inside the docker test cluster (env_file: tests/docker/sipher-a.env).");
process.exit(1);
}
const ORIGIN = process.env.BETTER_AUTH_URL!;
const OWN_SIGNING_PUB = process.env.FEDERATION_PUBLIC_KEY!;
const OWN_ENCRYPTION_PUB = process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!;
// ---------------------------------------------------------------------------
// Parse arguments
// ---------------------------------------------------------------------------
function argAfter(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : undefined;
}
const proxyUrl = argAfter("--proxy") ?? "http://sipher-b:3001";
const targetUrl = argAfter("--target") ?? "http://sipher-c:3002";
console.log("Proxy chain test");
console.log(` Server A (us): ${ORIGIN}`);
console.log(` Server B (proxy): ${proxyUrl}`);
console.log(` Server C (target): ${targetUrl}`);
console.log(` A signing key: ${fingerprintKey(OWN_SIGNING_PUB).slice(0, 16)}`);
console.log(` A encryption key: ${fingerprintKey(OWN_ENCRYPTION_PUB).slice(0, 16)}`);
// ---------------------------------------------------------------------------
// 1. Discovery check
// ---------------------------------------------------------------------------
interface DiscoverResponse {
url: string;
publicKey: string;
encryptionPublicKey: string;
peers: { url: string; isHealthy: boolean }[];
}
console.log("\n── Discovery ────────────────────────────────────────────");
async function fetchDiscover(url: string, label: string): Promise<DiscoverResponse> {
try {
const res = await fetch(`${url}/discover`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
console.error(`${label} (${url}) returned ${res.status}: ${await readErrorBody(res)}`);
process.exit(1);
}
const body = (await res.json()) as DiscoverResponse;
console.log(` ${label}: ${body.url}`);
console.log(` signing: ${fingerprintKey(body.publicKey).slice(0, 16)}`);
console.log(` encryption: ${fingerprintKey(body.encryptionPublicKey).slice(0, 16)}`);
console.log(` peers: ${body.peers.length}`);
return body;
} catch (err) {
console.error(`Cannot reach ${label} at ${url}/discover: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
}
const proxyInfo = await fetchDiscover(proxyUrl, "B");
const targetInfo = await fetchDiscover(targetUrl, "C");
const aOnB = proxyInfo.peers.some((p) => p.url === ORIGIN);
const aOnC = targetInfo.peers.some((p) => p.url === ORIGIN);
console.log(` A registered on B: ${aOnB}`);
console.log(` A registered on C: ${aOnC}`);
if (!aOnB || !aOnC) {
console.error(
"\n A is not registered on at least one peer. Run mutual discovery first:\n" +
" docker compose -f tests/docker-compose.yml --profile setup up",
);
process.exit(1);
}
// ---------------------------------------------------------------------------
// 2. Full proxy relay: A → B → C → B → A
// ---------------------------------------------------------------------------
console.log("\n── Test: Full proxy relay (A → B → C → B → A) ─────────");
{
const testName = "full proxy relay";
try {
const nonce = crypto.randomUUID();
// Encrypt a PING payload with C's public key so C can decrypt and verify
// the full A→B→C→B→A crypto routing without needing real user data.
const innerPayload = JSON.stringify({ method: "PING", nonce, sender: ORIGIN });
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const proxyBody = {
method: "PROXY",
targetUrl: targetUrl + "/proxy",
publicSigningKey: OWN_SIGNING_PUB,
publicEncryptionKey: OWN_ENCRYPTION_PUB,
payload: encrypted,
};
const res = await fetch(`${proxyUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": ORIGIN,
"Origin": ORIGIN,
},
body: JSON.stringify(proxyBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status !== 200) {
fail(testName, `expected 200, got ${res.status}: ${JSON.stringify(body)}`);
} else if (body.method !== "PROXY_RESPONSE") {
fail(testName, `expected method=PROXY_RESPONSE, got ${body.method}`);
} else if (!body.payload) {
fail(testName, "response missing payload (B did not relay C's response)");
} else if (body.payload.method !== "PROXY_RESPONSE") {
fail(testName, `inner payload method=${body.payload.method}, expected PROXY_RESPONSE`);
} else if (body.payload.nonce !== nonce) {
fail(testName, `nonce mismatch: sent ${nonce}, C echoed ${body.payload.nonce}`);
} else {
pass(testName, `nonce round-trip OK (${nonce})`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 3. Direct TARGETED: A → C
// ---------------------------------------------------------------------------
console.log("\n── Test: Direct TARGETED (A → C) ────────────────────────");
{
const testName = "direct TARGETED to C";
try {
const nonce = crypto.randomUUID();
const innerPayload = JSON.stringify({ method: "PING", nonce, sender: ORIGIN });
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const res = await fetch(`${targetUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": ORIGIN,
"Origin": ORIGIN,
},
body: JSON.stringify({
method: "TARGETED",
payload: encrypted,
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status !== 200) {
fail(testName, `expected 200, got ${res.status}: ${JSON.stringify(body)}`);
} else if (body.method !== "PROXY_RESPONSE") {
fail(testName, `expected method=PROXY_RESPONSE, got ${body.method}`);
} else if (body.nonce !== nonce) {
fail(testName, `nonce mismatch: sent ${nonce}, C echoed ${body.nonce}`);
} else {
pass(testName, `nonce round-trip OK (${nonce}), C status: ${body.status}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 4. TARGETED rejection — unregistered sender → C
// ---------------------------------------------------------------------------
console.log("\n── Test: TARGETED from unregistered sender → C ─────────");
{
const testName = "reject unregistered TARGETED sender";
try {
const fakeOrigin = "https://totally-unknown-federation-" + crypto.randomUUID().slice(0, 8) + ".test";
const innerPayload = JSON.stringify({ method: "PING", nonce: crypto.randomUUID() });
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const res = await fetch(`${targetUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": fakeOrigin,
"Origin": fakeOrigin,
},
body: JSON.stringify({
method: "TARGETED",
payload: encrypted,
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status === 403 && body.code === "UNKNOWN_FEDERATION_SERVER_INTERACTION") {
pass(testName, `C correctly rejected: "${body.error}"`);
} else {
fail(testName, `expected 403/UNKNOWN_FEDERATION_SERVER_INTERACTION, got ${res.status}/${body.code}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 5. Sender validation — bad signing key
// ---------------------------------------------------------------------------
console.log("\n── Test: Sender validation (bad keys → B) ──────────────");
{
const testName = "reject mismatched signing key";
try {
const fakeKeys = generateEnvKeyPair();
const innerPayload = JSON.stringify({ method: "PING", nonce: crypto.randomUUID() });
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const proxyBody = {
method: "PROXY",
targetUrl: targetUrl + "/proxy",
publicSigningKey: fakeKeys.signingPublicKey,
publicEncryptionKey: OWN_ENCRYPTION_PUB,
payload: encrypted,
};
const res = await fetch(`${proxyUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": ORIGIN,
"Origin": ORIGIN,
},
body: JSON.stringify(proxyBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status === 403 && body.code === "INCORRECT_KEYS") {
pass(testName, `B correctly rejected: "${body.error}"`);
} else {
fail(testName, `expected 403/INCORRECT_KEYS, got ${res.status}/${body.code}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 6. Unknown sender
// ---------------------------------------------------------------------------
console.log("\n── Test: Unknown sender (→ B) ────────────────────────────");
{
const testName = "reject unknown sender";
try {
const unknownKeys = generateEnvKeyPair();
const unknownOrigin = "https://totally-unknown-federation-" + crypto.randomUUID().slice(0, 8) + ".test";
const innerPayload = JSON.stringify({ method: "PING", nonce: crypto.randomUUID() });
const targetEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const encrypted = encryptPayload(innerPayload, targetEncKey);
const proxyBody = {
method: "PROXY",
targetUrl: targetUrl + "/proxy",
publicSigningKey: unknownKeys.signingPublicKey,
publicEncryptionKey: unknownKeys.encryptionPublicKey,
payload: encrypted,
};
const res = await fetch(`${proxyUrl}/proxy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": unknownOrigin,
"Origin": unknownOrigin,
},
body: JSON.stringify(proxyBody),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const body = await res.json();
if (res.status === 403 && body.code === "UNKNOWN_FEDERATION_SERVER_INTERACTION") {
pass(testName, `B correctly rejected: "${body.error}"`);
} else {
fail(testName, `expected 403/UNKNOWN_FEDERATION_SERVER_INTERACTION, got ${res.status}/${body.code}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 7. Real failover via federationFetch
//
// Drives the full failover code path on A's side — not a manually crafted
// PROXY envelope from the test script. We invoke A's actual
// `federationFetch(url, { serverUrl, proxyFallback: true, ... })` against a
// deliberately unreachable hostname so the direct call fails with
// `DNS_BLOCKED` (the proxy-eligible failure class in the threat model). That
// triggers the proxy fallback, which:
//
// • picks B as a healthy proxy via the registry
// • encrypts the request to C's encryption key
// • POSTs a PROXY envelope to B (real B, with real signing/registry checks)
// • B forwards it as TARGETED to C (real C, full schema + signature checks)
// • C processes a real FEDERATE_FOLLOW with users that exist on both servers
//
// The round-trip ack comes back through B verbatim, proving the entire flow
// works without stubs, without script-crafted envelopes, and with the real
// failover trigger in `federationFetch`.
// ---------------------------------------------------------------------------
console.log("\n── Test: real failover (A → C direct FAILS → A → B → C proxy SUCCEEDS) ─");
{
const testName = "real failover via federationFetch";
// `sipher-unreachable.test` is allow-listed in DEV_ALLOWED_HOSTNAMES (so
// url-guard passes) but does not resolve via Docker DNS, producing
// ENOTFOUND → UNKNOWN/DNS_BLOCKED → proxy-eligible per the threat model.
// The path `/api/auth/social/follows` matters because C's TARGETED router
// branches on it to dispatch FEDERATE_FOLLOW.
const sabotagedUrl = "http://sipher-unreachable.test:9999/api/auth/social/follows";
const sabotagedOrigin = new URL(sabotagedUrl).origin;
// `attemptProxyRoute` puts the original failing URL into the inner
// encrypted payload (`innerPayload.targetUrl = url`). When C processes the
// FEDERATE_FOLLOW, it derives `following_server_url` from that URL — and
// the `follows.following_server_url` column has an FK to server_registry.
// In production both A and B would be reaching the *same* real URL for C,
// so the FK target is already in C's registry. Our test deliberately
// breaks A's path while keeping B's path intact, so we seed the sabotaged
// URL as a placeholder registry row on C just to satisfy the FK. The keys
// are dummies — they're never used (sender validation runs against A's
// real keys via X-Federation-Origin).
const cDbUrl = process.env.DATABASE_URL!.replace(/\/sipher_a(\?|$)/, "/sipher_c$1");
const cPool = new Pool({ connectionString: cDbUrl });
const cDb = drizzle(cPool, { schema: { serverRegistry } });
const dummyKeys = generateEnvKeyPair();
try {
await cDb.insert(serverRegistry).values({
id: crypto.randomUUID(),
url: sabotagedOrigin,
publicKey: dummyKeys.signingPublicKey,
encryptionPublicKey: dummyKeys.encryptionPublicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
isHealthy: true,
}).onConflictDoNothing();
console.log(" Provisioning Alice on A and Bob on C…");
const alice = await createSipherUser(ORIGIN, {
emailPrefix: "alice-failover",
usernamePrefix: "alice_fo",
});
const bob = await createSipherUser(targetUrl, {
emailPrefix: "bob-failover",
usernamePrefix: "bob_fo",
});
console.log(` Alice: ${alice.userId} Bob: ${bob.userId}`);
// Build the FEDERATE follow body C will ultimately receive after the
// proxy hop. Inner is signed by A so C's signature check passes, and
// encrypted to C's encryption key so only C can decrypt the envelope.
const innerFollow = {
following: {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
followerId: alice.userId,
followingId: bob.userId,
accepted: false,
followerServerUrl: ORIGIN,
},
federationUrl: ORIGIN,
method: "FEDERATE" as const,
};
const innerRaw = JSON.stringify(innerFollow);
const aSigningSecret = new Uint8Array(
Buffer.from(process.env.FEDERATION_PRIVATE_KEY!, "base64"),
);
const signature = signMessage(innerRaw, aSigningSecret);
const cEncKey = new Uint8Array(Buffer.from(targetInfo.encryptionPublicKey, "base64"));
const followEnvelope = encryptPayload(innerRaw, cEncKey);
const fedRequestBody = JSON.stringify({
method: "FEDERATE",
payload: followEnvelope,
signature,
});
const result = await federationFetch(sabotagedUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Federation-Origin": ORIGIN,
"X-Federation-Target": sabotagedUrl,
"Origin": ORIGIN,
},
body: fedRequestBody,
// Overrides `extractServerUrl` so the proxy peer forwards to C's
// real URL, not the sabotaged one — proves that the real C URL is
// what the proxy chain uses.
serverUrl: targetUrl,
proxyFallback: true,
timeout: 20_000,
});
if (!result.proxied) {
fail(testName, "federationFetch did NOT use proxy — the direct call to sipher-unreachable.test must have unexpectedly succeeded");
} else if (!result.response.ok) {
fail(testName, `proxy response status=${result.response.status}: ${await readErrorBody(result.response)}`);
} else {
const body = await result.response.json();
if (body.method !== "PROXY_RESPONSE") {
fail(testName, `expected method=PROXY_RESPONSE, got ${body.method}`);
} else if (!body.payload) {
fail(testName, "expected outer PROXY_RESPONSE.payload from B (relay envelope missing)");
} else if (body.payload.method !== "PROXY_RESPONSE") {
fail(testName, `expected payload.method=PROXY_RESPONSE from C, got ${body.payload?.method}`);
} else if (body.payload.status !== "acknowledged") {
fail(testName, `expected payload.status=acknowledged from C, got ${body.payload?.status}`);
} else {
pass(
testName,
`A → ${result.proxyPeer} (proxy) → ${targetUrl} succeeded after direct failed; ack from C carried back through B`,
);
}
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
} finally {
try {
await cDb.delete(serverRegistry).where(eq(serverRegistry.url, sabotagedOrigin));
} catch (cleanupErr) {
console.warn(` (cleanup) could not remove fake server row: ${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`);
}
await cPool.end();
}
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
const passed = results.filter((r) => r.passed);
const failed = results.filter((r) => !r.passed);
console.log("\n════════════════════════════════════════════════════════");
console.log(`Results: ${passed.length} passed, ${failed.length} failed out of ${results.length}`);
if (failed.length > 0) {
console.error("\nFailed tests:");
failed.forEach((f) => console.error(`${f.name}: ${f.message}`));
process.exit(1);
}
console.log("\nAll tests passed.");
process.exit(0);