package netbox import ( "context" "errors" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" ) // patchedDevice records whether the fake NetBox saw a PATCH on device 12. var patchedDevice atomic.Bool // fakeNetBox serves canned NetBox v4.x JSON for the endpoints bootapi calls. // The payloads are trimmed but structurally faithful to real API responses. func fakeNetBox(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() // Interfaces filtered by MAC -> the interface (with nested device brief). mux.HandleFunc("/api/dcim/interfaces/", func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Token testtoken" { http.Error(w, `{"detail":"auth"}`, http.StatusForbidden) return } q := r.URL.Query() switch { case q.Get("mac_address") == "aa:bb:cc:00:11:22": writeJSON(w, `{"count":1,"results":[ {"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22", "untagged_vlan":{"vid":100,"name":"prod"}, "device":{"id":12,"name":"web01","slug":""}}]}`) case q.Get("device_id") == "12": // Full interface list for the device (two NICs). writeJSON(w, `{"count":2,"results":[ {"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22","untagged_vlan":{"vid":100,"name":"prod"}}, {"id":41,"name":"eth1","mac_address":"AA:BB:CC:00:11:33"}]}`) default: writeJSON(w, `{"count":0,"results":[]}`) } }) // Device detail (GET) + pxe_enabled write (PATCH). mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/12/") { patchedDevice.Store(true) writeJSON(w, `{"id":12,"name":"web01"}`) return } if strings.HasSuffix(r.URL.Path, "/12/") { writeJSON(w, `{ "id":12,"name":"web01", "platform":{"id":3,"name":"AlmaLinux 9","slug":"almalinux9"}, "role":{"id":2,"name":"K8s Worker","slug":"kubernetes-worker"}, "site":{"slug":"syd1"}, "primary_ip":{"address":"10.0.1.20/24"}, "custom_fields":{"domain":"syd1.au.unkin.net","gateway":"10.0.1.254","nameservers":"10.0.0.1,10.0.0.2","provision_template":null,"pxe_enabled":true}}`) return } // name= query (HostByName) if r.URL.Query().Get("name") == "web01" { writeJSON(w, `{"count":1,"results":[{"id":12,"name":"web01"}]}`) return } writeJSON(w, `{"count":0,"results":[]}`) }) // IP addresses for the device: eth0 has the primary, eth1 a second addr. mux.HandleFunc("/api/ipam/ip-addresses/", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, `{"count":2,"results":[ {"address":"10.0.1.20/24","assigned_object_id":40,"custom_fields":{"gateway":null}}, {"address":"10.9.9.5/24","assigned_object_id":41,"custom_fields":{"gateway":"10.9.9.1"}}]}`) }) return httptest.NewServer(mux) } func writeJSON(w http.ResponseWriter, body string) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(body)) } func newTestClient(t *testing.T, base string) *Client { t.Helper() return New(Options{BaseURL: base, Token: "testtoken"}) } func TestHostByMAC(t *testing.T) { srv := fakeNetBox(t) defer srv.Close() c := newTestClient(t, srv.URL) h, err := c.HostByMAC(context.Background(), "AA-BB-CC-00-11-22") if err != nil { t.Fatalf("HostByMAC: %v", err) } if h.Hostname != "web01" { t.Errorf("hostname = %q, want web01", h.Hostname) } if h.FQDN != "web01.syd1.au.unkin.net" { t.Errorf("fqdn = %q", h.FQDN) } if h.Platform != "almalinux9" || h.OSFamily != "almalinux" || h.OSVersion != "9" { t.Errorf("platform=%q family=%q version=%q", h.Platform, h.OSFamily, h.OSVersion) } if h.Role != "kubernetes-worker" { t.Errorf("role = %q", h.Role) } if h.PrimaryIP != "10.0.1.20" { t.Errorf("primaryIP = %q", h.PrimaryIP) } if h.DeviceID != 12 { t.Errorf("deviceID = %d, want 12", h.DeviceID) } if h.PXEEnabled == nil || !*h.PXEEnabled || !h.ShouldPXEInstall() { t.Errorf("pxe_enabled = %v, want true", h.PXEEnabled) } if len(h.Nameservers) != 2 || h.Nameservers[0] != "10.0.0.1" { t.Errorf("nameservers = %v", h.Nameservers) } if len(h.Interfaces) != 2 { t.Fatalf("interfaces = %d, want 2", len(h.Interfaces)) } // Primary interface (eth0, carrying the primary IP) must sort first. pi := h.PrimaryInterface() if pi == nil || pi.Name != "eth0" || !pi.Primary { t.Fatalf("primary interface = %+v", pi) } if pi.MAC != "aa:bb:cc:00:11:22" { t.Errorf("primary MAC = %q (want normalized lower-colon)", pi.MAC) } if pi.IP != "10.0.1.20" || pi.Netmask != "255.255.255.0" || pi.PrefixLen != 24 { t.Errorf("primary iface addr = %+v", pi) } // eth0 gateway comes from the device custom field (its IP had none). if pi.Gateway != "10.0.1.254" { t.Errorf("primary gateway = %q, want device CF 10.0.1.254", pi.Gateway) } if pi.VLAN != 100 { t.Errorf("primary vlan = %d", pi.VLAN) } // eth1's IP custom field gateway wins over the device default. for i := range h.Interfaces { if h.Interfaces[i].Name == "eth1" { if h.Interfaces[i].Gateway != "10.9.9.1" { t.Errorf("eth1 gateway = %q, want per-IP CF 10.9.9.1", h.Interfaces[i].Gateway) } } } } func TestHostByMACNotFound(t *testing.T) { srv := fakeNetBox(t) defer srv.Close() c := newTestClient(t, srv.URL) _, err := c.HostByMAC(context.Background(), "de:ad:be:ef:00:00") if !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestHostByName(t *testing.T) { srv := fakeNetBox(t) defer srv.Close() c := newTestClient(t, srv.URL) // FQDN input should be reduced to the short name for the NetBox query. h, err := c.HostByName(context.Background(), "web01.syd1.au.unkin.net") if err != nil { t.Fatalf("HostByName: %v", err) } if h.Hostname != "web01" || h.PrimaryIP != "10.0.1.20" { t.Errorf("host = %+v", h) } } func TestHostByNameNotFound(t *testing.T) { srv := fakeNetBox(t) defer srv.Close() c := newTestClient(t, srv.URL) if _, err := c.HostByName(context.Background(), "nope"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestAuthTokenRequired(t *testing.T) { srv := fakeNetBox(t) defer srv.Close() // Client with the wrong token -> NetBox 403 -> surfaced as an error. c := New(Options{BaseURL: srv.URL, Token: "wrong"}) if _, err := c.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err == nil { t.Fatal("expected an auth error, got nil") } } func TestSetPXEEnabled(t *testing.T) { srv := fakeNetBox(t) defer srv.Close() patchedDevice.Store(false) c := newTestClient(t, srv.URL) if err := c.SetPXEEnabled(context.Background(), 12, false); err != nil { t.Fatalf("SetPXEEnabled: %v", err) } if !patchedDevice.Load() { t.Error("expected a PATCH to device 12, got none") } } func TestCfBool(t *testing.T) { tr := true cases := []struct { cf map[string]any want *bool }{ {map[string]any{"pxe_enabled": true}, &tr}, {map[string]any{"pxe_enabled": "false"}, boolp(false)}, {map[string]any{"pxe_enabled": nil}, nil}, {map[string]any{}, nil}, } for _, c := range cases { got := cfBool(c.cf, "pxe_enabled") switch { case got == nil && c.want == nil: case got != nil && c.want != nil && *got == *c.want: default: t.Errorf("cfBool(%v) = %v, want %v", c.cf, got, c.want) } } } func boolp(b bool) *bool { return &b } func TestNormalizeMAC(t *testing.T) { cases := map[string]string{ "AA:BB:CC:00:11:22": "aa:bb:cc:00:11:22", "aa-bb-cc-00-11-22": "aa:bb:cc:00:11:22", "aabb.cc00.1122": "aa:bb:cc:00:11:22", "AABBCC001122": "aa:bb:cc:00:11:22", } for in, want := range cases { if got := normalizeMAC(in); got != want { t.Errorf("normalizeMAC(%q) = %q, want %q", in, got, want) } } } func TestSplitPlatform(t *testing.T) { cases := []struct{ in, fam, ver string }{ {"almalinux9", "almalinux", "9"}, {"fedora42", "fedora", "42"}, {"rocky9.4", "rocky", "9"}, {"debian", "debian", ""}, } for _, c := range cases { f, v := splitPlatform(c.in) if f != c.fam || v != c.ver { t.Errorf("splitPlatform(%q) = (%q,%q), want (%q,%q)", c.in, f, v, c.fam, c.ver) } } } func TestNetmaskFor(t *testing.T) { cases := map[int]string{24: "255.255.255.0", 16: "255.255.0.0", 25: "255.255.255.128", 0: ""} for prefix, want := range cases { if got := netmaskFor(prefix); got != want { t.Errorf("netmaskFor(%d) = %q, want %q", prefix, got, want) } } }