// 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;
}