sipher/tests/integration/discover.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

687 lines
24 KiB
TypeScript

/**
* Discover route integration test.
*
* Exercises `/discover` (`GET`, `POST REGISTER`, `POST DISCOVER`) on Server A
* using Server C from the federation cluster as the real remote peer — there
* is no stub layer. Every `federationFetch` the route makes against the peer
* lands on an actual sipher-c instance with real signing/encryption keys.
*
* Run inside the Docker test cluster:
*
* docker compose -f tests/docker-compose.yml run --rm test-runner \
* tests/integration/discover.ts --peer http://sipher-c:3002
*
* `--peer` defaults to `http://sipher-c:3002` if omitted.
*
* Coverage parity with the previous (deleted) `tests/federation/discover.e2e.ts`:
* 1. GET /discover returns own keys and only healthy peers ordered by lastSeen desc.
* 2. POST /discover rejects invalid JSON.
* 3. POST /discover rejects unknown method.
* 4. REGISTER rejects malformed signing-key length.
* 5. REGISTER rejects SSRF URL.
* 6. REGISTER returns 502 when peer is unreachable.
* 7. REGISTER rejects key mismatch vs remote GET /discover.
* 8. REGISTER rejects when URL already registered with different keys.
* 9. REGISTER happy path upserts the peer into the registry.
* 10. DISCOVER returns 404 when signing public key is unknown.
* 11. DISCOVER rejects blocked stored URL with 400.
* 12. DISCOVER returns 502 when stored peer is unreachable.
* 13. DISCOVER rejects invalid envelope (undecryptable ciphertext).
* 14. DISCOVER rejects fingerprint mismatch inside decrypted envelope.
* 15. DISCOVER rejects malformed envelope shape.
* 16. DISCOVER happy path confirms keys against the live peer.
*
* Whatever the test does to A's `server_registry`, it restores at the end so
* that subsequent integration tests inherit a working mesh.
*/
import db from "@/lib/db";
import { serverRegistry } from "@/lib/db/schema";
import { encryptPayload, fingerprintKey } from "@/lib/federation/keytools";
import { config } from "dotenv";
import { eq } from "drizzle-orm";
config({ path: ".env.local" });
const FETCH_TIMEOUT_MS = 15_000;
// ---------------------------------------------------------------------------
// Required env (test-runner uses sipher-a.env)
// ---------------------------------------------------------------------------
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!;
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function argAfter(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : undefined;
}
const peerUrl = argAfter("--peer") ?? "http://sipher-c:3002";
console.log("Discover route test");
console.log(` Server A (us): ${ORIGIN}`);
console.log(` Peer (real): ${peerUrl}`);
console.log(` A signing key: ${fingerprintKey(OWN_SIGNING_PUB).slice(0, 16)}`);
// ---------------------------------------------------------------------------
// Test harness (matches proxy-chain.ts so output is consistent across the suite)
// ---------------------------------------------------------------------------
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 });
}
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;
}
}
}
function randomKeyPair() {
// Anonymous helper — we deliberately use signed bytes of any size for
// "wrong key" tests so we don't accidentally rely on libsodium primitives
// matching the validity rules under test.
const bytes = (len: number) => Buffer.from(crypto.getRandomValues(new Uint8Array(len)));
return {
signingPublicKey: bytes(32).toString("base64"),
encryptionPublicKey: bytes(32).toString("base64"),
};
}
async function postDiscover(body: unknown, contentType = "application/json") {
return fetch(`${ORIGIN}/discover`, {
method: "POST",
headers: {
"Content-Type": contentType,
"X-Federation-Origin": ORIGIN,
"Origin": ORIGIN,
},
body: typeof body === "string" ? body : JSON.stringify(body),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
}
function ownEncryptionPublicKeyBytes(): Uint8Array {
return new Uint8Array(Buffer.from(OWN_ENCRYPTION_PUB, "base64"));
}
function buildDiscoverEnvelope(url: string, signingPub: string, encPub: string) {
const plaintext = JSON.stringify({
url,
publicKeyFingerprint: fingerprintKey(signingPub),
encryptionPublicKeyFingerprint: fingerprintKey(encPub),
});
return encryptPayload(plaintext, ownEncryptionPublicKeyBytes());
}
// ---------------------------------------------------------------------------
// Discover the live peer's keys once (sipher-c will return them via GET /discover)
// and capture A's pre-existing mesh entries so we can restore them at the end.
// ---------------------------------------------------------------------------
console.log("\n── Snapshotting cluster state ───────────────────────────");
interface PeerKeys {
publicKey: string;
encryptionPublicKey: string;
}
async function fetchPeerKeys(url: string): Promise<PeerKeys> {
const res = await fetch(`${url}/discover`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`GET ${url}/discover returned ${res.status}`);
}
const body = await res.json();
return { publicKey: body.publicKey, encryptionPublicKey: body.encryptionPublicKey };
}
const peerKeys = await fetchPeerKeys(peerUrl);
console.log(` Peer signing key: ${fingerprintKey(peerKeys.publicKey).slice(0, 16)}`);
console.log(` Peer encryption key: ${fingerprintKey(peerKeys.encryptionPublicKey).slice(0, 16)}`);
const meshSnapshot = await db.select().from(serverRegistry);
console.log(` Snapshotted ${meshSnapshot.length} existing registry entries.`);
async function restoreMesh() {
// Wipe everything, then re-insert the snapshot. Idempotent and safe to
// call even after partial failures inside individual tests.
await db.delete(serverRegistry);
for (const row of meshSnapshot) {
await db.insert(serverRegistry).values(row).onConflictDoNothing();
}
}
// ---------------------------------------------------------------------------
// 1. GET /discover returns own keys and only healthy peers, ordered by lastSeen desc
// ---------------------------------------------------------------------------
console.log("\n── Test: GET /discover ──────────────────────────────────");
{
const testName = "GET /discover orders healthy peers by lastSeen desc";
const newer = "http://discover-test-peer-newer.invalid";
const older = "http://discover-test-peer-older.invalid";
try {
await db.delete(serverRegistry).where(eq(serverRegistry.url, newer));
await db.delete(serverRegistry).where(eq(serverRegistry.url, older));
const k1 = randomKeyPair();
const k2 = randomKeyPair();
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url: newer,
publicKey: k1.signingPublicKey,
encryptionPublicKey: k1.encryptionPublicKey,
isHealthy: true,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url: older,
publicKey: k2.signingPublicKey,
encryptionPublicKey: k2.encryptionPublicKey,
isHealthy: true,
lastSeen: new Date(Date.now() - 120_000),
createdAt: new Date(Date.now() - 120_000),
updatedAt: new Date(Date.now() - 120_000),
});
const res = await fetch(`${ORIGIN}/discover`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
fail(testName, `GET /discover returned ${res.status}`);
} else {
const body = await res.json();
const peerUrls = body.peers.map((p: { url: string }) => p.url);
const newerIdx = peerUrls.indexOf(newer);
const olderIdx = peerUrls.indexOf(older);
if (body.url !== ORIGIN) {
fail(testName, `expected url=${ORIGIN}, got ${body.url}`);
} else if (body.publicKey !== OWN_SIGNING_PUB) {
fail(testName, "GET /discover did not echo own signing key");
} else if (newerIdx === -1 || olderIdx === -1) {
fail(testName, `seeded peers missing from response (newer=${newerIdx}, older=${olderIdx})`);
} else if (newerIdx > olderIdx) {
fail(testName, `newer peer (idx ${newerIdx}) should come before older (idx ${olderIdx})`);
} else {
// Health filter: mark older unhealthy and re-check.
await db.update(serverRegistry).set({ isHealthy: false }).where(eq(serverRegistry.url, older));
const res2 = await fetch(`${ORIGIN}/discover`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
const body2 = await res2.json();
const peerUrls2 = body2.peers.map((p: { url: string }) => p.url);
if (peerUrls2.includes(older)) {
fail(testName, "unhealthy peer should be filtered out but still appears");
} else if (!peerUrls2.includes(newer)) {
fail(testName, "healthy peer disappeared after toggling sibling unhealthy");
} else {
pass(testName, `${peerUrls.length} peers seen; ordering & healthy filter OK`);
}
}
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
} finally {
await db.delete(serverRegistry).where(eq(serverRegistry.url, newer));
await db.delete(serverRegistry).where(eq(serverRegistry.url, older));
}
}
// ---------------------------------------------------------------------------
// 2-3. POST /discover input validation
// ---------------------------------------------------------------------------
console.log("\n── Test: POST /discover input validation ────────────────");
{
const testName = "rejects invalid JSON";
try {
const res = await postDiscover("{not-json");
const body = await res.json();
if (res.status === 400 && body.code === "INVALID_JSON") {
pass(testName);
} else {
fail(testName, `expected 400/INVALID_JSON, got ${res.status}/${body.code}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "rejects unknown method";
try {
const res = await postDiscover({ method: "NOT_A_REAL_METHOD" });
if (res.status === 400) {
pass(testName, await readErrorBody(res));
} else {
fail(testName, `expected 400, got ${res.status}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 4-9. REGISTER scenarios
// ---------------------------------------------------------------------------
console.log("\n── Test: REGISTER ───────────────────────────────────────");
{
const testName = "REGISTER rejects malformed signing-key length";
try {
const res = await postDiscover({
method: "REGISTER",
url: peerUrl,
publicKey: Buffer.alloc(31).toString("base64"),
encryptionPublicKey: peerKeys.encryptionPublicKey,
});
if (res.status === 400) {
pass(testName);
} else {
fail(testName, `expected 400, got ${res.status}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "REGISTER rejects SSRF URL";
try {
const k = randomKeyPair();
const res = await postDiscover({
method: "REGISTER",
url: "http://10.0.0.1/",
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
});
if (res.status === 400) {
pass(testName);
} else {
fail(testName, `expected 400, got ${res.status}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "REGISTER returns 502 when peer is unreachable";
try {
const k = randomKeyPair();
// sipher-unreachable.test is allow-listed in DEV_ALLOWED_HOSTNAMES but
// fails DNS — the same trick the proxy-chain failover test uses.
const res = await postDiscover({
method: "REGISTER",
url: "http://sipher-unreachable.test:9999/",
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
});
const body = await res.json();
if (res.status === 502) {
pass(testName, `code=${body.code}`);
} else {
fail(testName, `expected 502, got ${res.status}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "REGISTER rejects key mismatch vs remote GET /discover";
try {
const wrong = randomKeyPair();
const res = await postDiscover({
method: "REGISTER",
url: peerUrl,
publicKey: wrong.signingPublicKey,
encryptionPublicKey: wrong.encryptionPublicKey,
});
const body = await res.json();
if (res.status === 400 && /Public keys do not match/i.test(body.error)) {
pass(testName, body.error);
} else {
fail(testName, `expected 400 with "Public keys do not match", got ${res.status}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "REGISTER rejects URL already registered with different keys";
try {
// Pre-state: replace A's registry entry for the peer with wrong keys
// so the route's "existing registration with mismatched key" branch
// fires. We restore the correct entry from the mesh snapshot at the
// end of the file.
const wrong = randomKeyPair();
await db
.update(serverRegistry)
.set({ publicKey: wrong.signingPublicKey, encryptionPublicKey: wrong.encryptionPublicKey })
.where(eq(serverRegistry.url, peerUrl));
const res = await postDiscover({
method: "REGISTER",
url: peerUrl,
publicKey: peerKeys.publicKey,
encryptionPublicKey: peerKeys.encryptionPublicKey,
});
const body = await res.json();
if (res.status === 400 && /key rotation flow/i.test(body.error)) {
pass(testName, body.error);
} else {
fail(testName, `expected 400 mentioning "key rotation flow", got ${res.status}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
} finally {
// Restore the correct entry before the next test relies on it.
await db
.update(serverRegistry)
.set({ publicKey: peerKeys.publicKey, encryptionPublicKey: peerKeys.encryptionPublicKey })
.where(eq(serverRegistry.url, peerUrl));
}
}
{
const testName = "REGISTER happy path upserts the peer into the registry";
try {
// Drop A's entry for the peer first so we genuinely exercise the
// insert path (the route upserts, but seeing an unchanged row would
// be unconvincing). The mesh snapshot will restore it at the end.
await db.delete(serverRegistry).where(eq(serverRegistry.url, peerUrl));
const res = await postDiscover({
method: "REGISTER",
url: peerUrl,
publicKey: peerKeys.publicKey,
encryptionPublicKey: peerKeys.encryptionPublicKey,
});
const body = await res.json();
if (res.status !== 200) {
fail(testName, `expected 200, got ${res.status}: ${JSON.stringify(body)}`);
} else {
const row = (await db.select().from(serverRegistry).where(eq(serverRegistry.url, peerUrl)))[0];
if (!row) {
fail(testName, "registry row missing after REGISTER 200");
} else if (row.publicKey !== peerKeys.publicKey) {
fail(testName, "registry signing key mismatch after REGISTER");
} else if (row.encryptionPublicKey !== peerKeys.encryptionPublicKey) {
fail(testName, "registry encryption key mismatch after REGISTER");
} else if (body.echo?.publicKey !== OWN_SIGNING_PUB) {
fail(testName, "REGISTER response did not echo own signing key");
} else {
pass(testName, `peer ${peerUrl} registered with real keys from live GET /discover`);
}
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// 10-16. DISCOVER scenarios
// ---------------------------------------------------------------------------
console.log("\n── Test: DISCOVER ───────────────────────────────────────");
{
const testName = "DISCOVER returns 404 when signing public key is unknown";
try {
const k = randomKeyPair();
const envelope = buildDiscoverEnvelope("http://unused.invalid", k.signingPublicKey, k.encryptionPublicKey);
const res = await postDiscover({
method: "DISCOVER",
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
envelope,
});
if (res.status === 404) {
pass(testName);
} else {
fail(testName, `expected 404, got ${res.status}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "DISCOVER rejects blocked stored URL with 400";
const blockedUrl = "http://10.0.0.2:999/";
const k = randomKeyPair();
try {
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url: blockedUrl,
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
isHealthy: true,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
const envelope = buildDiscoverEnvelope(blockedUrl, k.signingPublicKey, k.encryptionPublicKey);
const res = await postDiscover({
method: "DISCOVER",
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
envelope,
});
const body = await res.json();
if (res.status === 400 && /stored server URL is blocked/i.test(body.error)) {
pass(testName, body.error);
} else {
fail(testName, `expected 400 "blocked", got ${res.status}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
} finally {
await db.delete(serverRegistry).where(eq(serverRegistry.url, blockedUrl));
}
}
{
const testName = "DISCOVER returns 502 when stored peer is unreachable";
const deadUrl = "http://sipher-unreachable.test:9999/";
const k = randomKeyPair();
try {
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url: deadUrl,
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
isHealthy: true,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
const envelope = buildDiscoverEnvelope(deadUrl, k.signingPublicKey, k.encryptionPublicKey);
const res = await postDiscover({
method: "DISCOVER",
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
envelope,
});
const body = await res.json();
if (res.status === 502) {
pass(testName, `code=${body.code}`);
} else {
fail(testName, `expected 502, got ${res.status}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
} finally {
await db.delete(serverRegistry).where(eq(serverRegistry.url, deadUrl));
}
}
{
const testName = "DISCOVER rejects invalid envelope (undecryptable ciphertext)";
try {
// The peer entry for the real cluster peer is already in registry, but
// the envelope check happens during zod validation BEFORE any peer fetch,
// so the test never depends on the peer being reachable.
const goodEnvelope = buildDiscoverEnvelope(peerUrl, peerKeys.publicKey, peerKeys.encryptionPublicKey);
const broken = {
...goodEnvelope,
ciphertext: Buffer.alloc(Buffer.from(goodEnvelope.ciphertext, "base64").length, 0).toString("base64"),
};
const res = await postDiscover({
method: "DISCOVER",
publicKey: peerKeys.publicKey,
encryptionPublicKey: peerKeys.encryptionPublicKey,
envelope: broken,
});
const body = await res.json();
if (res.status === 400 && /Invalid envelope/i.test(body.error)) {
pass(testName, body.error);
} else {
fail(testName, `expected 400 "Invalid envelope", got ${res.status}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "DISCOVER rejects fingerprint mismatch inside decrypted envelope";
try {
const plaintext = JSON.stringify({
url: peerUrl,
publicKeyFingerprint: "deadbeef",
encryptionPublicKeyFingerprint: fingerprintKey(peerKeys.encryptionPublicKey),
});
const envelope = encryptPayload(plaintext, ownEncryptionPublicKeyBytes());
const res = await postDiscover({
method: "DISCOVER",
publicKey: peerKeys.publicKey,
encryptionPublicKey: peerKeys.encryptionPublicKey,
envelope,
});
const body = await res.json();
if (res.status === 400 && /signing public key/i.test(body.error)) {
pass(testName, body.error);
} else {
fail(testName, `expected 400 mentioning signing public key, got ${res.status}: ${JSON.stringify(body)}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "DISCOVER rejects malformed envelope shape";
try {
const k = randomKeyPair();
const res = await postDiscover({
method: "DISCOVER",
publicKey: k.signingPublicKey,
encryptionPublicKey: k.encryptionPublicKey,
envelope: { ephemeralPublicKey: "AA", iv: "AA", ciphertext: "AA" /* missing authTag */ },
});
if (res.status === 400) {
pass(testName);
} else {
fail(testName, `expected 400, got ${res.status}`);
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
{
const testName = "DISCOVER happy path confirms keys against the live peer";
try {
const envelope = buildDiscoverEnvelope(peerUrl, peerKeys.publicKey, peerKeys.encryptionPublicKey);
const res = await postDiscover({
method: "DISCOVER",
publicKey: peerKeys.publicKey,
encryptionPublicKey: peerKeys.encryptionPublicKey,
envelope,
});
const body = await res.json();
if (res.status !== 200) {
fail(testName, `expected 200, got ${res.status}: ${JSON.stringify(body)}`);
} else if (body.sameKeyOnServer !== true || body.sameKeyOnFetch !== true) {
fail(testName, `expected both confirmations true, got ${JSON.stringify(body)}`);
} else {
pass(testName, "live peer GET /discover confirmed local registry keys");
}
} catch (err) {
fail(testName, `${err instanceof Error ? err.message : err}`);
}
}
// ---------------------------------------------------------------------------
// Restore cluster mesh state and report
// ---------------------------------------------------------------------------
await restoreMesh();
console.log("\n Restored original mesh snapshot.");
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);