package netbox import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" ) func sourceTestServer(t *testing.T, token, ip, comment string) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Token "+token { t.Errorf("credentials crossed sources: %q, want %q", got, token) http.Error(w, "bad token", 401) return } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/dcim/devices/": json.NewEncoder(w).Encode(deviceListResponse(netboxDevice{ID: 1, Name: "web", PrimaryIP4: &netboxIP{Address: ip + "/24"}})) case "/api/virtualization/virtual-machines/": json.NewEncoder(w).Encode(vmListResponse()) case "/api/ipam/ip-addresses/": if r.URL.Query().Get("device_id") != "1" { t.Errorf("wrong host ID: %s", r.URL.RawQuery) } json.NewEncoder(w).Encode(ipListResponse(ip + "/24")) case "/api/dcim/devices/1/": if r.Method == http.MethodPatch { var data map[string]string if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Error(err) } if data["comments"] != comment+" updated" { t.Errorf("comment write crossed sources: %v", data) } } json.NewEncoder(w).Encode(map[string]string{"comments": comment}) default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) return srv } func TestMultiSourceSearchAndRouting(t *testing.T) { office := sourceTestServer(t, "office-token", "10.0.0.1", "office note") lab := sourceTestServer(t, "lab-token", "10.0.0.2", "lab note") c := NewMultiClient([]Source{{Name: "office", URL: office.URL, Token: "office-token"}, {Name: "lab", URL: lab.URL, Token: "lab-token"}}) ctx := context.Background() entries, err := c.Search(ctx, "web", SearchOptions{}) if err != nil || len(entries) != 2 { t.Fatalf("combined search: %+v, %v", entries, err) } if entries[0].Target() != "lab/web" || entries[1].Target() != "office/web" { t.Fatalf("source lost: %+v", entries) } if _, err := c.FindHost(ctx, "web", ""); err == nil { t.Fatal("ambiguous unqualified hostname accepted") } for _, tc := range []struct{ name, ip, note string }{{"office", "10.0.0.1", "office note"}, {"lab", "10.0.0.2", "lab note"}} { t.Run(tc.name, func(t *testing.T) { host, err := c.FindHost(ctx, tc.name+"/web", "") if err != nil { t.Fatal(err) } if host.Source != tc.name || host.Name != "web" || host.ID != 1 { t.Fatalf("wrong host identity: %+v", host) } ips, err := c.GetIPs(ctx, host) if err != nil || len(ips) != 1 || ips[0] != tc.ip { t.Fatalf("wrong IP source: %v, %v", ips, err) } ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0") if err != nil || len(ips) != 1 || ips[0] != tc.ip { t.Fatalf("wrong interface source: %v, %v", ips, err) } text, _, err := c.GetComments(ctx, host) if err != nil || text != tc.note { t.Fatalf("wrong comment source: %q, %v", text, err) } // TUI cache items carry a qualified name rather than a separate source. qualified := HostEntry{Name: tc.name + "/web", ID: 1, Kind: "device"} if err := c.SetComments(ctx, qualified, tc.note+" updated", ""); err != nil { t.Fatal(err) } }) } if _, err := c.Search(ctx, "missing/web", SearchOptions{}); err == nil { t.Fatal("unknown source accepted") } if _, _, err := c.HostScope(HostEntry{Name: "web", Kind: "device"}); err == nil { t.Fatal("unqualified note namespace accepted") } if _, err := c.GetIPsWithFilter(ctx, "device_id=1"); err == nil { t.Fatal("IP filter without instance accepted") } } func TestMultiSourceSearchRunsConcurrently(t *testing.T) { var wg sync.WaitGroup wg.Add(2) makeServer := func() *httptest.Server { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { wg.Done() both := make(chan struct{}) go func() { wg.Wait(); close(both) }() select { case <-both: fmt.Fprint(w, `{"count":0,"results":[]}`) case <-r.Context().Done(): } })) t.Cleanup(srv.Close) return srv } a, b := makeServer(), makeServer() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() c := NewMultiClient([]Source{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}) if _, err := c.Search(ctx, "web", SearchOptions{Kind: "device"}); err != nil { t.Fatalf("sources did not run concurrently: %v", err) } } func TestMultiSourcePartialFailure(t *testing.T) { good := sourceTestServer(t, "good-token", "10.0.0.1", "note") failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", 503) })) defer failed.Close() c := NewMultiClient([]Source{{Name: "good", URL: good.URL, Token: "good-token"}, {Name: "offline", URL: failed.URL}}) ctx := context.Background() entries, err := c.Search(ctx, "web", SearchOptions{}) if len(entries) != 1 || err == nil || !strings.Contains(err.Error(), "offline") { t.Fatalf("partial search lost results or warning: %+v, %v", entries, err) } if _, err := c.SearchAll(ctx, "web", SearchOptions{}); err == nil { t.Fatal("incomplete full search succeeded") } if _, err := c.FindHost(ctx, "web", ""); err == nil { t.Fatal("incomplete search claimed unique host") } if host, err := c.FindHost(ctx, "good/web", ""); err != nil || host.Source != "good" { t.Fatalf("qualified lookup blocked by unrelated outage: %+v, %v", host, err) } } func TestMultiSourceKeepsIndependentDeviceAndVMInventories(t *testing.T) { office := newTestServer(t, map[string]any{ "/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "office-router", PrimaryIP4: &netboxIP{Address: "10.1.0.1/24"}}), "/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "office-vm", PrimaryIP4: &netboxIP{Address: "10.1.0.2/24"}}), }) defer office.Close() lab := newTestServer(t, map[string]any{ "/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "lab-switch", PrimaryIP4: &netboxIP{Address: "10.2.0.1/24"}}), "/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "lab-vm", PrimaryIP4: &netboxIP{Address: "10.2.0.2/24"}}), }) defer lab.Close() client := NewMultiClient([]Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}}) entries, err := client.SearchAll(context.Background(), "", SearchOptions{}) if err != nil { t.Fatal(err) } want := map[string]string{"office/office-router": "device", "office/office-vm": "vm", "lab/lab-switch": "device", "lab/lab-vm": "vm"} if len(entries) != len(want) { t.Fatalf("independent inventories missing: %+v", entries) } for _, entry := range entries { if want[entry.Target()] != entry.Kind { t.Fatalf("inventory crossed source or kind: %+v", entry) } } vms, err := client.Search(context.Background(), "", SearchOptions{Kind: "vm"}) if err != nil || len(vms) != 2 { t.Fatalf("VM filter across instances: %+v, %v", vms, err) } for _, host := range vms { if host.Kind != "vm" { t.Fatalf("device leaked into VM filter: %+v", host) } } host, err := client.FindHost(context.Background(), "lab/lab-vm", "vm") if err != nil || host.Source != "lab" || host.ID != 2 || host.PrimaryIP4 != "10.2.0.2" { t.Fatalf("wrong VM selected: %+v, %v", host, err) } }