OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / test / test_phase5.cpp

592 lines · cpp ref:
// Phase 5 tests: Multi-Topic discovery, session counting, refresh timing,
// connection deduplication, NatSampler firewall classification.
//
// Build: see build_tests.sh (test_phase5 target)
// Run: ./test_phase5

#include "compact_encoding.h"
#include "dht_connector.h"
#include "dht_messages.h"
#include "holepuncher.h"
#include "hyperdht.h"
#include "nat_sampler.h"
#include "noise_ik.h"

#include <cstdio>
#include <cstring>
#include <sodium.h>

extern "C" {
#include <udx.h>
#include <uv.h>
}

// ---- Test harness ----

static int tests_run = 0;
static int tests_passed = 0;
static int tests_failed = 0;

static void check(bool cond, const char *name) {
	tests_run++;
	if (cond) {
		tests_passed++;
		printf("  PASS: %s\n", name);
	} else {
		tests_failed++;
		printf("  FAIL: %s\n", name);
	}
}

// ---- Helper: create a mock HyperDht (no real network) ----

static DhtRpc *create_mock_rpc(uv_loop_t *loop, udx_t *udx, udx_socket_t *sock) {
	udx_socket_init(udx, sock, nullptr);
	struct sockaddr_in bind_addr;
	uv_ip4_addr("127.0.0.1", 0, &bind_addr);
	udx_socket_bind(sock, (struct sockaddr *)&bind_addr, 0);

	DhtRpc *rpc = new DhtRpc();
	rpc->init(sock, loop);
	return rpc;
}

// ---- Topic join/leave tests ----

static void test_topic_join_leave() {
	printf("\n--- test_topic_join_leave ---\n");

	uv_loop_t loop;
	uv_loop_init(&loop);
	udx_t udx;
	udx_init(&loop, &udx, nullptr);
	udx_socket_t sock;
	DhtRpc *rpc = create_mock_rpc(&loop, &udx, &sock);

	Ed25519KeyPair kp;
	crypto_sign_keypair(kp.public_key, kp.secret_key);

	HyperDht dht;
	dht.init(rpc, kp);

	// Create a topic.
	uint8_t topic[32];
	randombytes_buf(topic, 32);

	check(dht.topic_count() == 0, "no topics initially");

	// Join as server+client.
	bool ok = dht.join_topic(topic, true, true);
	check(ok, "join_topic returns true");
	check(dht.topic_count() == 1, "one topic after join");

	// Verify discovery state.
	const TopicDiscovery *td = dht.find_topic(topic);
	check(td != nullptr, "find_topic returns discovery");
	check(td->server_sessions == 1, "server_sessions == 1");
	check(td->client_sessions == 1, "client_sessions == 1");
	check(td->active, "discovery is active");

	// Leave the topic.
	dht.leave_topic(topic);
	check(dht.topic_count() == 0, "no topics after leave");

	// find_topic should return null.
	td = dht.find_topic(topic);
	check(td == nullptr, "find_topic returns null after leave");

	delete rpc;
	udx_socket_close(&sock);
	uv_run(&loop, UV_RUN_NOWAIT);
	uv_loop_close(&loop);
}

static void test_topic_session_counting() {
	printf("\n--- test_topic_session_counting ---\n");

	uv_loop_t loop;
	uv_loop_init(&loop);
	udx_t udx;
	udx_init(&loop, &udx, nullptr);
	udx_socket_t sock;
	DhtRpc *rpc = create_mock_rpc(&loop, &udx, &sock);

	Ed25519KeyPair kp;
	crypto_sign_keypair(kp.public_key, kp.secret_key);

	HyperDht dht;
	dht.init(rpc, kp);

	uint8_t topic[32];
	randombytes_buf(topic, 32);

	// Join twice (two sessions).
	dht.join_topic(topic, true, true);
	dht.join_topic(topic, true, false);
	check(dht.topic_count() == 1, "still one topic discovery");

	const TopicDiscovery *td = dht.find_topic(topic);
	check(td != nullptr, "discovery exists");
	check(td->server_sessions == 2, "server_sessions == 2 after two joins");
	check(td->client_sessions == 1, "client_sessions == 1 (second join was server-only)");

	// First leave: decrements one of each.
	dht.leave_topic(topic);
	td = dht.find_topic(topic);
	check(td != nullptr, "discovery still exists after one leave");
	check(td->server_sessions == 1, "server_sessions == 1 after one leave");
	check(td->client_sessions == 0, "client_sessions == 0 after one leave");

	// Second leave: server drops to 0, all gone → discovery removed.
	dht.leave_topic(topic);
	check(dht.topic_count() == 0, "no topics after all leaves");

	delete rpc;
	udx_socket_close(&sock);
	uv_run(&loop, UV_RUN_NOWAIT);
	uv_loop_close(&loop);
}

