package sdwan import ( "net" "path/filepath" "testing" "time" "github.com/vishvananda/netlink" ) func TestSDWANManagerBasics(t *testing.T) { tmpDir := t.TempDir() storePath := filepath.Join(tmpDir, "sdwan.json") mgr := NewManager(storePath) defer mgr.Stop() if mgr == nil { t.Fatal("expected non-nil manager") } // 1. Create SD-WAN group group := &SDWANGroup{ Name: "sdwan0", Mode: ModeWeighted, ProbeInterval: 3, AutoNAT: true, Comment: "Primary Internet SD-WAN", } if err := mgr.Save(group); err != nil { t.Fatalf("failed to save group: %v", err) } // Verify group is retrievable by Name and ID got, ok := mgr.Get("sdwan0") if !ok || got == nil { t.Fatal("expected to find sdwan0 by name") } if got.ID == "" { t.Fatal("expected auto-generated group ID") } // 2. Add members mem1 := &SDWANMember{ Interface: "eth0", Gateway: "192.168.1.1", Weight: 70, TargetProbe: "1.1.1.1", } if err := mgr.AddMember("sdwan0", mem1); err != nil { t.Fatalf("failed adding member 1: %v", err) } mem2 := &SDWANMember{ Interface: "eth1", Gateway: "192.168.2.1", Weight: 30, TargetProbe: "8.8.8.8", } if err := mgr.AddMember("sdwan0", mem2); err != nil { t.Fatalf("failed adding member 2: %v", err) } // Test duplicate member rejection if err := mgr.AddMember("sdwan0", &SDWANMember{Interface: "eth0"}); err == nil { t.Fatal("expected error on duplicate member interface") } // 3. Test Interface Resolution for Firewall resolved := mgr.ResolveGroupInterfaces("sdwan0") if len(resolved) != 2 || resolved[0] != "eth0" || resolved[1] != "eth1" { t.Fatalf("expected [eth0, eth1], got %v", resolved) } allGroups := mgr.ResolveAllSDWANGroups() if len(allGroups["sdwan0"]) != 2 { t.Fatalf("expected sdwan0 in ResolveAllSDWANGroups, got %v", allGroups) } // 4. Update Member mem1.Weight = 80 if err := mgr.UpdateMember("sdwan0", mem1); err != nil { t.Fatalf("failed updating member: %v", err) } updated, _ := mgr.Get("sdwan0") if updated.Members[0].Weight != 80 { t.Fatalf("expected weight 80, got %d", updated.Members[0].Weight) } // 5. Telemetry Summary summary := mgr.GetSummary() if summary.TotalGroups != 1 || summary.ActiveGroups != 1 || summary.TotalMembers != 2 { t.Fatalf("unexpected summary: %+v", summary) } // 6. Delete Member if err := mgr.DeleteMember("sdwan0", "eth1"); err != nil { t.Fatalf("failed deleting member: %v", err) } resolvedAfterDelete := mgr.ResolveGroupInterfaces("sdwan0") if len(resolvedAfterDelete) != 1 || resolvedAfterDelete[0] != "eth0" { t.Fatalf("expected [eth0], got %v", resolvedAfterDelete) } // 7. Delete Group if err := mgr.Delete("sdwan0"); err != nil { t.Fatalf("failed deleting group: %v", err) } if len(mgr.List()) != 0 { t.Fatal("expected 0 groups after delete") } } func TestHealthMetricsCalculation(t *testing.T) { monitor := NewHealthMonitor(nil) mem := &SDWANMember{ Interface: "eth0", MaxLatency: 100, MaxLoss: 10, } samples := []probeSample{ {latency: 20.0, success: true, t: time.Now()}, {latency: 25.0, success: true, t: time.Now()}, {latency: 30.0, success: true, t: time.Now()}, {latency: 0.0, success: false, t: time.Now()}, } monitor.calculateMetrics(mem, samples) // 1 failure out of 4 = 25% loss if mem.PacketLoss != 25.0 { t.Errorf("expected 25.0%% loss, got %.1f", mem.PacketLoss) } // Average of 20, 25, 30 is 25.0 ms if mem.LiveLatency != 25.0 { t.Errorf("expected 25.0ms latency, got %.1f", mem.LiveLatency) } if mem.Jitter <= 0 { t.Errorf("expected positive jitter, got %.1f", mem.Jitter) } } func TestHealthHysteresisAndThresholds(t *testing.T) { mem := &SDWANMember{ Interface: "eth0", MaxLatency: 250, MaxLoss: 20, Status: StatusHealthy, } // 1-4 fails: should NOT transition to StatusDown for i := 1; i <= 4; i++ { mem.ConsecutiveFails = i mem.ConsecutiveSuccesses = 0 if mem.ConsecutiveFails >= 5 { mem.Status = StatusDown } if mem.Status == StatusDown { t.Fatalf("member became DOWN prematurely at %d fails", i) } } // 5th fail: transitions to StatusDown mem.ConsecutiveFails = 5 if mem.ConsecutiveFails >= 5 { mem.Status = StatusDown } if mem.Status != StatusDown { t.Fatalf("expected StatusDown after 5 fails, got %s", mem.Status) } // 1st recovery success: hysteresis must keep it StatusDown prevStatus := mem.Status mem.ConsecutiveSuccesses = 1 mem.ConsecutiveFails = 0 if prevStatus == StatusDown && mem.ConsecutiveSuccesses < 2 { mem.Status = StatusDown } if mem.Status != StatusDown { t.Fatalf("expected StatusDown during 1st success cycle (hysteresis), got %s", mem.Status) } // 2nd recovery success: now allowed to recover mem.ConsecutiveSuccesses = 2 if prevStatus == StatusDown && mem.ConsecutiveSuccesses < 2 { mem.Status = StatusDown } else { mem.Status = StatusHealthy } if mem.Status != StatusHealthy { t.Fatalf("expected StatusHealthy after 2 consecutive successes, got %s", mem.Status) } } func TestSDWANVirtualDeviceLifecycle(t *testing.T) { tmpDir := t.TempDir() storePath := filepath.Join(tmpDir, "sdwan.json") mgr := NewManager(storePath) defer mgr.Stop() group := &SDWANGroup{ Name: "sdwantest0", Mode: ModeFailover, AutoNAT: false, Members: []*SDWANMember{ {Interface: "lo", Gateway: "127.0.0.1", Weight: 100}, }, } if err := mgr.Save(group); err != nil { t.Fatalf("failed saving group: %v", err) } // Verify virtual device exists in kernel link, err := netlink.LinkByName("sdwantest0") if err != nil { t.Fatalf("expected sdwantest0 virtual device to be created in kernel: %v", err) } if link.Attrs().Flags&net.FlagUp == 0 { t.Fatalf("expected sdwantest0 to be UP") } // Delete group and verify virtual device is removed if err := mgr.Delete("sdwantest0"); err != nil { t.Fatalf("failed to delete group: %v", err) } if _, err := netlink.LinkByName("sdwantest0"); err == nil { t.Fatalf("expected sdwantest0 virtual device to be removed from kernel after group delete") } }