diff --git a/internal/proxmox/bootstrap_test.go b/internal/proxmox/bootstrap_test.go index fcc6db1..cfd5483 100644 --- a/internal/proxmox/bootstrap_test.go +++ b/internal/proxmox/bootstrap_test.go @@ -5,10 +5,12 @@ import ( "context" "errors" "log/slog" + "net" "os" "path/filepath" "strings" "testing" + "time" "golang.org/x/crypto/ssh" ) @@ -330,3 +332,159 @@ func TestDeployPubKey_WhitespaceOnlyPubLine(t *testing.T) { t.Error("expected error for whitespace-only pub line") } } + +func TestBootstrapProxmox_FullFlow_IdempotentReRun(t *testing.T) { + srv := newFakeSSHServer(t) + defer srv.close() + + home := t.TempDir() + t.Setenv("ORCA_HOME", home) + if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil { + t.Fatalf("create known_hosts: %v", err) + } + + orig := sshDialer + defer func() { sshDialer = orig }() + origRunner := sessionRunner + defer func() { sessionRunner = origRunner }() + + host, _, _ := net.SplitHostPort(srv.addr()) + sshDialer = &funcDialer{fn: func(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + return fakeSSHClient(t, srv), nil + }} + + for i := 0; i < 2; i++ { + sessionRunner = nil + if _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + }); err != nil { + t.Fatalf("bootstrap run %d: %v", i+1, err) + } + } +} + +func TestBootstrapProxmox_FullFlow_NoPasswordInLogs(t *testing.T) { + srv := newFakeSSHServer(t) + defer srv.close() + + home := t.TempDir() + t.Setenv("ORCA_HOME", home) + if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil { + t.Fatalf("create known_hosts: %v", err) + } + + orig := sshDialer + defer func() { sshDialer = orig }() + origRunner := sessionRunner + defer func() { sessionRunner = origRunner }() + sessionRunner = nil + sshDialer = &staticDialer{client: fakeSSHClient(t, srv)} + + host, _, _ := net.SplitHostPort(srv.addr()) + + var logBuf bytes.Buffer + _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "super-secret-pw-12345", + Logger: slog.New(slog.NewTextHandler(&logBuf, nil)), + }) + if err != nil { + t.Fatalf("BootstrapProxmox: %v", err) + } + out := logBuf.String() + if strings.Contains(out, "super-secret-pw-12345") { + t.Errorf("password leaked into logs (D-031): %s", out) + } +} + +func TestBootstrapProxmox_FullFlow_ValidateSudoersFails(t *testing.T) { + srv := newFakeSSHServer(t) + defer srv.close() + srv.forceSudoersInvalid = true + + home := t.TempDir() + t.Setenv("ORCA_HOME", home) + if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil { + t.Fatalf("create known_hosts: %v", err) + } + + orig := sshDialer + defer func() { sshDialer = orig }() + origRunner := sessionRunner + defer func() { sessionRunner = origRunner }() + sessionRunner = nil + sshDialer = &staticDialer{client: fakeSSHClient(t, srv)} + + host, _, _ := net.SplitHostPort(srv.addr()) + + _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + }) + if err == nil { + t.Fatal("expected error for invalid sudoers") + } + if !strings.Contains(err.Error(), "validate sudoers") { + t.Errorf("error should mention validate sudoers, got: %v", err) + } +} + +func TestDefaultSSHDialer_DialContext_ConnectionRefused(t *testing.T) { + d := defaultSSHDialer{} + cfg := &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ssh.Password("pw")}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 200 * time.Millisecond, + } + _, err := d.DialContext(context.Background(), "tcp", "127.0.0.1:1", cfg) + if err == nil { + t.Fatal("expected error for connection refused") + } +} + +func TestBootstrapProxmox_FullFlow_CreateLinuxUserFails(t *testing.T) { + srv := newFakeSSHServer(t) + defer srv.close() + + home := t.TempDir() + t.Setenv("ORCA_HOME", home) + if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil { + t.Fatalf("create known_hosts: %v", err) + } + + orig := sshDialer + defer func() { sshDialer = orig }() + origRunner := sessionRunner + defer func() { sessionRunner = origRunner }() + sessionRunner = nil + sshDialer = &staticDialer{client: fakeSSHClient(t, srv)} + + host, _, _ := net.SplitHostPort(srv.addr()) + + // ProxmoxUser=root exercises the /root home branch in deployPubKey. + _, err := BootstrapProxmox(t.Context(), Options{ + Host: host, + Password: "pw", + ProxmoxUser: "root", + }) + if err != nil { + t.Fatalf("BootstrapProxmox with ProxmoxUser=root: %v", err) + } +} + +func TestSSHSessionRunner_CombinedOutput_NewSessionError(t *testing.T) { + srv := newFakeSSHServer(t) + defer srv.close() + conn := fakeSSHClient(t, srv) + conn.Close() + r := &sshSessionRunner{client: conn} + _, err := r.CombinedOutput("echo hi") + if err == nil { + t.Fatal("expected error from NewSession on closed client") + } + if !strings.Contains(err.Error(), "new session") { + t.Errorf("error should mention new session, got: %v", err) + } +} diff --git a/internal/proxmox/ssh_session_test.go b/internal/proxmox/ssh_session_test.go index a2941a0..178a87d 100644 --- a/internal/proxmox/ssh_session_test.go +++ b/internal/proxmox/ssh_session_test.go @@ -23,9 +23,10 @@ type fakeSSHServer struct { config *ssh.ServerConfig done chan struct{} - mu sync.Mutex - state map[string]string - authDir string + mu sync.Mutex + state map[string]string + authDir string + forceSudoersInvalid bool } func newFakeSSHServer(t *testing.T) *fakeSSHServer { @@ -142,10 +143,11 @@ func (s *fakeSSHServer) runCommand(cmd string) ([]byte, int) { case strings.HasPrefix(trimmed, "cat > /etc/sudoers.d/"): return s.handleSudoersWrite(trimmed), 0 case strings.HasPrefix(trimmed, "visudo -cf /etc/sudoers.d/orca"): - if s.state["sudoers_valid"] == "true" { - return []byte("/etc/sudoers.d/orca: parsed OK\n"), 0 + force := s.forceSudoersInvalid + if force || s.state["sudoers_valid"] != "true" { + return []byte("/etc/sudoers.d/orca: syntax error\n"), 1 } - return []byte("/etc/sudoers.d/orca: syntax error\n"), 1 + return []byte("/etc/sudoers.d/orca: parsed OK\n"), 0 case strings.HasPrefix(trimmed, "cat /") && strings.HasSuffix(trimmed, "/authorized_keys"): return s.readAuthFile(trimmed[4:]), 0 case strings.HasPrefix(trimmed, "cat /") && strings.Contains(trimmed, "/orca"): @@ -406,6 +408,14 @@ func (d *staticDialer) DialContext(ctx context.Context, network, addr string, co return d.client, nil } +type funcDialer struct { + fn func(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) +} + +func (d *funcDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + return d.fn(ctx, network, addr, config) +} + func TestBootstrapProxmox_FullFlow_Success(t *testing.T) { srv := newFakeSSHServer(t) defer srv.close()