static void test_topic_refresh_timing() {
	printf("\n--- test_topic_refresh_timing ---\n");

	uv_loop_t loop;
	uv_loop_init(&loop);
	udx_t udx;
	udx_init(&loop, &udx, nullptr);
	udx_socket_t sock;
	DhtRpc *rpc = create_mock_rpc(&loop, &udx, &sock);

	Ed25519KeyPair kp;
	crypto_sign_keypair(kp.public_key, kp.secret_key);

	HyperDht dht;
	dht.init(rpc, kp);

	uint8_t topic[32];
	randombytes_buf(topic, 32);

	dht.join_topic(topic, true, true);

	// Simulate time passing: first tick sets up refresh schedule.
	uint64_t now = 1000000; // Start at 1M ms.
	dht.tick(now);

	const TopicDiscovery *td = dht.find_topic(topic);
	check(td != nullptr, "discovery exists");
	check(td->refresh_at > now, "refresh_at is in the future");

	uint64_t refresh_at = td->refresh_at;

	// ⛔ THIS TOPIC IS ON THE EARLY-RETRY SCHEDULE, NOT THE 10-MINUTE ONE.
	//
	// It was joined with client=true and has seen no peers, which is exactly
	// fadee53's condition: a topic looking for peers and finding none retries
	// from 2s rather than in ten minutes, because two peers joining a session
	// seconds apart can BOTH lose the first race and then neither retries for
	// ten minutes (hyper-games #68 part C, measured 1-in-3 on the public DHT).
	//
	// This assertion predates that change and asserted the old cadence. See
	// docs/PARITY-hyperswarm.md 8.4.
	uint64_t retry_max = now + TOPIC_DISCOVERY_RETRY_MS + 1000; // + sub-second jitter
	check(refresh_at > now && refresh_at <= retry_max,
		  "refresh_at is on the early-retry schedule while no peers are known");

	// Tick just before refresh — nothing should change.
	dht.tick(refresh_at - 1);
	td = dht.find_topic(topic);
	check(td->refresh_at == refresh_at, "refresh_at unchanged before due time");

	// Tick at refresh time — should reschedule.
	dht.tick(refresh_at);
	td = dht.find_topic(topic);
	check(td->refresh_at > refresh_at, "refresh_at advanced after tick at due time");
	check(td->last_refresh == refresh_at, "last_refresh updated");

	// Test overdue detection (laptop wake grace period).
	// Simulate 15 minutes passing (well beyond the scheduled refresh + grace).
	uint64_t sleep_wakeup = td->refresh_at + TOPIC_REFRESH_GRACE_MS + 60000;
	dht.tick(sleep_wakeup);
	td = dht.find_topic(topic);
	// After overdue detection, refresh_at should be rescheduled eagerly (jitter only).
	check(td->refresh_at <= sleep_wakeup + TOPIC_REFRESH_JITTER_MS,
		  "overdue: rescheduled eagerly (jitter only)");

	dht.leave_topic(topic);

	delete rpc;
	udx_socket_close(&sock);
	uv_run(&loop, UV_RUN_NOWAIT);
	uv_loop_close(&loop);
}

