Add unit tests for configuration, cryptography, interfaces, and packet handling.

This commit is contained in:
Ivan
2025-05-07 18:24:52 -05:00
parent 3f141bf93b
commit 8114c3bda4
13 changed files with 1473 additions and 6 deletions

290
pkg/interfaces/auto_test.go Normal file
View File

@@ -0,0 +1,290 @@
package interfaces
import (
"net"
"testing"
"time"
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
)
func TestNewAutoInterface(t *testing.T) {
t.Run("DefaultConfig", func(t *testing.T) {
config := &common.InterfaceConfig{Enabled: true}
ai, err := NewAutoInterface("autoDefault", config)
if err != nil {
t.Fatalf("NewAutoInterface failed with default config: %v", err)
}
if ai == nil {
t.Fatal("NewAutoInterface returned nil with default config")
}
if ai.GetName() != "autoDefault" {
t.Errorf("GetName() = %s; want autoDefault", ai.GetName())
}
if ai.GetType() != common.IF_TYPE_AUTO {
t.Errorf("GetType() = %v; want %v", ai.GetType(), common.IF_TYPE_AUTO)
}
if ai.discoveryPort != DEFAULT_DISCOVERY_PORT {
t.Errorf("discoveryPort = %d; want %d", ai.discoveryPort, DEFAULT_DISCOVERY_PORT)
}
if ai.dataPort != DEFAULT_DATA_PORT {
t.Errorf("dataPort = %d; want %d", ai.dataPort, DEFAULT_DATA_PORT)
}
if string(ai.groupID) != "reticulum" {
t.Errorf("groupID = %s; want reticulum", string(ai.groupID))
}
if ai.discoveryScope != SCOPE_LINK {
t.Errorf("discoveryScope = %s; want %s", ai.discoveryScope, SCOPE_LINK)
}
if len(ai.peers) != 0 {
t.Errorf("peers map not empty initially")
}
})
t.Run("CustomConfig", func(t *testing.T) {
config := &common.InterfaceConfig{
Enabled: true,
Port: 12345, // Custom discovery port
GroupID: "customGroup",
}
ai, err := NewAutoInterface("autoCustom", config)
if err != nil {
t.Fatalf("NewAutoInterface failed with custom config: %v", err)
}
if ai == nil {
t.Fatal("NewAutoInterface returned nil with custom config")
}
if ai.discoveryPort != 12345 {
t.Errorf("discoveryPort = %d; want 12345", ai.discoveryPort)
}
if string(ai.groupID) != "customGroup" {
t.Errorf("groupID = %s; want customGroup", string(ai.groupID))
}
})
}
// mockAutoInterface embeds AutoInterface but overrides methods that start goroutines
type mockAutoInterface struct {
*AutoInterface
}
func newMockAutoInterface(name string, config *common.InterfaceConfig) (*mockAutoInterface, error) {
ai, err := NewAutoInterface(name, config)
if err != nil {
return nil, err
}
// Initialize maps that would normally be initialized in Start()
ai.peers = make(map[string]*Peer)
ai.linkLocalAddrs = make([]string, 0)
ai.adoptedInterfaces = make(map[string]string)
ai.interfaceServers = make(map[string]*net.UDPConn)
ai.multicastEchoes = make(map[string]time.Time)
return &mockAutoInterface{AutoInterface: ai}, nil
}
func (m *mockAutoInterface) Start() error {
// Don't start any goroutines
return nil
}
func (m *mockAutoInterface) Stop() error {
// Don't try to close connections that were never opened
return nil
}
// mockHandlePeerAnnounce is a test-only method that doesn't handle its own locking
func (m *mockAutoInterface) mockHandlePeerAnnounce(addr *net.UDPAddr, data []byte, ifaceName string) {
peerAddr := addr.IP.String() + "%" + addr.Zone
for _, localAddr := range m.linkLocalAddrs {
if peerAddr == localAddr {
m.multicastEchoes[ifaceName] = time.Now()
return
}
}
if _, exists := m.peers[peerAddr]; !exists {
m.peers[peerAddr] = &Peer{
ifaceName: ifaceName,
lastHeard: time.Now(),
}
} else {
m.peers[peerAddr].lastHeard = time.Now()
}
}
func TestAutoInterfacePeerManagement(t *testing.T) {
// Use a shorter timeout for testing
testTimeout := 100 * time.Millisecond
config := &common.InterfaceConfig{Enabled: true}
ai, err := newMockAutoInterface("autoPeerTest", config)
if err != nil {
t.Fatalf("Failed to create mock interface: %v", err)
}
// Create a done channel to signal goroutine cleanup
done := make(chan struct{})
// Start peer management with done channel
go func() {
ticker := time.NewTicker(testTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ai.mutex.Lock()
now := time.Now()
for addr, peer := range ai.peers {
if now.Sub(peer.lastHeard) > testTimeout {
delete(ai.peers, addr)
}
}
ai.mutex.Unlock()
case <-done:
return
}
}
}()
// Ensure cleanup
defer func() {
close(done)
ai.Stop()
}()
// Simulate receiving peer announces
peer1AddrStr := "fe80::1%eth0"
peer2AddrStr := "fe80::2%eth0"
localAddrStr := "fe80::aaaa%eth0" // Simulate a local address
peer1Addr := &net.UDPAddr{IP: net.ParseIP("fe80::1"), Zone: "eth0"}
peer2Addr := &net.UDPAddr{IP: net.ParseIP("fe80::2"), Zone: "eth0"}
localAddr := &net.UDPAddr{IP: net.ParseIP("fe80::aaaa"), Zone: "eth0"}
// Add a simulated local address to avoid adding it as a peer
ai.mutex.Lock()
ai.linkLocalAddrs = append(ai.linkLocalAddrs, localAddrStr)
ai.mutex.Unlock()
t.Run("AddPeer1", func(t *testing.T) {
ai.mutex.Lock()
ai.mockHandlePeerAnnounce(peer1Addr, []byte("announce1"), "eth0")
ai.mutex.Unlock()
// Give a small amount of time for the peer to be processed
time.Sleep(10 * time.Millisecond)
ai.mutex.RLock()
count := len(ai.peers)
peer, exists := ai.peers[peer1AddrStr]
var ifaceName string
if exists {
ifaceName = peer.ifaceName
}
ai.mutex.RUnlock()
if count != 1 {
t.Fatalf("Expected 1 peer, got %d", count)
}
if !exists {
t.Fatalf("Peer %s not found in map", peer1AddrStr)
}
if ifaceName != "eth0" {
t.Errorf("Peer %s interface name = %s; want eth0", peer1AddrStr, ifaceName)
}
})
t.Run("AddPeer2", func(t *testing.T) {
ai.mutex.Lock()
ai.mockHandlePeerAnnounce(peer2Addr, []byte("announce2"), "eth0")
ai.mutex.Unlock()
// Give a small amount of time for the peer to be processed
time.Sleep(10 * time.Millisecond)
ai.mutex.RLock()
count := len(ai.peers)
_, exists := ai.peers[peer2AddrStr]
ai.mutex.RUnlock()
if count != 2 {
t.Fatalf("Expected 2 peers, got %d", count)
}
if !exists {
t.Fatalf("Peer %s not found in map", peer2AddrStr)
}
})
t.Run("IgnoreLocalAnnounce", func(t *testing.T) {
ai.mutex.Lock()
ai.mockHandlePeerAnnounce(localAddr, []byte("local_announce"), "eth0")
ai.mutex.Unlock()
// Give a small amount of time for the peer to be processed
time.Sleep(10 * time.Millisecond)
ai.mutex.RLock()
count := len(ai.peers)
ai.mutex.RUnlock()
if count != 2 {
t.Fatalf("Expected 2 peers after local announce, got %d", count)
}
})
t.Run("UpdatePeerTimestamp", func(t *testing.T) {
ai.mutex.RLock()
peer, exists := ai.peers[peer1AddrStr]
var initialTime time.Time
if exists {
initialTime = peer.lastHeard
}
ai.mutex.RUnlock()
if !exists {
t.Fatalf("Peer %s not found before timestamp update", peer1AddrStr)
}
ai.mutex.Lock()
ai.mockHandlePeerAnnounce(peer1Addr, []byte("announce1_again"), "eth0")
ai.mutex.Unlock()
// Give a small amount of time for the peer to be processed
time.Sleep(10 * time.Millisecond)
ai.mutex.RLock()
peer, exists = ai.peers[peer1AddrStr]
var updatedTime time.Time
if exists {
updatedTime = peer.lastHeard
}
ai.mutex.RUnlock()
if !exists {
t.Fatalf("Peer %s not found after timestamp update", peer1AddrStr)
}
if !updatedTime.After(initialTime) {
t.Errorf("Peer timestamp was not updated after receiving another announce")
}
})
t.Run("PeerTimeout", func(t *testing.T) {
// Wait for peer timeout
time.Sleep(testTimeout * 2)
ai.mutex.RLock()
count := len(ai.peers)
ai.mutex.RUnlock()
if count != 0 {
t.Errorf("Expected all peers to timeout, got %d peers", count)
}
})
}

