// AutoInterface loopback integration tests. // // These exercise the real socket/poll/setsockopt paths against the host's // loopback IPv6 multicast — the closest we can get to live LAN behavior // without two physical machines or root privileges. // // Limitations of single-host loopback testing: // * Two AutoInterface instances bound to the same fe80:: address can't // genuinely peer with each other: each treats the other's announce as // a self-echo (their src.sin6_addr matches each instance's own // _self_link_local_bin). True peering requires distinct link-local // identities, which on a single host requires root + `ip -6 addr add`. // * IPV6_JOIN_GROUP on lo0 may be filtered by some hosts (notably // macOS / older lwIP); those configurations TEST_IGNORE rather than // fail so CI on developer laptops doesn't flake. // // What IS verified end-to-end here: // 1. Two AutoInterface instances can coexist on the same host without // crashing, leaking sockets, or interfering with each other's // multicast traffic. // 2. has_carrier() becomes true after start() drives the multicast // loopback round-trip. // 3. Self-echo suppression keeps peer_count() at 0 after notify_link_ // change(), even when the kernel delays a loopback of the announce // sent under the previous link-local. // 4. notify_link_change() while online survives the IPV6_LEAVE_GROUP / // IPV6_JOIN_GROUP rotation without breaking carrier. #include #include #include "Interfaces/AutoInterface.h" #include "Bytes.h" using RNS::AutoInterface; using RNS::Bytes; // Ports chosen well above the production defaults and the unit-test range // (49716+) so there is no overlap. static constexpr uint16_t kAlfaDisc = 49810; static constexpr uint16_t kAlfaData = 49814; static constexpr uint16_t kBravoDisc = 49820; static constexpr uint16_t kBravoData = 49824; namespace { // Drive loop_at() in 100ms simulated steps, sleeping a real 5ms between // each so the kernel actually delivers any pending multicast frames. void drive(AutoInterface& ai, uint64_t start_ms, uint64_t end_ms) { for (uint64_t t = start_ms; t <= end_ms; t += 100) { ai.test_loop_at(t); struct timespec ts { 0, 5'000'000 }; // 5 ms nanosleep(&ts, nullptr); } } void drive_pair(AutoInterface& a, AutoInterface& b, uint64_t start_ms, uint64_t end_ms) { for (uint64_t t = start_ms; t <= end_ms; t += 100) { a.test_loop_at(t); b.test_loop_at(t); struct timespec ts { 0, 5'000'000 }; nanosleep(&ts, nullptr); } } } // namespace void test_two_instances_coexist_on_loopback() { AutoInterface alfa("alfa", "reticulum", AutoInterface::MCAST_ADDR_TYPE_TEMPORARY, AutoInterface::SCOPE_LINK, kAlfaDisc, kAlfaData, 4); AutoInterface bravo("bravo", "reticulum", AutoInterface::MCAST_ADDR_TYPE_TEMPORARY, AutoInterface::SCOPE_LINK, kBravoDisc, kBravoData, 4); alfa.set_link_local("fe80::1", 1); bravo.set_link_local("fe80::1", 1); if (!alfa.start() || !bravo.start()) { alfa.stop(); bravo.stop(); TEST_IGNORE_MESSAGE("could not start two AutoInterface instances on loopback"); return; } drive_pair(alfa, bravo, 0, 3000); if (!alfa.has_carrier() || !bravo.has_carrier()) { alfa.stop(); bravo.stop(); TEST_IGNORE_MESSAGE("multicast loopback filtered on this host (lo0 IPV6 mcast)"); return; } // Both saw multicast carrier — kernel is routing between them. TEST_ASSERT_TRUE(alfa.has_carrier()); TEST_ASSERT_TRUE(bravo.has_carrier()); // Both bound to fe80::1, so each correctly treats the other's // announce as self-echo. Documented limitation — round-trip peering // across a single loopback host without root is not achievable. TEST_ASSERT_EQUAL_size_t(0, alfa.peer_count()); TEST_ASSERT_EQUAL_size_t(0, bravo.peer_count()); alfa.stop(); bravo.stop(); } void test_notify_link_change_while_online_keeps_carrier() { AutoInterface ai("nlc_online", "reticulum", AutoInterface::MCAST_ADDR_TYPE_TEMPORARY, AutoInterface::SCOPE_LINK, kAlfaDisc + 100, kAlfaData + 100, 4); ai.set_link_local("fe80::1", 1); if (!ai.start()) { TEST_IGNORE_MESSAGE("start() failed on this host's loopback"); return; } // Phase 1: acquire carrier under the original address. drive(ai, 0, 2000); if (!ai.has_carrier()) { ai.stop(); TEST_IGNORE_MESSAGE("no multicast echo on this host's loopback"); return; } // Phase 2: rotate the link-local while online. Same scope_id so no // IPV6_LEAVE/JOIN cycle fires — only the token rotates and the old // address goes into the self-echo history. ai.notify_link_change("fe80::abcd:1234", 1); TEST_ASSERT_EQUAL_size_t(1, ai.test_self_link_local_history_size()); // Phase 3: keep driving. Any late loopback of the *previous* // announce's source address must be suppressed by the history- // backed self-echo filter — peer_count() must stay 0. drive(ai, 2000, 5000); TEST_ASSERT_EQUAL_size_t(0, ai.peer_count()); // Carrier should still be true (we kept getting echoes of our new // announces during phase 3). If multicast suddenly broke this would // surface here as a regression. TEST_ASSERT_TRUE(ai.has_carrier()); ai.stop(); } void test_notify_link_change_with_new_scope_rejoins_group() { // Same scope_id is the common case (Wi-Fi reassoc, new link-local // prefix on the same NIC). This test exercises the rarer scope_id // change (e.g. Wi-Fi disconnect, fall back to a different netif) so // the IPV6_LEAVE_GROUP / IPV6_JOIN_GROUP code path runs in a real // socket context. We can't observe the rejoin directly without // platform-specific introspection, so the assertion is "didn't // crash, sockets still open, carrier eventually reacquires." AutoInterface ai("nlc_scope", "reticulum", AutoInterface::MCAST_ADDR_TYPE_TEMPORARY, AutoInterface::SCOPE_LINK, kAlfaDisc + 200, kAlfaData + 200, 4); ai.set_link_local("fe80::1", 1); if (!ai.start()) { TEST_IGNORE_MESSAGE("start() failed on this host's loopback"); return; } drive(ai, 0, 2000); if (!ai.has_carrier()) { ai.stop(); TEST_IGNORE_MESSAGE("no multicast echo on this host's loopback"); return; } // Rotate scope while online. scope_id=2 on most hosts won't match // any real interface — the rejoin will warn but must not break the // already-open sockets. ai.notify_link_change("fe80::abcd:1234", 2); TEST_ASSERT_GREATER_OR_EQUAL_INT(0, ai.discovery_socket_fd()); TEST_ASSERT_GREATER_OR_EQUAL_INT(0, ai.unicast_socket_fd()); TEST_ASSERT_GREATER_OR_EQUAL_INT(0, ai.data_socket_fd()); // Drive a few more cycles — this must not crash and must continue // processing the loop without leaking FDs. drive(ai, 2000, 3500); ai.stop(); TEST_ASSERT_EQUAL_INT(-1, ai.discovery_socket_fd()); TEST_ASSERT_EQUAL_INT(-1, ai.unicast_socket_fd()); TEST_ASSERT_EQUAL_INT(-1, ai.data_socket_fd()); } void setUp(void) {} void tearDown(void) {} int runUnityTests(void) { UNITY_BEGIN(); RUN_TEST(test_two_instances_coexist_on_loopback); RUN_TEST(test_notify_link_change_while_online_keeps_carrier); RUN_TEST(test_notify_link_change_with_new_scope_rejoins_group); return UNITY_END(); } int main(void) { return runUnityTests(); } #ifdef ARDUINO void setup() { delay(2000); runUnityTests(); } void loop() {} #endif void app_main() { runUnityTests(); }