// fadee53's early-retry schedule, which had NO test. hyper-games #68 recorded
// it explicitly: "The failure is measured. The RELIABILITY OF THE FIX IS NOT...
// Do not treat C as closed." These are the four properties its own comment
// claims, each asserted separately so a regression names which one broke.
static void test_topic_discovery_retry_schedule() {
	printf("\n--- Topic Discovery Retry Schedule (fadee53) ---\n");

	uv_loop_t loop;
	uv_loop_init(&loop);
	udx_t udx;
	udx_init(&loop, &udx, nullptr);
	udx_socket_t sock;
	DhtRpc *rpc = create_mock_rpc(&loop, &udx, &sock);

	Ed25519KeyPair kp;
	crypto_sign_keypair(kp.public_key, kp.secret_key);

	// 1. A SERVER-ONLY topic keeps the JS cadence. The deviation is deliberately
	//    narrow -- a pure server has nothing to look up, so retrying early would
	//    buy nothing and cost queries.
	{
		HyperDht dht;
		dht.init(rpc, kp);
		uint8_t topic[32];
		randombytes_buf(topic, 32);
		dht.join_topic(topic, true, false);   // server only
		uint64_t now = 1000000;
		dht.tick(now);
		const TopicDiscovery *td = dht.find_topic(topic);
		check(td != nullptr, "server-only discovery exists");
		check(td->refresh_at >= now + TOPIC_REFRESH_INTERVAL_MS,
			  "server-only topic stays on the 10-minute cadence");
	}

	// 2. A CLIENT topic with no peers retries early, and the backoff DOUBLES.
	{
		HyperDht dht;
		dht.init(rpc, kp);
		uint8_t topic[32];
		randombytes_buf(topic, 32);
		dht.join_topic(topic, false, true);   // client only
		uint64_t now = 1000000;
		dht.tick(now);
		const TopicDiscovery *td = dht.find_topic(topic);
		check(td != nullptr, "client discovery exists");

		uint64_t first = td->refresh_at - now;
		check(first <= TOPIC_DISCOVERY_RETRY_MS + 1000,
			  "first retry is ~2s, not 10 minutes");

		// Walk the schedule forward and watch the delay grow, then flatten at
		// the ceiling. Asserting the CEILING matters as much as the growth: an
		// unbounded shift would eventually overflow the delay.
		// ⛔ "SOMETHING GOT BIGGER" IS NOT A TEST OF DOUBLING.
		// The first version of this asserted `delay > prev` for any step, and it
		// PASSED with the early-retry block mutated out -- because the 10-minute
		// cadence carries up to 2 minutes of random jitter, so some later delay
		// exceeds some earlier one by chance. It certified the bug. Assert the
		// doubling itself: step N must be about 2^N * 2s until the ceiling.
		uint64_t delays[8];
		bool capped = true;
		for (int i = 0; i < 8; i++) {
			now = td->refresh_at;
			dht.tick(now);
			delays[i] = td->refresh_at - now;
			if (delays[i] > TOPIC_DISCOVERY_RETRY_MAX_MS + 1000) capped = false;
		}
		// Second scheduled delay is the first doubling: 2s -> 4s.
		check(delays[0] >= 2 * TOPIC_DISCOVERY_RETRY_MS &&
			  delays[0] < 4 * TOPIC_DISCOVERY_RETRY_MS,
			  "retry backoff doubles (2s -> 4s)");
		check(delays[1] >= 4 * TOPIC_DISCOVERY_RETRY_MS &&
			  delays[1] < 8 * TOPIC_DISCOVERY_RETRY_MS,
			  "retry backoff doubles again (4s -> 8s)");
		check(capped, "retry backoff is capped at the 60s ceiling");
	}

	udx_socket_close(&sock);
	uv_run(&loop, UV_RUN_NOWAIT);
	uv_loop_close(&loop);
	delete rpc;
}

static void test_topic_multiple_topics() {
	printf("\n--- test_topic_multiple_topics ---\n");

	uv_loop_t loop;
	uv_loop_init(&loop);
	udx_t udx;
	udx_init(&loop, &udx, nullptr);
	udx_socket_t sock;
	DhtRpc *rpc = create_mock_rpc(&loop, &udx, &sock);

	Ed25519KeyPair kp;
	crypto_sign_keypair(kp.public_key, kp.secret_key);

	HyperDht dht;
	dht.init(rpc, kp);

	uint8_t topic1[32], topic2[32], topic3[32];
	randombytes_buf(topic1, 32);
	randombytes_buf(topic2, 32);
	randombytes_buf(topic3, 32);

	dht.join_topic(topic1, true, true);
	dht.join_topic(topic2, false, true);
	dht.join_topic(topic3, true, false);
	check(dht.topic_count() == 3, "three topics active");

	check(dht.find_topic(topic1) != nullptr, "topic1 found");
	check(dht.find_topic(topic2) != nullptr, "topic2 found");
	check(dht.find_topic(topic3) != nullptr, "topic3 found");

	// Leave topic2.
	dht.leave_topic(topic2);
	check(dht.topic_count() == 2, "two topics after leaving topic2");
	check(dht.find_topic(topic2) == nullptr, "topic2 gone");

	// Leave topic1.
	dht.leave_topic(topic1);
	check(dht.topic_count() == 1, "one topic after leaving topic1");

	// Leave topic3.
	dht.leave_topic(topic3);
	check(dht.topic_count() == 0, "no topics after leaving all");

	delete rpc;
	udx_socket_close(&sock);
	uv_run(&loop, UV_RUN_NOWAIT);
	uv_loop_close(&loop);
}

// ---- Connection deduplication tests ----