View File

@@ -0,0 +1,230 @@
package interfaces
import (
"bytes"
"net"
"sync"
"testing"
"time"
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
)
func TestBaseInterfaceStateChanges(t *testing.T) {
bi := NewBaseInterface("test", common.IF_TYPE_TCP, false) // Start disabled
if bi.IsEnabled() {
t.Error("Newly created disabled interface reports IsEnabled() == true")
}
if bi.IsOnline() {
t.Error("Newly created disabled interface reports IsOnline() == true")
}
if bi.IsDetached() {
t.Error("Newly created interface reports IsDetached() == true")
}
bi.Enable()
if !bi.IsEnabled() {
t.Error("After Enable(), IsEnabled() == false")
}
if !bi.IsOnline() {
t.Error("After Enable(), IsOnline() == false")
}
if bi.IsDetached() {
t.Error("After Enable(), IsDetached() == true")
}
bi.Detach()
if bi.IsEnabled() {
t.Error("After Detach(), IsEnabled() == true")
}
if bi.IsOnline() {
t.Error("After Detach(), IsOnline() == true")
}
if !bi.IsDetached() {
t.Error("After Detach(), IsDetached() == false")
}
// Reset for Disable test
bi = NewBaseInterface("test2", common.IF_TYPE_UDP, true) // Start enabled
if !bi.Enabled { // Check the Enabled field directly first
t.Error("Newly created enabled interface reports Enabled == false")
}
if bi.IsEnabled() { // IsEnabled should still be false because Online is false
t.Error("Newly created enabled interface reports IsEnabled() == true before Enable() is called")
}
bi.Enable() // Explicitly enable to set Online = true
if !bi.IsEnabled() { // Now IsEnabled should be true
t.Error("After Enable() on initially enabled interface, IsEnabled() == false")
}
bi.Disable()
if bi.Enabled { // Check Enabled field after Disable()
t.Error("After Disable(), Enabled == true")
}
if bi.IsOnline() {
t.Error("After Disable(), IsOnline() == true")
}
if bi.IsDetached() { // Disable doesn't detach
t.Error("After Disable(), IsDetached() == true")
}
}
func TestBaseInterfaceGetters(t *testing.T) {
bi := NewBaseInterface("getterTest", common.IF_TYPE_AUTO, true)
if bi.GetName() != "getterTest" {
t.Errorf("GetName() = %s; want getterTest", bi.GetName())
}
if bi.GetType() != common.IF_TYPE_AUTO {
t.Errorf("GetType() = %v; want %v", bi.GetType(), common.IF_TYPE_AUTO)
}
if bi.GetMode() != common.IF_MODE_FULL {
t.Errorf("GetMode() = %v; want %v", bi.GetMode(), common.IF_MODE_FULL)
}
if bi.GetMTU() != common.DEFAULT_MTU { // Assuming default MTU
t.Errorf("GetMTU() = %d; want %d", bi.GetMTU(), common.DEFAULT_MTU)
}
}
func TestBaseInterfaceCallbacks(t *testing.T) {
bi := NewBaseInterface("callbackTest", common.IF_TYPE_TCP, true)
var wg sync.WaitGroup
var callbackCalled bool
callback := func(data []byte, iface common.NetworkInterface) {
if len(data) != 5 {
t.Errorf("Callback received data length %d; want 5", len(data))
}
if iface.GetName() != "callbackTest" {
t.Errorf("Callback received interface name %s; want callbackTest", iface.GetName())
}
callbackCalled = true
wg.Done()
}
bi.SetPacketCallback(callback)
if bi.GetPacketCallback() == nil { // Cannot directly compare functions
t.Error("GetPacketCallback() returned nil after SetPacketCallback()")
}
wg.Add(1)
go bi.ProcessIncoming([]byte{1, 2, 3, 4, 5}) // Run in goroutine as callback might block
// Wait for callback or timeout
waitTimeout(&wg, 1*time.Second, t)
if !callbackCalled {
t.Error("Packet callback was not called after ProcessIncoming")
}
}
func TestBaseInterfaceStats(t *testing.T) {
bi := NewBaseInterface("statsTest", common.IF_TYPE_UDP, true)
bi.Enable() // Need to be Online for ProcessOutgoing
data1 := []byte{1, 2, 3}
data2 := []byte{4, 5, 6, 7, 8}
bi.ProcessIncoming(data1)
if bi.RxBytes != uint64(len(data1)) {
t.Errorf("RxBytes = %d; want %d after first ProcessIncoming", bi.RxBytes, len(data1))
}
bi.ProcessIncoming(data2)
if bi.RxBytes != uint64(len(data1)+len(data2)) {
t.Errorf("RxBytes = %d; want %d after second ProcessIncoming", bi.RxBytes, len(data1)+len(data2))
}
// ProcessOutgoing only updates TxBytes in BaseInterface
err := bi.ProcessOutgoing(data1)
if err != nil {
t.Fatalf("ProcessOutgoing failed: %v", err)
}
if bi.TxBytes != uint64(len(data1)) {
t.Errorf("TxBytes = %d; want %d after first ProcessOutgoing", bi.TxBytes, len(data1))
}
err = bi.ProcessOutgoing(data2)
if err != nil {
t.Fatalf("ProcessOutgoing failed: %v", err)
}
if bi.TxBytes != uint64(len(data1)+len(data2)) {
t.Errorf("TxBytes = %d; want %d after second ProcessOutgoing", bi.TxBytes, len(data1)+len(data2))
}
}
// Helper function to wait for a WaitGroup with a timeout
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration, t *testing.T) {
c := make(chan struct{})
go func() {
defer close(c)
wg.Wait()
}()
select {
case <-c:
// Completed normally
case <-time.After(timeout):
t.Fatal("Timed out waiting for WaitGroup")
}
}
// Minimal mock interface for InterceptedInterface test
type mockInterface struct {
BaseInterface
sendCalled bool
sendData []byte
}
func (m *mockInterface) Send(data []byte, addr string) error {
m.sendCalled = true
m.sendData = data
return nil
}
// Add other methods to satisfy the Interface interface (can be minimal/panic)
func (m *mockInterface) GetType() common.InterfaceType { return common.IF_TYPE_NONE }
func (m *mockInterface) GetMode() common.InterfaceMode { return common.IF_MODE_FULL }
func (m *mockInterface) ProcessIncoming(data []byte) {}
func (m *mockInterface) ProcessOutgoing(data []byte) error { return nil }
func (m *mockInterface) SendPathRequest([]byte) error { return nil }
func (m *mockInterface) SendLinkPacket([]byte, []byte, time.Time) error { return nil }
func (m *mockInterface) Start() error { return nil }
func (m *mockInterface) Stop() error { return nil }
func (m *mockInterface) GetConn() net.Conn { return nil }
func (m *mockInterface) GetBandwidthAvailable() bool { return true }
func TestInterceptedInterface(t *testing.T) {
mockBase := &mockInterface{}
var interceptorCalled bool
var interceptedData []byte
interceptor := func(data []byte, iface common.NetworkInterface) error {
interceptorCalled = true
interceptedData = data
return nil
}
intercepted := NewInterceptedInterface(mockBase, interceptor)
testData := []byte("intercept me")
err := intercepted.Send(testData, "dummy_addr")
if err != nil {
t.Fatalf("Intercepted Send failed: %v", err)
}
if !interceptorCalled {
t.Error("Interceptor function was not called")
}
if !bytes.Equal(interceptedData, testData) {
t.Errorf("Interceptor received data %x; want %x", interceptedData, testData)
}
if !mockBase.sendCalled {
t.Error("Original Send function was not called")
}
if !bytes.Equal(mockBase.sendData, testData) {
t.Errorf("Original Send received data %x; want %x", mockBase.sendData, testData)
}
}

