package netbox import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) // newTestServer returns an httptest.Server that serves fixed responses per path. func newTestServer(t *testing.T, handlers map[string]any) *httptest.Server { t.Helper() mux := http.NewServeMux() for path, body := range handlers { b, err := json.Marshal(body) if err != nil { t.Fatalf("marshalling handler for %s: %v", path, err) } captured := b mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(captured) }) } return httptest.NewServer(mux) } func deviceListResponse(devices ...netboxDevice) netboxListResponse[netboxDevice] { return netboxListResponse[netboxDevice]{Count: len(devices), Results: devices} } func vmListResponse(vms ...netboxVM) netboxListResponse[netboxVM] { return netboxListResponse[netboxVM]{Count: len(vms), Results: vms} } func ipListResponse(addrs ...string) netboxIPListResponse { resp := netboxIPListResponse{Count: len(addrs)} for _, a := range addrs { resp.Results = append(resp.Results, struct { Address string `json:"address"` Interface *struct { Name string `json:"name"` } `json:"assigned_object"` }{Address: a}) } return resp } func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/dcim/devices/": deviceListResponse( netboxDevice{ID: 1, Name: "router-01", PrimaryIP4: &netboxIP{Address: "10.0.0.1/24"}}, ), "/api/virtualization/virtual-machines/": vmListResponse( netboxVM{ID: 2, Name: "vm-01", PrimaryIP4: &netboxIP{Address: "10.0.0.2/24"}}, ), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, err := c.Search(context.Background(), "", SearchOptions{}) if err != nil { t.Fatalf("Search: %v", err) } if len(results) != 2 { t.Errorf("got %d results, want 2", len(results)) } names := map[string]bool{} for _, r := range results { names[r.Name] = true } if !names["router-01"] || !names["vm-01"] { t.Errorf("missing expected hosts in results: %v", names) } } func TestSearch_MapsKindCorrectly(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/dcim/devices/": deviceListResponse( netboxDevice{ID: 1, Name: "sw-01"}, ), "/api/virtualization/virtual-machines/": vmListResponse( netboxVM{ID: 2, Name: "vm-01"}, ), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, _ := c.Search(context.Background(), "", SearchOptions{}) for _, r := range results { switch r.Name { case "sw-01": if r.Kind != "device" { t.Errorf("sw-01 kind: got %q, want %q", r.Kind, "device") } case "vm-01": if r.Kind != "vm" { t.Errorf("vm-01 kind: got %q, want %q", r.Kind, "vm") } } } } func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/dcim/devices/": deviceListResponse( netboxDevice{ID: 1, Name: "host", PrimaryIP4: &netboxIP{Address: "192.168.1.10/24"}}, ), "/api/virtualization/virtual-machines/": vmListResponse(), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, _ := c.Search(context.Background(), "host", SearchOptions{}) if len(results) == 0 { t.Fatal("expected at least one result") } if results[0].PrimaryIP4 != "192.168.1.10" { t.Errorf("PrimaryIP4: got %q, want %q", results[0].PrimaryIP4, "192.168.1.10") } } func TestSearch_TagsAreMapped(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/dcim/devices/": deviceListResponse( netboxDevice{ ID: 1, Name: "host", Tags: []struct { Name string `json:"name"` }{{Name: "prod"}, {Name: "mgmt"}}, }, ), "/api/virtualization/virtual-machines/": vmListResponse(), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, _ := c.Search(context.Background(), "", SearchOptions{}) if len(results[0].Tags) != 2 { t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags) } } func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) { // Only devices endpoint works; VMs returns 500. mux := http.NewServeMux() body, _ := json.Marshal(deviceListResponse(netboxDevice{ID: 1, Name: "sw-01"})) mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(body) }) mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) }) srv := httptest.NewServer(mux) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, err := c.Search(context.Background(), "", SearchOptions{}) if err != nil { t.Fatalf("partial failure should not return error, got: %v", err) } if len(results) != 1 || results[0].Name != "sw-01" { t.Errorf("expected device results, got %v", results) } } func TestSearch_BothFail_ReturnsError(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "error", http.StatusInternalServerError) }) srv := httptest.NewServer(mux) defer srv.Close() c := NewClient(srv.URL, "token", 0) _, err := c.Search(context.Background(), "", SearchOptions{}) if err == nil { t.Error("both endpoints failing should return an error") } } func TestGetIPs_Device(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/ipam/ip-addresses/": ipListResponse("10.0.0.1/24", "10.0.0.2/24"), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) ips, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"}) if err != nil { t.Fatalf("GetIPs: %v", err) } if len(ips) != 2 { t.Errorf("got %d IPs, want 2", len(ips)) } if ips[0] != "10.0.0.1" || ips[1] != "10.0.0.2" { t.Errorf("IPs: got %v, want [10.0.0.1 10.0.0.2]", ips) } } func TestGetIPs_VM(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/ipam/ip-addresses/": ipListResponse("172.16.0.5/16"), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) ips, err := c.GetIPs(context.Background(), HostEntry{ID: 2, Kind: "vm"}) if err != nil { t.Fatalf("GetIPs: %v", err) } if len(ips) != 1 || ips[0] != "172.16.0.5" { t.Errorf("IPs: got %v, want [172.16.0.5]", ips) } } func TestGetIPs_UnknownKind(t *testing.T) { c := NewClient("http://localhost", "token", 0) _, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "unknown"}) if err == nil { t.Error("unknown kind should return an error") } } func TestGetIPsWithFilter(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/ipam/ip-addresses/": ipListResponse("10.10.10.1/24"), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0") if err != nil { t.Fatalf("GetIPsWithFilter: %v", err) } if len(ips) != 1 || ips[0] != "10.10.10.1" { t.Errorf("IPs: got %v, want [10.10.10.1]", ips) } } func TestTokenVersion(t *testing.T) { tests := []struct { token string want int }{ {"nbt_abc123", 2}, {"nbt_", 2}, {"abc123def456", 1}, {"", 1}, {"Token abc", 1}, } for _, tt := range tests { if got := TokenVersion(tt.token); got != tt.want { t.Errorf("TokenVersion(%q) = %d, want %d", tt.token, got, tt.want) } } } func TestNewClient_AutoDetectsVersion(t *testing.T) { c := NewClient("http://localhost", "nbt_secret", 0) if c.tokenVersion != 2 { t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion) } c2 := NewClient("http://localhost", "legacytoken", 0) if c2.tokenVersion != 1 { t.Errorf("tokenVersion: got %d, want 1", c2.tokenVersion) } } func TestNewClient_RespectsExplicitVersion(t *testing.T) { // Explicit version overrides auto-detection. c := NewClient("http://localhost", "legacytoken", 2) if c.tokenVersion != 2 { t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion) } } func Test403_V1Token_HintsUpgrade(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "forbidden", http.StatusForbidden) })) defer srv.Close() c := NewClient(srv.URL, "legacytoken", 1) _, err := c.Search(context.Background(), "host", SearchOptions{}) if err == nil { t.Fatal("expected error on 403") } if !strings.Contains(err.Error(), "v1 token") { t.Errorf("expected v1 hint in error, got: %v", err) } } func Test403_V2Token_NoV1Hint(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "forbidden", http.StatusForbidden) })) defer srv.Close() c := NewClient(srv.URL, "nbt_secret", 2) _, err := c.Search(context.Background(), "host", SearchOptions{}) if err == nil { t.Fatal("expected error on 403") } if strings.Contains(err.Error(), "v1 token") { t.Errorf("v1 hint should not appear for v2 token, got: %v", err) } if !strings.Contains(err.Error(), "check token permissions") { t.Errorf("expected permissions hint in error, got: %v", err) } } func TestGet_SendsAuthorizationHeader(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") b, _ := json.Marshal(deviceListResponse()) w.Write(b) })) defer srv.Close() c := NewClient(srv.URL, "nbt_mytoken", 2) c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck want := "Token nbt_mytoken" if gotAuth != want { t.Errorf("Authorization header: got %q, want %q", gotAuth, want) } } func TestSearchAll_PaginatesResults(t *testing.T) { // Simulate a NetBox endpoint with 3 total devices split across 2 pages. // count=3 throughout; first page has 2 results, second has 1. callCount := 0 mux := http.NewServeMux() mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) { callCount++ offset := r.URL.Query().Get("offset") w.Header().Set("Content-Type", "application/json") var resp netboxListResponse[netboxDevice] switch offset { case "", "0": resp = netboxListResponse[netboxDevice]{ Count: 3, Results: []netboxDevice{{ID: 1, Name: "d-01"}, {ID: 2, Name: "d-02"}}, } default: resp = netboxListResponse[netboxDevice]{ Count: 3, Results: []netboxDevice{{ID: 3, Name: "d-03"}}, } } b, _ := json.Marshal(resp) w.Write(b) }) mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") b, _ := json.Marshal(vmListResponse()) w.Write(b) }) srv := httptest.NewServer(mux) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, err := c.SearchAll(context.Background(), "", SearchOptions{}) if err != nil { t.Fatalf("SearchAll: %v", err) } if len(results) != 3 { t.Errorf("SearchAll: got %d results, want 3", len(results)) } if callCount < 2 { t.Errorf("expected at least 2 device API calls for pagination, got %d", callCount) } } func TestSearch_KindFilterDeviceOnly(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/dcim/devices/": deviceListResponse( netboxDevice{ID: 1, Name: "sw-01"}, ), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, err := c.Search(context.Background(), "", SearchOptions{Kind: "device"}) if err != nil { t.Fatalf("Search: %v", err) } if len(results) != 1 || results[0].Name != "sw-01" { t.Errorf("expected 1 device result, got %v", results) } } func TestSearch_KindFilterVMOnly(t *testing.T) { srv := newTestServer(t, map[string]any{ "/api/virtualization/virtual-machines/": vmListResponse( netboxVM{ID: 1, Name: "vm-01"}, ), }) defer srv.Close() c := NewClient(srv.URL, "token", 0) results, err := c.Search(context.Background(), "", SearchOptions{Kind: "vm"}) if err != nil { t.Fatalf("Search: %v", err) } if len(results) != 1 || results[0].Name != "vm-01" { t.Errorf("expected 1 vm result, got %v", results) } } func TestStripPrefix(t *testing.T) { tests := []struct { in string want string }{ {"10.0.0.1/24", "10.0.0.1"}, {"::1/128", "::1"}, {"192.168.1.1", "192.168.1.1"}, // no prefix — unchanged {"", ""}, } for _, tt := range tests { if got := stripPrefix(tt.in); got != tt.want { t.Errorf("stripPrefix(%q) = %q, want %q", tt.in, got, tt.want) } } }