static void test_dedup_higher_key_wins() {
	printf("\n--- test_dedup_higher_key_wins ---\n");

	uv_loop_t loop;
	uv_loop_init(&loop);
	udx_t udx;
	udx_init(&loop, &udx, nullptr);
	udx_socket_t sock;
	DhtRpc *rpc = create_mock_rpc(&loop, &udx, &sock);

	Ed25519KeyPair kp;
	crypto_sign_keypair(kp.public_key, kp.secret_key);

	HyperDht dht;
	dht.init(rpc, kp);

	DhtConnector connector;
	connector.init(&dht, &sock, &loop);

	// Generate a remote key that is deterministically lower than ours.
	uint8_t remote_pk[32];
	memset(remote_pk, 0, 32); // All zeros — guaranteed lower than any real key.

	// Track connection results.
	int connected_count = 0;
	int failed_count = 0;
	uint32_t last_connected_id = 0;
	connector.on_connected = [&](uint32_t id, const DhtConnection &conn) {
		connected_count++;
		last_connected_id = id;
	};
	connector.on_failed = [&](uint32_t id, const uint8_t *remote_pk, const char *error) {
		failed_count++;
	};

	// Manually create two connections to same remote (simulating simultaneous connect).
	// We'll need to directly manipulate DhtConnector's internal state.
	// Since we can't easily without public API, we verify the dedup logic indirectly.
	// The dedup code in _finalize_connection checks _find_connection_by_pk().
	// If existing is CONNECTED and new arrives, the tie-break fires.

	// For now, verify the logic by checking the key comparison.
	// Our key is random, remote_pk is zeros → we are higher.
	int cmp = memcmp(kp.public_key, remote_pk, 32);
	check(cmp > 0, "our key > remote key (zeros)");

	// When we are higher and connection is initiator, keepNew = true
	// (matching JS: isInitiator === existing.isInitiator || compare > 0)
	bool we_are_higher = (cmp > 0);
	bool keep_new_same_role = true; // Same isInitiator → always keep new.
	bool keep_new_diff_role = we_are_higher; // Higher key → keep new.
	check(keep_new_same_role, "same initiator role → keep new");
	check(keep_new_diff_role, "we are higher → keep new even with different role");

	// Test with remote key higher than ours.
	uint8_t high_remote_pk[32];
	memset(high_remote_pk, 0xFF, 32); // All 0xFF — guaranteed higher.
	int cmp2 = memcmp(kp.public_key, high_remote_pk, 32);
	check(cmp2 < 0, "remote key > our key");
	bool keep_new_diff_role2 = (cmp2 > 0);
	check(!keep_new_diff_role2, "remote is higher + different role → keep existing");

	delete rpc;
	udx_socket_close(&sock);
	uv_run(&loop, UV_RUN_NOWAIT);
	uv_loop_close(&loop);
}

// ---- NatSampler tests ----

static void test_nat_sampler_consistent() {
	printf("\n--- test_nat_sampler_consistent ---\n");

	NatSampler sampler;

	// Add 4 samples where reported address is always the same.
	// This means the NAT consistently maps to the same external port.
	IPv4Address reported = {{1, 2, 3, 4}, 5000};
	IPv4Address from1 = {{10, 0, 0, 1}, 49737};
	IPv4Address from2 = {{10, 0, 0, 2}, 49737};
	IPv4Address from3 = {{10, 0, 0, 3}, 49737};
	IPv4Address from4 = {{10, 0, 0, 4}, 49737};

	sampler.add(reported, from1);
	sampler.add(reported, from2);
	sampler.add(reported, from3);
	sampler.add(reported, from4);

	uint32_t fw = sampler.firewall();
	check(fw == FIREWALL_CONSISTENT, "4 identical samples → CONSISTENT");
	check(sampler.address_count() > 0, "has at least one address");

	const IPv4Address *addrs = sampler.addresses();
	check(addrs[0].port == 5000, "address port matches reported");
}

static void test_nat_sampler_random() {
	printf("\n--- test_nat_sampler_random ---\n");

	NatSampler sampler;

	// Add 4 samples where reported address is different each time.
	// This means the NAT uses random port allocation (symmetric NAT).
	// NatSampler deduplicates by source IP, so each sample must come
	// from a different source.
	IPv4Address from1 = {{10, 0, 0, 1}, 49737};
	IPv4Address from2 = {{10, 0, 0, 2}, 49737};
	IPv4Address from3 = {{10, 0, 0, 3}, 49737};
	IPv4Address from4 = {{10, 0, 0, 4}, 49737};

	IPv4Address r1 = {{1, 2, 3, 4}, 5001};
	IPv4Address r2 = {{1, 2, 3, 4}, 5002};
	IPv4Address r3 = {{1, 2, 3, 4}, 5003};
	IPv4Address r4 = {{1, 2, 3, 4}, 5004};

	sampler.add(r1, from1);
	sampler.add(r2, from2);
	sampler.add(r3, from3);
	sampler.add(r4, from4);

	uint32_t fw = sampler.firewall();
	check(fw == FIREWALL_RANDOM, "4 unique port samples → RANDOM");
}

