package main import ( "bytes" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "time" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" ) func prepareInstancesTest(t *testing.T, officeURL, labURL string) *config.Config { t.Helper() dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("NETSSH_NETBOX_URL", "") t.Setenv("NETSSH_NETBOX_TOKEN", "") t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json")) t.Setenv("NETSSH_SHELL_ALIAS", "") data := fmt.Sprintf("netboxes:\n - name: office\n url: %q\n token: office-token\n - name: lab\n url: %q\n token: lab-token\nresolver:\n strategies: [primary_ip]\n", officeURL, labURL) if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil { t.Fatal(err) } cfg, err := config.Load() if err != nil { t.Fatal(err) } return cfg } func multiHost(name, ip string) connectionTestHost { return connectionTestHost{ID: 1, Name: name, PrimaryIP4: &struct { Address string `json:"address"` }{Address: ip + "/24"}} } func TestRefreshStoresSameHostnameFromBothInstances(t *testing.T) { office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil) lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil) cfg := prepareInstancesTest(t, office.URL, lab.URL) cmd := rootCmd() cmd.SetArgs([]string{"cache", "refresh"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) if err := c.Load(); err != nil { t.Fatal(err) } for name, ip := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2"} { got, fresh := c.Get(name) if !fresh || got.IP != ip { t.Fatalf("cache source collision: %+v, fresh=%v", got, fresh) } } if len(c.All()) != 2 || c.NeedsRefresh(time.Hour) { t.Fatal("full refresh is incomplete") } } func TestRefreshFailureAndPerInstanceRefresh(t *testing.T) { office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil) failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "offline", 503) })) defer failed.Close() cfg := prepareInstancesTest(t, office.URL, failed.URL) c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c.Upsert(cache.Entry{Name: "lab/old", Kind: "device", IP: "10.0.0.2"}) if err := c.Save(); err != nil { t.Fatal(err) } cmd := rootCmd() cmd.SetArgs([]string{"cache", "refresh"}) if err := cmd.Execute(); err == nil { t.Fatal("failed instance marked as full refresh") } if err := c.Load(); err != nil { t.Fatal(err) } if len(c.All()) != 1 || !c.NeedsRefresh(time.Hour) { t.Fatal("partial refresh changed cache or full-refresh stamp") } cmd = rootCmd() cmd.SetArgs([]string{"cache", "refresh", "--instance", "office"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } if err := c.Load(); err != nil { t.Fatal(err) } if len(c.All()) != 2 || !c.NeedsRefresh(time.Hour) { t.Fatal("per-instance refresh lost other source or updated full stamp") } } func TestLocalNotesCLIKeepsInstancesSeparate(t *testing.T) { cfg := prepareInstancesTest(t, "https://office.example", "https://lab.example") c := cache.New(cfg.CachePath(), cfg.Cache.TTL) for _, name := range []string{"office/web", "lab/web"} { c.Upsert(cache.Entry{Name: name, Kind: "device", IP: "10.0.0.1"}) } if err := c.Save(); err != nil { t.Fatal(err) } run := func(args ...string) (string, error) { var out bytes.Buffer cmd := rootCmd() cmd.SetArgs(append([]string{"notes"}, args...)) cmd.SetOut(&out) cmd.SetErr(&out) err := cmd.Execute() return out.String(), err } for _, source := range []string{"office", "lab"} { if _, err := run("set", source+"/web", source+" note"); err != nil { t.Fatal(err) } } for _, source := range []string{"office", "lab"} { got, err := run("show", source+"/web") if err != nil || got != source+" note" { t.Fatalf("CLI notes crossed sources: %q, %v", got, err) } } if _, err := run("set", "web", "wrong note"); err == nil { t.Fatal("ambiguous note target accepted") } } func TestMultiInstanceWrapperHelper(t *testing.T) { if target := os.Getenv("NETSSH_TEST_MULTI_TARGET"); target != "" { runSSHWrapper([]string{target}) return } } func TestWrapperRoutesQualifiedTargetsAndRejectsAmbiguity(t *testing.T) { office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil) lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil) cfg := prepareInstancesTest(t, office.URL, lab.URL) c := cache.New(cfg.CachePath(), 3600) if err := c.SetRefreshed(); err != nil { t.Fatal(err) } bin := t.TempDir() if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil { t.Fatal(err) } for target, want := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2", "web": "ambiguous"} { cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$") cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET="+target, "PATH="+bin+":"+os.Getenv("PATH")) out, err := cmd.CombinedOutput() if want == "ambiguous" { if err == nil || !strings.Contains(string(out), "2 exact matches") { t.Fatalf("ambiguous connection accepted: %s, %v", out, err) } } else if err != nil || !strings.Contains(string(out), want+"\n") { t.Fatalf("wrong SSH destination for %s: %s, %v", target, out, err) } } } func TestQualifiedShortcutDoesNotMatchOtherInstance(t *testing.T) { cfg := config.ShortcutsConfig{Domains: []string{".example.com"}, StripHyphens: true} normalize := normalizeTarget(cfg, true) if normalize("office/web-01.example.com") != normalize("office/web01") { t.Fatal("qualified shortcut does not normalize") } if normalize("office/web01") == normalize("lab/web01") { t.Fatal("qualified shortcut lost instance") } } func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) { office := connectionTestServer(t, []connectionTestHost{multiHost("router", "10.1.0.1")}, []connectionTestHost{multiHost("app", "10.1.0.2")}) lab := connectionTestServer(t, []connectionTestHost{multiHost("switch", "10.2.0.1")}, []connectionTestHost{multiHost("test-vm", "10.2.0.2")}) cfg := prepareInstancesTest(t, office.URL, lab.URL) cmd := rootCmd() cmd.SetArgs([]string{"cache", "refresh"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) if err := c.Load(); err != nil { t.Fatal(err) } want := map[string]struct{ kind, ip string }{ "office/router": {"device", "10.1.0.1"}, "office/app": {"vm", "10.1.0.2"}, "lab/switch": {"device", "10.2.0.1"}, "lab/test-vm": {"vm", "10.2.0.2"}, } if len(c.All()) != len(want) { t.Fatalf("missing inventory entries: %+v", c.All()) } for name, entry := range want { got, fresh := c.Get(name) if !fresh || got.Kind != entry.kind || got.IP != entry.ip { t.Fatalf("wrong cached host %s: %+v", name, got) } } }