package store import ( "errors" "os" "path/filepath" "strings" "testing" "github.com/arescom/dockmv/internal/sshx" ) func newTestSources(t *testing.T) (*Sources, string) { t.Helper() path := filepath.Join(t.TempDir(), "sources.json") s, err := NewSources(path, Source{Name: "this host", DockerHost: "unix:///var/run/docker.sock"}) if err != nil { t.Fatalf("NewSources: %v", err) } return s, path } func TestSourcesLocalIsAlwaysPresent(t *testing.T) { s, _ := newTestSources(t) list := s.List() if len(list) != 1 || list[0].ID != LocalSourceID || list[0].Kind != SourceLocal { t.Fatalf("expected only the local source, got %+v", list) } if got := s.Selected(); got != LocalSourceID { t.Fatalf("selected = %q, want %q", got, LocalSourceID) } if _, err := s.Save(Source{ID: LocalSourceID, Kind: SourceDocker, DockerHost: "tcp://x:2375"}); err == nil { t.Fatal("editing the local source should be refused") } if err := s.Delete(LocalSourceID); err == nil { t.Fatal("deleting the local source should be refused") } } func TestSourcesValidation(t *testing.T) { s, _ := newTestSources(t) for _, tc := range []struct { name string src Source want string }{ {"no docker address", Source{Kind: SourceDocker}, "docker address is required"}, {"address without scheme", Source{Kind: SourceDocker, DockerHost: "10.0.0.5:2375"}, "needs a scheme"}, {"ssh without host", Source{Kind: SourceSSH, SSH: &sshx.Config{User: "root"}}, "host is required"}, {"ssh without user", Source{Kind: SourceSSH, SSH: &sshx.Config{Host: "h"}}, "user is required"}, {"unknown kind", Source{Kind: "carrier-pigeon"}, "unknown source kind"}, } { t.Run(tc.name, func(t *testing.T) { if _, err := s.Save(tc.src); err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("error = %v, want it to mention %q", err, tc.want) } }) } } func TestSourcesSaveDefaults(t *testing.T) { s, _ := newTestSources(t) saved, err := s.Save(Source{Kind: SourceSSH, SSH: &sshx.Config{Host: "10.0.0.9", User: "root"}}) if err != nil { t.Fatalf("Save: %v", err) } if saved.ID == "" { t.Fatal("an id should have been generated") } if saved.Name != "10.0.0.9" { t.Fatalf("name = %q, want the host as a fallback", saved.Name) } if saved.SSH.Port != 22 { t.Fatalf("port = %d, want 22", saved.SSH.Port) } // A docker source drops any ssh configuration, and the other way round. dock, err := s.Save(Source{Kind: SourceDocker, DockerHost: "tcp://10.0.0.5:2375", SSH: &sshx.Config{Host: "x", User: "y"}}) if err != nil { t.Fatalf("Save: %v", err) } if dock.SSH != nil { t.Fatalf("ssh configuration should be dropped for a docker source: %+v", dock.SSH) } if dock.Name != "tcp://10.0.0.5:2375" { t.Fatalf("name = %q, want the address as a fallback", dock.Name) } } func TestSourcesSecretsStayOffDiskUnlessAsked(t *testing.T) { s, path := newTestSources(t) kept, err := s.Save(Source{ Kind: SourceSSH, SSH: &sshx.Config{Host: "h1", User: "root", Auth: sshx.AuthPassword, Password: "in-memory"}, }) if err != nil { t.Fatalf("Save: %v", err) } if kept.SSH.Password != "" { t.Fatal("the returned form must be redacted") } if lst := s.List(); lst[1].SSH.Password != "" { t.Fatal("List must not hand out credentials") } // Get is the dialling path, so it does see the password. got, err := s.Get(kept.ID) if err != nil || got.SSH.Password != "in-memory" { t.Fatalf("Get password = %q (err %v), want the in-memory secret", got.SSH.Password, err) } remembered, err := s.Save(Source{ Kind: SourceSSH, SSH: &sshx.Config{Host: "h2", User: "root", Auth: sshx.AuthPassword, Password: "on-disk", SaveSecrets: true}, }) if err != nil { t.Fatalf("Save: %v", err) } b, err := os.ReadFile(path) if err != nil { t.Fatalf("read file: %v", err) } if strings.Contains(string(b), "in-memory") { t.Fatal("a secret the operator did not want persisted reached the disk") } if !strings.Contains(string(b), "on-disk") { t.Fatal("a remembered secret should have been written") } // An update that omits the password keeps the one already held. again, err := s.Save(Source{ID: remembered.ID, Kind: SourceSSH, SSH: &sshx.Config{Host: "h2", User: "admin", SaveSecrets: true}}) if err != nil { t.Fatalf("Save: %v", err) } reloaded, err := s.Get(again.ID) if err != nil { t.Fatalf("Get: %v", err) } if reloaded.SSH.Password != "on-disk" { t.Fatalf("password = %q, want it carried forward", reloaded.SSH.Password) } if reloaded.SSH.User != "admin" { t.Fatalf("user = %q, want the update to apply", reloaded.SSH.User) } } func TestSourcesSelectionSurvivesRestart(t *testing.T) { s, path := newTestSources(t) saved, err := s.Save(Source{Name: "prod", Kind: SourceSSH, SSH: &sshx.Config{Host: "10.0.0.9", User: "root", SaveSecrets: true}}) if err != nil { t.Fatalf("Save: %v", err) } if err := s.Select("nope"); !errors.Is(err, ErrNotFound) { t.Fatalf("Select of an unknown id = %v, want ErrNotFound", err) } if err := s.Select(saved.ID); err != nil { t.Fatalf("Select: %v", err) } reopened, err := NewSources(path, Source{Name: "this host"}) if err != nil { t.Fatalf("NewSources: %v", err) } if got := reopened.Selected(); got != saved.ID { t.Fatalf("selected after restart = %q, want %q", got, saved.ID) } if len(reopened.List()) != 2 { t.Fatalf("sources after restart = %+v", reopened.List()) } // Deleting the selected source falls back to the local one. if err := reopened.Delete(saved.ID); err != nil { t.Fatalf("Delete: %v", err) } if got := reopened.Selected(); got != LocalSourceID { t.Fatalf("selected after delete = %q, want %q", got, LocalSourceID) } if _, err := reopened.Get(saved.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("Get after delete = %v, want ErrNotFound", err) } } func TestSourcesUnknownSelectionIgnoredOnLoad(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sources.json") body := `{"selected":"gone","sources":[{"id":"gone-too","name":"x","kind":"docker","dockerHost":"tcp://h:2375"}]}` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write: %v", err) } s, err := NewSources(path, Source{Name: "this host"}) if err != nil { t.Fatalf("NewSources: %v", err) } if got := s.Selected(); got != LocalSourceID { t.Fatalf("selected = %q, want the local fallback", got) } }