static void test_nat_sampler_open() {
	printf("\n--- test_nat_sampler_open ---\n");

	NatSampler sampler;

	// NatSampler deduplicates by source IP, so each sample must come
	// from a different source to actually be counted.
	IPv4Address reported = {{1, 2, 3, 4}, 5000};
	IPv4Address from1 = {{10, 0, 0, 1}, 49737};
	IPv4Address from2 = {{10, 0, 0, 2}, 49737};
	IPv4Address from3 = {{10, 0, 0, 3}, 49737};

	sampler.add(reported, from1);
	check(sampler.firewall() == FIREWALL_UNKNOWN, "1 sample → UNKNOWN");

	sampler.add(reported, from2);
	check(sampler.firewall() == FIREWALL_UNKNOWN, "2 samples → UNKNOWN");

	// 3 identical reported addresses from different sources → CONSISTENT
	// (OPEN requires port-forwarding or no NAT detection via external service).
	sampler.add(reported, from3);
	check(sampler.firewall() == FIREWALL_CONSISTENT, "3 identical → CONSISTENT");
}

static void test_nat_sampler_mixed() {
	printf("\n--- test_nat_sampler_mixed ---\n");

	NatSampler sampler;

	// Mix of same and different ports from different sources.
	// NatSampler deduplicates by source IP — each sample needs a unique from IP.
	IPv4Address from1 = {{10, 0, 0, 1}, 49737};
	IPv4Address from2 = {{10, 0, 0, 2}, 49737};
	IPv4Address from3 = {{10, 0, 0, 3}, 49737};
	IPv4Address from4 = {{10, 0, 0, 4}, 49737};

	IPv4Address r1 = {{1, 2, 3, 4}, 5000};
	IPv4Address r2 = {{1, 2, 3, 4}, 5000};
	IPv4Address r3 = {{1, 2, 3, 4}, 5001};
	IPv4Address r4 = {{1, 2, 3, 4}, 5000};

	sampler.add(r1, from1);
	sampler.add(r2, from2);
	sampler.add(r3, from3);
	sampler.add(r4, from4);

	// With 3 hits on port 5000, max_hits >= 3 → CONSISTENT.
	uint32_t fw = sampler.firewall();
	check(fw == FIREWALL_CONSISTENT, "3/4 same port → CONSISTENT");
}

static void test_topic_join_invalid() {
	printf("\n--- test_topic_join_invalid ---\n");

	uv_loop_t loop;
	uv_loop_init(&loop);
	udx_t udx;
	udx_init(&loop, &udx, nullptr);
	udx_socket_t sock;
	DhtRpc *rpc = create_mock_rpc(&loop, &udx, &sock);

	Ed25519KeyPair kp;
	crypto_sign_keypair(kp.public_key, kp.secret_key);

	HyperDht dht;
	dht.init(rpc, kp);

	uint8_t topic[32];
	randombytes_buf(topic, 32);

	// Join with neither server nor client should fail.
	bool ok = dht.join_topic(topic, false, false);
	check(!ok, "join_topic(false, false) returns false");
	check(dht.topic_count() == 0, "no topic created for invalid join");

	delete rpc;
	udx_socket_close(&sock);
	uv_run(&loop, UV_RUN_NOWAIT);
	uv_loop_close(&loop);
}

// ---- Main ----

int main() {
	if (sodium_init() < 0) {
		fprintf(stderr, "sodium_init failed\n");
		return 1;
	}

	printf("=== Phase 5 Tests: Multi-Topic + Deduplication + NatSampler ===\n");

	// Topic management tests.
	test_topic_join_leave();
	test_topic_session_counting();
	test_topic_refresh_timing();
	test_topic_discovery_retry_schedule();
	test_topic_multiple_topics();
	test_topic_join_invalid();

	// Connection deduplication tests.
	test_dedup_higher_key_wins();

	// NatSampler tests.
	test_nat_sampler_consistent();
	test_nat_sampler_random();
	test_nat_sampler_open();
	test_nat_sampler_mixed();

	printf("\n=== Summary: %d/%d passed ===\n", tests_passed, tests_run);
	return tests_failed > 0 ? 1 : 0;
}