mirror of
https://github.com/ratspeak/microReticulum
synced 2026-08-12 18:07:25 -04:00
213 lines
7.2 KiB
C++
213 lines
7.2 KiB
C++
// 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 <unity.h>
|
|
#include <time.h>
|
|
|
|
#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(); }
|