View File

@@ -0,0 +1,52 @@
package interfaces
import (
"bytes"
"testing"
)
func TestEscapeHDLC(t *testing.T) {
testCases := []struct {
name string
input []byte
expected []byte
}{
{"NoEscape", []byte{0x01, 0x02, 0x03}, []byte{0x01, 0x02, 0x03}},
{"EscapeFlag", []byte{0x01, HDLC_FLAG, 0x03}, []byte{0x01, HDLC_ESC, HDLC_FLAG ^ HDLC_ESC_MASK, 0x03}},
{"EscapeEsc", []byte{0x01, HDLC_ESC, 0x03}, []byte{0x01, HDLC_ESC, HDLC_ESC ^ HDLC_ESC_MASK, 0x03}},
{"EscapeBoth", []byte{HDLC_FLAG, HDLC_ESC}, []byte{HDLC_ESC, HDLC_FLAG ^ HDLC_ESC_MASK, HDLC_ESC, HDLC_ESC ^ HDLC_ESC_MASK}},
{"Empty", []byte{}, []byte{}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := escapeHDLC(tc.input)
if !bytes.Equal(result, tc.expected) {
t.Errorf("escapeHDLC(%x) = %x; want %x", tc.input, result, tc.expected)
}
})
}
}
func TestEscapeKISS(t *testing.T) {
testCases := []struct {
name string
input []byte
expected []byte
}{
{"NoEscape", []byte{0x01, 0x02, 0x03}, []byte{0x01, 0x02, 0x03}},
{"EscapeFEND", []byte{0x01, KISS_FEND, 0x03}, []byte{0x01, KISS_FESC, KISS_TFEND, 0x03}},
{"EscapeFESC", []byte{0x01, KISS_FESC, 0x03}, []byte{0x01, KISS_FESC, KISS_TFESC, 0x03}},
{"EscapeBoth", []byte{KISS_FEND, KISS_FESC}, []byte{KISS_FESC, KISS_TFEND, KISS_FESC, KISS_TFESC}},
{"Empty", []byte{}, []byte{}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := escapeKISS(tc.input)
if !bytes.Equal(result, tc.expected) {
t.Errorf("escapeKISS(%x) = %x; want %x", tc.input, result, tc.expected)
}
})
}
}

