Files
Sebastian Unterschütz d127a3b957
Release / release (push) Successful in 49s
feat: enhance host resolution, filtering, and cache management
- **Strategies**: Add resolver strategy input validation and parsing in setup wizard. Support comma-separated input with known strategy mapping.
- **Client**: Extend Search and SearchAll to include kind and tag filters. Add pagination for full cache refresh handling large datasets.
- **Cache**: Introduce `RecentlyUsed` and `MarkUsed`. Persist `LastUsed` timestamps for entries.
- **TUI**: Add recent hosts view, tag/kind filters, and inline editor for user/port override.
- **Tests**: Comprehensive unit tests for new features, including strategy validation, cache behavior, and client filtering.
- **Docs**: Update README with new TUI features and cache subcommands.
2026-05-23 17:06:24 +02:00

437 lines
12 KiB
Go

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)
}
}
}