- Implement SSH dial via stdio for remote connections - Add sources API and storage layer for managing connection sources - Add SourcePanel and SshFields web components for SSH configuration - Update app structure to support source-based connections - Update handlers and server for new sources endpoint Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
203 lines
6.2 KiB
Go
203 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|