View File

@@ -0,0 +1,93 @@
package interfaces
import (
"testing"
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
)
func TestNewUDPInterface(t *testing.T) {
validAddr := "127.0.0.1:0" // Use port 0 for OS to assign a free port
validTarget := "127.0.0.1:8080"
invalidAddr := "invalid-address"
t.Run("ValidConfig", func(t *testing.T) {
ui, err := NewUDPInterface("udpValid", validAddr, validTarget, true)
if err != nil {
t.Fatalf("NewUDPInterface failed with valid config: %v", err)
}
if ui == nil {
t.Fatal("NewUDPInterface returned nil interface with valid config")
}
if ui.GetName() != "udpValid" {
t.Errorf("GetName() = %s; want udpValid", ui.GetName())
}
if ui.GetType() != common.IF_TYPE_UDP {
t.Errorf("GetType() = %v; want %v", ui.GetType(), common.IF_TYPE_UDP)
}
if ui.addr.String() != validAddr && ui.addr.Port == 0 { // Check if address resolved, port 0 is special
// Allow OS-assigned port if 0 was specified
} else if ui.addr.String() != validAddr {
// t.Errorf("Resolved addr = %s; want %s", ui.addr.String(), validAddr) //This check is flaky with port 0
}
if ui.targetAddr.String() != validTarget {
t.Errorf("Resolved targetAddr = %s; want %s", ui.targetAddr.String(), validTarget)
}
if !ui.Enabled { // BaseInterface field
t.Error("Interface not enabled by default when requested")
}
if ui.IsOnline() { // Should be offline initially
t.Error("Interface online initially")
}
})
t.Run("ValidConfigNoTarget", func(t *testing.T) {
ui, err := NewUDPInterface("udpNoTarget", validAddr, "", true)
if err != nil {
t.Fatalf("NewUDPInterface failed with valid config (no target): %v", err)
}
if ui == nil {
t.Fatal("NewUDPInterface returned nil interface with valid config (no target)")
}
if ui.targetAddr != nil {
t.Errorf("targetAddr = %v; want nil", ui.targetAddr)
}
})
t.Run("InvalidAddress", func(t *testing.T) {
_, err := NewUDPInterface("udpInvalidAddr", invalidAddr, validTarget, true)
if err == nil {
t.Error("NewUDPInterface succeeded with invalid address")
}
})
t.Run("InvalidTarget", func(t *testing.T) {
_, err := NewUDPInterface("udpInvalidTarget", validAddr, invalidAddr, true)
if err == nil {
t.Error("NewUDPInterface succeeded with invalid target address")
}
})
}
func TestUDPInterfaceState(t *testing.T) {
// Basic state tests are covered by BaseInterface tests
// Add specific UDP ones if needed, e.g., involving the conn
addr := "127.0.0.1:0"
ui, _ := NewUDPInterface("udpState", addr, "", true)
if ui.conn != nil {
t.Error("conn field is not nil before Start()")
}
// We don't call Start() here because it requires actual network binding
// Testing Send requires Start() and a listener, which is too complex for unit tests here
// Test Detach
ui.Detach()
if !ui.IsDetached() {
t.Error("IsDetached() is false after Detach()")
}
// Further tests on Send/ProcessOutgoing/readLoop would require mocking net.UDPConn
// or setting up a local listener.
}