package main import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" ) type connectionTestHost struct { ID int `json:"id"` Name string `json:"name"` PrimaryIP4 *struct { Address string `json:"address"` } `json:"primary_ip4,omitempty"` } func connectionTestServer(t *testing.T, devices, vms []connectionTestHost) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var hosts []connectionTestHost switch r.URL.Path { case "/api/dcim/devices/": hosts = devices case "/api/virtualization/virtual-machines/": hosts = vms case "/api/ipam/ip-addresses/": fmt.Fprint(w, `{"count":0,"results":[]}`) return default: http.NotFound(w, r) return } query := strings.ToLower(r.URL.Query().Get("name__ic")) matched := make([]connectionTestHost, 0) for _, host := range hosts { if strings.Contains(strings.ToLower(host.Name), query) { matched = append(matched, host) } } offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit <= 0 { limit = 50 } start, end := min(offset, len(matched)), min(offset+limit, len(matched)) json.NewEncoder(w).Encode(map[string]any{"count": len(matched), "results": matched[start:end]}) })) t.Cleanup(srv.Close) return srv } func TestFindSSHHostBeyondFirstPage(t *testing.T) { var devices []connectionTestHost for i := range 50 { devices = append(devices, connectionTestHost{ID: i + 1, Name: fmt.Sprintf("web-extra-%02d", i)}) } devices = append(devices, connectionTestHost{ID: 51, Name: "web"}) srv := connectionTestServer(t, devices, nil) host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", nil) if err != nil || host.ID != 51 { t.Fatalf("host beyond first page not resolved: %+v, %v", host, err) } } func TestFindSSHHostDomainExpansion(t *testing.T) { for _, domain := range []string{"example.com", ".example.com", " .example.com "} { t.Run(domain, func(t *testing.T) { srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web.example.com"}}, nil) host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{domain}) if err != nil || host.Name != "web.example.com" { t.Fatalf("domain expansion failed: %+v, %v", host, err) } }) } } func TestFindSSHHostRejectsAmbiguity(t *testing.T) { srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web"}, {ID: 2, Name: "web.example.com"}}, []connectionTestHost{{ID: 3, Name: "web"}}) _, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"}) if err == nil || errors.Is(err, netbox.ErrHostNotFound) || !strings.Contains(err.Error(), "2 exact matches") { t.Fatalf("ambiguous name accepted or expanded: %v", err) } } func TestFindSSHHostDoesNotHideAPIErrors(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", http.StatusServiceUnavailable) })) defer srv.Close() _, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"}) if err == nil || errors.Is(err, netbox.ErrHostNotFound) { t.Fatalf("API failure hidden as missing host: %v", err) } } func TestFindSSHHostNotFound(t *testing.T) { srv := connectionTestServer(t, nil, nil) _, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "missing", []string{"example.com"}) if !errors.Is(err, netbox.ErrHostNotFound) { t.Fatalf("want missing-host error, got %v", err) } } func TestCacheRefreshHonorsResolverAndRemovesUnsafeEntries(t *testing.T) { for _, tc := range []struct { name, strategies string wantCached bool }{ {"management only", "[management_subnet]", false}, {"explicit primary fallback", "[management_subnet, primary_ip]", true}, } { t.Run(tc.name, func(t *testing.T) { host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct { Address string `json:"address"` }{Address: "192.0.2.1/24"}} srv := connectionTestServer(t, []connectionTestHost{host}, nil) path := prepareRefreshTest(t, srv.URL, tc.strategies) c := cache.New(path, 3600) c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"}) if err := c.Save(); err != nil { t.Fatal(err) } cmd := rootCmd() cmd.SetArgs([]string{"cache", "refresh"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } if err := c.Load(); err != nil { t.Fatal(err) } got, fresh := c.Get("web") if tc.wantCached { if !fresh || got.IP != "192.0.2.1" { t.Fatalf("explicit fallback failed: %+v", got) } } else if got.Name != "" { t.Fatalf("unsafe primary IP retained: %+v", got) } }) } } func TestCacheRefreshSkipsAmbiguousNames(t *testing.T) { host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct { Address string `json:"address"` }{Address: "192.0.2.1/24"}} vm := host vm.ID = 2 srv := connectionTestServer(t, []connectionTestHost{host}, []connectionTestHost{vm}) path := prepareRefreshTest(t, srv.URL, "[primary_ip]") c := cache.New(path, 3600) c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"}) if err := c.Save(); err != nil { t.Fatal(err) } cmd := rootCmd() cmd.SetArgs([]string{"cache", "refresh"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } if err := c.Load(); err != nil { t.Fatal(err) } if len(c.All()) != 0 { t.Fatalf("ambiguous host cached: %+v", c.All()) } } func prepareRefreshTest(t *testing.T, baseURL, strategies string) string { t.Helper() configDir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", configDir) t.Setenv("XDG_CACHE_HOME", t.TempDir()) path := filepath.Join(t.TempDir(), "hosts.json") t.Setenv("NETSSH_CACHE_PATH", path) t.Setenv("NETSSH_NETBOX_URL", baseURL) t.Setenv("NETSSH_NETBOX_TOKEN", "token") t.Setenv("NETSSH_SHELL_ALIAS", "") data := fmt.Sprintf("resolver:\n strategies: %s\n management_subnets: [10.0.0.0/8]\n", strategies) if err := os.WriteFile(filepath.Join(configDir, "netssh.yaml"), []byte(data), 0o600); err != nil { t.Fatal(err) } return path }