package routing import ( "encoding/json" "os" "path/filepath" "testing" "debtik/internal/sdwan" ) func TestDeduplicationOnLoad(t *testing.T) { tmpDir := t.TempDir() storePath := filepath.Join(tmpDir, "static_routes.json") // Create test JSON with duplicate routes routes := []RouteItem{ { ID: "route-1", DstAddress: "10.0.0.0/8", Gateway: "10.42.36.254", Interface: "AWMN-Internal", RoutingTable: "main", Distance: 1, Comment: "Original", }, { ID: "route-2", DstAddress: "10.0.0.0/8", Gateway: "10.42.36.254", Interface: "AWMN-Internal", RoutingTable: "main", Distance: 1, Comment: "Duplicate", }, { ID: "route-3", DstAddress: "192.168.1.0/24", Gateway: "192.168.1.1", RoutingTable: "main", Distance: 1, }, } data, err := json.Marshal(routes) if err != nil { t.Fatalf("failed to marshal routes: %v", err) } if err := os.WriteFile(storePath, data, 0600); err != nil { t.Fatalf("failed to write test file: %v", err) } mgr := &RoutingManager{ staticRoutes: make([]RouteItem, 0), } // Read and deduplicate using the exact logic from load() seenIDs := make(map[string]bool) seenKeys := make(map[string]bool) var dedupedRev []RouteItem hasDups := false var loadedRoutes []RouteItem _ = json.Unmarshal(data, &loadedRoutes) for i := len(loadedRoutes) - 1; i >= 0; i-- { r := loadedRoutes[i] normDst := normalizeDst(r.DstAddress) tbl := r.RoutingTable if tbl == "" { tbl = "main" } routeKey := normDst + "|" + tbl + "|" + r.Gateway + "|" + r.Interface + "|1" if seenIDs[r.ID] || seenKeys[routeKey] { hasDups = true continue } seenIDs[r.ID] = true seenKeys[routeKey] = true dedupedRev = append(dedupedRev, r) } var deduped []RouteItem for i := len(dedupedRev) - 1; i >= 0; i-- { deduped = append(deduped, dedupedRev[i]) } mgr.staticRoutes = deduped if !hasDups { t.Errorf("expected duplicates to be detected") } if len(mgr.staticRoutes) != 2 { t.Fatalf("expected 2 routes after dedup, got %d", len(mgr.staticRoutes)) } // The latest entry for 10.0.0.0/8 should have been preserved if mgr.staticRoutes[0].ID != "route-2" || mgr.staticRoutes[0].Comment != "Duplicate" { t.Errorf("expected route-2 (latest) to be kept, got %+v", mgr.staticRoutes[0]) } } func TestSaveRouteUpdateNoDuplicates(t *testing.T) { mgr := &RoutingManager{ staticRoutes: []RouteItem{ { ID: "route-100", DstAddress: "10.0.0.0/8", Gateway: "10.42.36.254", Interface: "AWMN-Internal", RoutingTable: "main", Distance: 1, Comment: "Original AWMN Route", }, }, } // 1. Edit using matching ID editReq1 := RouteItem{ ID: "route-100", DstAddress: "10.0.0.0/8", Gateway: "10.42.36.253", Interface: "AWMN-Internal", RoutingTable: "main", Distance: 1, Comment: "Updated Gateway", } // Test findIndex logic foundIndex := -1 for i, existing := range mgr.staticRoutes { if existing.ID == editReq1.ID { foundIndex = i break } } if foundIndex != 0 { t.Fatalf("expected foundIndex 0, got %d", foundIndex) } mgr.staticRoutes[foundIndex] = editReq1 if len(mgr.staticRoutes) != 1 { t.Fatalf("expected 1 route, got %d (duplicate created!)", len(mgr.staticRoutes)) } if mgr.staticRoutes[0].Gateway != "10.42.36.253" { t.Errorf("expected gateway 10.42.36.253, got %s", mgr.staticRoutes[0].Gateway) } // 2. Edit where UI sent kr-... ID (kernel route adoption/edit) but with OldDstAddress and OldGateway editReq2 := RouteItem{ ID: "kr-254-10.0.0.0_8-10.42.36.253", OldDstAddress: "10.0.0.0/8", OldGateway: "10.42.36.253", OldRoutingTable: "main", OldInterface: "AWMN-Internal", DstAddress: "10.0.0.0/8", Gateway: "10.42.36.252", Interface: "AWMN-Internal", RoutingTable: "main", Distance: 1, CheckGateway: "internet", CheckTarget: "10.22.11.4", } foundIndex = -1 // Match by OldDstAddress / OldGateway normOldDst := normalizeDst(editReq2.OldDstAddress) for i, existing := range mgr.staticRoutes { if normalizeDst(existing.DstAddress) == normOldDst && existing.RoutingTable == editReq2.OldRoutingTable { if existing.Gateway == editReq2.OldGateway || existing.Interface == editReq2.OldGateway { foundIndex = i break } } } if foundIndex != 0 { t.Fatalf("expected foundIndex 0 from OldDst/OldGw match, got %d", foundIndex) } // Preserve ID editReq2.ID = mgr.staticRoutes[foundIndex].ID mgr.staticRoutes[foundIndex] = editReq2 if len(mgr.staticRoutes) != 1 { t.Fatalf("expected 1 route, got %d (duplicate created!)", len(mgr.staticRoutes)) } if mgr.staticRoutes[0].ID != "route-100" { t.Errorf("expected ID route-100 preserved, got %s", mgr.staticRoutes[0].ID) } if mgr.staticRoutes[0].Gateway != "10.42.36.252" { t.Errorf("expected gateway 10.42.36.252, got %s", mgr.staticRoutes[0].Gateway) } if mgr.staticRoutes[0].CheckTarget != "10.22.11.4" { t.Errorf("expected check_target 10.22.11.4, got %s", mgr.staticRoutes[0].CheckTarget) } } func TestDeleteRouteMatchingKrID(t *testing.T) { mgr := &RoutingManager{ staticRoutes: []RouteItem{ { ID: "route-100", DstAddress: "10.0.0.0/8", Gateway: "10.42.36.254", Interface: "AWMN-Internal", RoutingTable: "main", Distance: 1, }, { ID: "route-200", DstAddress: "0.0.0.0/0", Gateway: "sdwan0", Interface: "sdwan0", RoutingTable: "main", Distance: 1, }, }, } // Simulating a live route from kernel corresponding to route-100 liveRoute := RouteItem{ ID: "kr-254-10.0.0.0_8-10.42.36.254", DstAddress: "10.0.0.0/8", Gateway: "10.42.36.254", Interface: "AWMN-Internal", RoutingTable: "main", Distance: 1, } // If user clicks delete on kr-254-10.0.0.0_8-10.42.36.254 idToDelete := liveRoute.ID targetIdx := -1 for i, r := range mgr.staticRoutes { if r.ID == idToDelete { targetIdx = i break } } if targetIdx == -1 { // Fallback to matching live route against static routes normLrDst := normalizeDst(liveRoute.DstAddress) for i, sr := range mgr.staticRoutes { gwMatches := sr.Gateway == "" || liveRoute.Gateway == sr.Gateway || liveRoute.Interface == sr.Gateway if normalizeDst(sr.DstAddress) == normLrDst && sr.RoutingTable == liveRoute.RoutingTable && gwMatches { targetIdx = i break } } } if targetIdx != 0 { t.Fatalf("expected targetIdx 0, got %d", targetIdx) } // Remove target mgr.staticRoutes = append(mgr.staticRoutes[:targetIdx], mgr.staticRoutes[targetIdx+1:]...) if len(mgr.staticRoutes) != 1 { t.Fatalf("expected 1 route remaining, got %d", len(mgr.staticRoutes)) } if mgr.staticRoutes[0].ID != "route-200" { t.Errorf("expected route-200 to remain, got %s", mgr.staticRoutes[0].ID) } } func TestNormalizeDst(t *testing.T) { if normalizeDst("default") != "default" { t.Errorf("expected default, got %s", normalizeDst("default")) } if normalizeDst("0.0.0.0/0") != "default" { t.Errorf("expected default, got %s", normalizeDst("0.0.0.0/0")) } if normalizeDst("") != "default" { t.Errorf("expected default, got %s", normalizeDst("")) } if normalizeDst("10.0.0.0/8") != "10.0.0.0/8" { t.Errorf("expected 10.0.0.0/8, got %s", normalizeDst("10.0.0.0/8")) } } func TestSaveAndDeleteRouteLifecycle(t *testing.T) { tmpDir := t.TempDir() origStore := StaticRoutesStorePath defer func() { StaticRoutesStorePath = origStore }() StaticRoutesStorePath = filepath.Join(tmpDir, "static_routes.json") mgr := &RoutingManager{ staticRoutes: make([]RouteItem, 0), } // 1. Save new route with empty ID r1 := RouteItem{ DstAddress: "198.51.100.0/24", Gateway: "blackhole", Action: "blackhole", RoutingTable: "main", Distance: 10, Comment: "Initial route", } if err := mgr.SaveRoute(&r1); err != nil { t.Fatalf("expected nil error on SaveRoute, got %v", err) } if r1.ID == "" { t.Fatalf("expected SaveRoute to populate r1.ID, got empty") } if len(mgr.staticRoutes) != 1 { t.Fatalf("expected 1 static route in manager, got %d", len(mgr.staticRoutes)) } savedID := r1.ID // 2. Edit existing route with populated ID r1Edit := RouteItem{ ID: savedID, DstAddress: "198.51.100.0/24", Gateway: "blackhole", Action: "blackhole", RoutingTable: "main", Distance: 20, Comment: "Updated route", } if err := mgr.SaveRoute(&r1Edit); err != nil { t.Fatalf("expected nil error on editing SaveRoute, got %v", err) } if len(mgr.staticRoutes) != 1 { t.Fatalf("expected still 1 static route after edit, got %d", len(mgr.staticRoutes)) } if mgr.staticRoutes[0].ID != savedID { t.Errorf("expected ID %s preserved, got %s", savedID, mgr.staticRoutes[0].ID) } if mgr.staticRoutes[0].Distance != 20 { t.Errorf("expected distance 20, got %d", mgr.staticRoutes[0].Distance) } if mgr.staticRoutes[0].Comment != "Updated route" { t.Errorf("expected comment 'Updated route', got %s", mgr.staticRoutes[0].Comment) } // 3. Delete route delErr := mgr.DeleteRoute(savedID) if delErr != nil { t.Fatalf("expected nil error on DeleteRoute, got %v", delErr) } if len(mgr.staticRoutes) != 0 { t.Fatalf("expected 0 static routes after delete, got %d", len(mgr.staticRoutes)) } } func TestMultipathMixedNexthopFlags(t *testing.T) { tmpDir := t.TempDir() origStore := StaticRoutesStorePath defer func() { StaticRoutesStorePath = origStore }() StaticRoutesStorePath = filepath.Join(tmpDir, "static_routes.json") mgr := &RoutingManager{ staticRoutes: make([]RouteItem, 0), } // Route 1: via IP with device (hasVia = true) r1 := RouteItem{ DstAddress: "198.51.100.0/24", Gateway: "100.68.0.1", Interface: "Fibergrid", RoutingTable: "main", Distance: 10, } if err := mgr.SaveRoute(&r1); err != nil { t.Fatalf("failed to save route 1: %v", err) } // Route 2: device only without gateway IP (hasVia = false, e.g. sdwan0-Internet) r2 := RouteItem{ DstAddress: "198.51.100.0/24", Gateway: "sdwan0-Internet", Interface: "sdwan0-Internet", RoutingTable: "main", Distance: 10, } // This previously threw "Error: Invalid flags for nexthop - PERVASIVE and ONLINK can not be set." if err := mgr.SaveRoute(&r2); err != nil { t.Fatalf("failed to save multipath route 2: %v", err) } if len(mgr.staticRoutes) != 2 { t.Fatalf("expected 2 routes in multipath group, got %d", len(mgr.staticRoutes)) } // Cleanup _ = mgr.DeleteRoute(r1.ID) _ = mgr.DeleteRoute(r2.ID) } func TestSDWANRouteGroupResolution(t *testing.T) { tmpDir := t.TempDir() sdwanPath := filepath.Join(tmpDir, "sdwan.json") sdwanMgr := sdwan.NewManager(sdwanPath) defer sdwanMgr.Stop() // 1. Create failover group group := &sdwan.SDWANGroup{ ID: "sdwan-grp-test", Name: "sdwan-test", Mode: sdwan.ModeFailover, Members: []*sdwan.SDWANMember{ { ID: "mem-1", Interface: "Fibergrid", Gateway: "100.68.0.1", Priority: 1, Status: sdwan.StatusHealthy, }, { ID: "mem-2", Interface: "wgx-Space", Gateway: "", Priority: 2, Status: sdwan.StatusHealthy, }, }, } if err := sdwanMgr.Save(group); err != nil { t.Fatalf("failed to save sdwan test group: %v", err) } // 2. Test resolution when primary is healthy nhList, ok := sdwan.ResolveGroupForRouting("sdwan-test") if !ok || len(nhList) != 1 { t.Fatalf("expected 1 resolved nexthop, got %d (ok=%v)", len(nhList), ok) } if nhList[0].Interface != "Fibergrid" || nhList[0].Gateway != "100.68.0.1" { t.Fatalf("expected Fibergrid via 100.68.0.1, got %s via %s", nhList[0].Interface, nhList[0].Gateway) } // 3. Mark primary as DOWN and verify automatic failover to wgx-Space group.Members[0].Status = sdwan.StatusDown if err := sdwanMgr.Save(group); err != nil { t.Fatalf("failed to update sdwan group: %v", err) } nhListAfter, ok := sdwan.ResolveGroupForRouting("sdwan-test") if !ok || len(nhListAfter) != 1 { t.Fatalf("expected 1 resolved nexthop after failover, got %d", len(nhListAfter)) } if nhListAfter[0].Interface != "wgx-Space" { t.Fatalf("expected failover to wgx-Space, got %s", nhListAfter[0].Interface) } // 4. Test RoutingManager saving a static route mapped to SD-WAN group routesPath := filepath.Join(tmpDir, "static_routes.json") rulesPath := filepath.Join(tmpDir, "policy_rules.json") customPath := filepath.Join(tmpDir, "custom_tables.json") StaticRoutesStorePath = routesPath PolicyRulesStorePath = rulesPath CustomTablesStorePath = customPath mgr := NewRoutingManager() defer mgr.Stop() sdwanRoute := RouteItem{ DstAddress: "198.51.200.0/24", Interface: "sdwan-test", RoutingTable: "main", Distance: 5, } if err := mgr.SaveRoute(&sdwanRoute); err != nil { t.Fatalf("failed to save static route pointing to SD-WAN group: %v", err) } // Verify route is present and active routes := mgr.ListRoutes() found := false for _, r := range routes { if r.DstAddress == "198.51.200.0/24" { found = true if r.Interface != "sdwan-test" { t.Errorf("expected route interface sdwan-test, got %s", r.Interface) } break } } if !found { t.Fatalf("SD-WAN static route not found in ListRoutes") } _ = mgr.DeleteRoute(sdwanRoute.ID) }