package ssh import ( "context" "encoding/json" "fmt" "log/slog" "os" "testing" "github.com/shankar0123/certctl/internal/connector/target" ) // testLogger returns a slog.Logger for test output. func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn})) } // --- Mock SSH Client --- // mockSSHClient records all calls and returns configurable results. type mockSSHClient struct { connectCalls int connectErr error writeFileCalls []writeFileCall writeFileErr error executeCalls []string executeOutput string executeErr error statFileCalls []string statFileSize int64 statFileErr error closeCalls int } type writeFileCall struct { Path string Data []byte Mode os.FileMode } func (m *mockSSHClient) Connect(ctx context.Context) error { m.connectCalls++ return m.connectErr } func (m *mockSSHClient) WriteFile(remotePath string, data []byte, mode os.FileMode) error { m.writeFileCalls = append(m.writeFileCalls, writeFileCall{Path: remotePath, Data: data, Mode: mode}) return m.writeFileErr } func (m *mockSSHClient) Execute(ctx context.Context, command string) (string, error) { m.executeCalls = append(m.executeCalls, command) return m.executeOutput, m.executeErr } func (m *mockSSHClient) StatFile(remotePath string) (int64, error) { m.statFileCalls = append(m.statFileCalls, remotePath) return m.statFileSize, m.statFileErr } func (m *mockSSHClient) Close() error { m.closeCalls++ return nil } // --- ValidateConfig tests --- func TestValidateConfig_Success_KeyAuth(t *testing.T) { // Create a temporary key file keyFile := createTempKeyFile(t) cfg := map[string]interface{}{ "host": "server.example.com", "user": "deploy", "auth_method": "key", "private_key_path": keyFile, "cert_path": "/etc/ssl/certs/cert.pem", "key_path": "/etc/ssl/private/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err != nil { t.Fatalf("expected no error, got %v", err) } if c.config.Port != 22 { t.Errorf("expected default port 22, got %d", c.config.Port) } if c.config.CertMode != "0644" { t.Errorf("expected default cert_mode 0644, got %s", c.config.CertMode) } if c.config.KeyMode != "0600" { t.Errorf("expected default key_mode 0600, got %s", c.config.KeyMode) } if c.config.Timeout != 30 { t.Errorf("expected default timeout 30, got %d", c.config.Timeout) } } func TestValidateConfig_Success_InlineKey(t *testing.T) { cfg := map[string]interface{}{ "host": "10.0.0.5", "user": "root", "auth_method": "key", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err != nil { t.Fatalf("expected no error, got %v", err) } } func TestValidateConfig_Success_PasswordAuth(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "auth_method": "password", "password": "s3cret", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err != nil { t.Fatalf("expected no error, got %v", err) } } func TestValidateConfig_InvalidJSON(t *testing.T) { c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) err := c.ValidateConfig(context.Background(), json.RawMessage(`{invalid`)) if err == nil { t.Fatal("expected error for invalid JSON") } } func TestValidateConfig_MissingHost(t *testing.T) { cfg := map[string]interface{}{ "user": "deploy", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for missing host") } } func TestValidateConfig_MissingUser(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for missing user") } } func TestValidateConfig_MissingCertPath(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for missing cert_path") } } func TestValidateConfig_MissingKeyPath(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "cert_path": "/etc/ssl/cert.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for missing key_path") } } func TestValidateConfig_KeyAuth_MissingKey(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "auth_method": "key", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for key auth missing both private_key and private_key_path") } } func TestValidateConfig_PasswordAuth_MissingPassword(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "auth_method": "password", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for password auth missing password") } } func TestValidateConfig_InvalidHost(t *testing.T) { cfg := map[string]interface{}{ "host": "server;rm -rf /", "user": "deploy", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", "private_key": "fake", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for host with shell metacharacters") } } func TestValidateConfig_InvalidPermissions(t *testing.T) { keyFile := createTempKeyFile(t) cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "private_key_path": keyFile, "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", "cert_mode": "999", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for invalid cert_mode") } } func TestValidateConfig_ReloadCommandInjection(t *testing.T) { tests := []struct { name string command string }{ {"semicolon", "systemctl reload nginx; rm -rf /"}, {"pipe", "systemctl reload nginx | cat"}, {"backtick", "systemctl reload `malicious`"}, {"command substitution", "systemctl reload $(evil)"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { keyFile := createTempKeyFile(t) cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "private_key_path": keyFile, "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", "reload_command": tc.command, } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatalf("expected error for reload command injection: %q", tc.command) } }) } } func TestValidateConfig_InvalidAuthMethod(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "auth_method": "kerberos", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for invalid auth method") } } func TestValidateConfig_KeyFileNotFound(t *testing.T) { cfg := map[string]interface{}{ "host": "server.local", "user": "deploy", "auth_method": "key", "private_key_path": "/nonexistent/key.pem", "cert_path": "/etc/ssl/cert.pem", "key_path": "/etc/ssl/key.pem", } c := NewWithClient(&Config{}, &mockSSHClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for nonexistent key file") } } // --- DeployCertificate tests --- func TestDeployCertificate_Success_NoChainPath(t *testing.T) { mock := &mockSSHClient{statFileSize: 1024} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.DeploymentRequest{ CertPEM: "-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----", KeyPEM: "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----", ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----", } result, err := c.DeployCertificate(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Success { t.Fatalf("expected success, got %s", result.Message) } // Should have 2 writes (cert with chain appended, key) if len(mock.writeFileCalls) != 2 { t.Fatalf("expected 2 write calls, got %d", len(mock.writeFileCalls)) } // Cert should include chain (fullchain) certWrite := mock.writeFileCalls[0] if certWrite.Path != "/etc/ssl/cert.pem" { t.Errorf("expected cert path /etc/ssl/cert.pem, got %s", certWrite.Path) } if certWrite.Mode != 0644 { t.Errorf("expected cert mode 0644, got %v", certWrite.Mode) } certContent := string(certWrite.Data) if len(certContent) == 0 { t.Error("cert data should not be empty") } // Key write keyWrite := mock.writeFileCalls[1] if keyWrite.Path != "/etc/ssl/key.pem" { t.Errorf("expected key path /etc/ssl/key.pem, got %s", keyWrite.Path) } if keyWrite.Mode != 0600 { t.Errorf("expected key mode 0600, got %v", keyWrite.Mode) } // Metadata if result.Metadata["host"] != "server.local" { t.Errorf("expected host metadata server.local, got %s", result.Metadata["host"]) } } func TestDeployCertificate_Success_SeparateChain(t *testing.T) { mock := &mockSSHClient{} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", ChainPath: "/etc/ssl/chain.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.DeploymentRequest{ CertPEM: "cert-data", KeyPEM: "key-data", ChainPEM: "chain-data", } result, err := c.DeployCertificate(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Success { t.Fatalf("expected success, got %s", result.Message) } // Should have 3 writes (cert, key, chain) if len(mock.writeFileCalls) != 3 { t.Fatalf("expected 3 write calls, got %d", len(mock.writeFileCalls)) } // Chain should be separate chainWrite := mock.writeFileCalls[2] if chainWrite.Path != "/etc/ssl/chain.pem" { t.Errorf("expected chain path /etc/ssl/chain.pem, got %s", chainWrite.Path) } } func TestDeployCertificate_Success_WithReload(t *testing.T) { mock := &mockSSHClient{executeOutput: "ok"} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", ReloadCommand: "systemctl reload nginx", } c := NewWithClient(cfg, mock, testLogger()) req := target.DeploymentRequest{ CertPEM: "cert", KeyPEM: "key", } result, err := c.DeployCertificate(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Success { t.Fatalf("expected success, got %s", result.Message) } // Should have executed reload command if len(mock.executeCalls) != 1 { t.Fatalf("expected 1 execute call, got %d", len(mock.executeCalls)) } if mock.executeCalls[0] != "systemctl reload nginx" { t.Errorf("expected reload command, got %s", mock.executeCalls[0]) } } func TestDeployCertificate_MissingKeyPEM(t *testing.T) { mock := &mockSSHClient{} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.DeploymentRequest{ CertPEM: "cert", KeyPEM: "", // Missing } result, err := c.DeployCertificate(context.Background(), req) if err == nil { t.Fatal("expected error for missing KeyPEM") } if result.Success { t.Fatal("expected failure result") } } func TestDeployCertificate_ConnectionFailure(t *testing.T) { mock := &mockSSHClient{connectErr: fmt.Errorf("connection refused")} cfg := &Config{ Host: "unreachable.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.DeploymentRequest{ CertPEM: "cert", KeyPEM: "key", } result, err := c.DeployCertificate(context.Background(), req) if err == nil { t.Fatal("expected error for connection failure") } if result.Success { t.Fatal("expected failure result") } } func TestDeployCertificate_WriteFailure(t *testing.T) { mock := &mockSSHClient{writeFileErr: fmt.Errorf("permission denied")} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.DeploymentRequest{ CertPEM: "cert", KeyPEM: "key", } result, err := c.DeployCertificate(context.Background(), req) if err == nil { t.Fatal("expected error for write failure") } if result.Success { t.Fatal("expected failure result") } } func TestDeployCertificate_ReloadFailure(t *testing.T) { mock := &mockSSHClient{executeErr: fmt.Errorf("reload failed: exit status 1"), executeOutput: "error"} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", ReloadCommand: "systemctl reload nginx", } c := NewWithClient(cfg, mock, testLogger()) req := target.DeploymentRequest{ CertPEM: "cert", KeyPEM: "key", } result, err := c.DeployCertificate(context.Background(), req) if err == nil { t.Fatal("expected error for reload failure") } if result.Success { t.Fatal("expected failure result") } } // --- ValidateDeployment tests --- func TestValidateDeployment_Success(t *testing.T) { mock := &mockSSHClient{statFileSize: 2048} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.ValidationRequest{ CertificateID: "mc-test", Serial: "ABC123", } result, err := c.ValidateDeployment(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Valid { t.Fatalf("expected valid, got %s", result.Message) } // Should have stat'd both files if len(mock.statFileCalls) != 2 { t.Fatalf("expected 2 stat calls, got %d", len(mock.statFileCalls)) } if mock.statFileCalls[0] != "/etc/ssl/cert.pem" { t.Errorf("expected cert path, got %s", mock.statFileCalls[0]) } if mock.statFileCalls[1] != "/etc/ssl/key.pem" { t.Errorf("expected key path, got %s", mock.statFileCalls[1]) } } func TestValidateDeployment_CertNotFound(t *testing.T) { mock := &mockSSHClient{statFileErr: fmt.Errorf("file not found")} cfg := &Config{ Host: "server.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.ValidationRequest{ CertificateID: "mc-test", Serial: "ABC123", } result, err := c.ValidateDeployment(context.Background(), req) if err == nil { t.Fatal("expected error for missing cert") } if result.Valid { t.Fatal("expected invalid result") } } func TestValidateDeployment_ConnectionFailure(t *testing.T) { mock := &mockSSHClient{connectErr: fmt.Errorf("connection refused")} cfg := &Config{ Host: "unreachable.local", Port: 22, CertPath: "/etc/ssl/cert.pem", KeyPath: "/etc/ssl/key.pem", CertMode: "0644", KeyMode: "0600", } c := NewWithClient(cfg, mock, testLogger()) req := target.ValidationRequest{ CertificateID: "mc-test", Serial: "ABC123", } result, err := c.ValidateDeployment(context.Background(), req) if err == nil { t.Fatal("expected error for connection failure") } if result.Valid { t.Fatal("expected invalid result") } } // --- Helper tests --- func TestParsePermissions(t *testing.T) { tests := []struct { input string expected os.FileMode wantErr bool }{ {"0644", 0644, false}, {"0600", 0600, false}, {"0755", 0755, false}, {"invalid", 0, true}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { mode, err := parsePermissions(tc.input) if tc.wantErr && err == nil { t.Fatal("expected error") } if !tc.wantErr && err != nil { t.Fatalf("unexpected error: %v", err) } if !tc.wantErr && mode != tc.expected { t.Errorf("expected %v, got %v", tc.expected, mode) } }) } } func TestApplyDefaults(t *testing.T) { cfg := &Config{} applyDefaults(cfg) if cfg.Port != 22 { t.Errorf("expected port 22, got %d", cfg.Port) } if cfg.AuthMethod != "key" { t.Errorf("expected auth_method key, got %s", cfg.AuthMethod) } if cfg.CertMode != "0644" { t.Errorf("expected cert_mode 0644, got %s", cfg.CertMode) } if cfg.KeyMode != "0600" { t.Errorf("expected key_mode 0600, got %s", cfg.KeyMode) } if cfg.Timeout != 30 { t.Errorf("expected timeout 30, got %d", cfg.Timeout) } } // TestDeployCertificate_FullChainMode tests that when ChainPath is not set but // ChainPEM is provided, the chain is appended to the certificate data before writing. func TestDeployCertificate_FullChainMode(t *testing.T) { keyFile := createTempKeyFile(t) cfg := &Config{ Host: "example.com", Port: 22, User: "deploy", AuthMethod: "key", PrivateKeyPath: keyFile, CertPath: "/etc/ssl/certs/cert.pem", KeyPath: "/etc/ssl/private/key.pem", ChainPath: "", // Not set, so chain should be appended to cert CertMode: "0644", KeyMode: "0600", Timeout: 30, } mock := &mockSSHClient{} connector := NewWithClient(cfg, mock, testLogger()) deployReq := target.DeploymentRequest{ CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBk...\n-----END CERTIFICATE-----", KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIBj...\n-----END CERTIFICATE-----", } result, err := connector.DeployCertificate(context.Background(), deployReq) if err != nil { t.Fatalf("deployment failed: %v", err) } if !result.Success { t.Fatalf("deployment result was not successful: %s", result.Message) } // Verify that the cert file received contains both cert and chain concatenated if len(mock.writeFileCalls) < 2 { t.Fatalf("expected at least 2 WriteFile calls, got %d", len(mock.writeFileCalls)) } certWriteCall := mock.writeFileCalls[0] if certWriteCall.Path != "/etc/ssl/certs/cert.pem" { t.Errorf("expected cert path /etc/ssl/certs/cert.pem, got %s", certWriteCall.Path) } certData := string(certWriteCall.Data) if !containsString(certData, "BEGIN CERTIFICATE") || !containsString(certData, "END CERTIFICATE") { t.Errorf("cert data should contain combined cert and chain") } // Verify chain was not written separately (since ChainPath is empty) if len(mock.writeFileCalls) > 2 { t.Errorf("expected only 2 WriteFile calls (cert + key), got %d", len(mock.writeFileCalls)) } } // TestDeployCertificate_Permissions tests that the correct file permissions are // passed to WriteFile for both certificate and key files. func TestDeployCertificate_Permissions(t *testing.T) { keyFile := createTempKeyFile(t) cfg := &Config{ Host: "example.com", Port: 22, User: "deploy", AuthMethod: "key", PrivateKeyPath: keyFile, CertPath: "/etc/ssl/certs/cert.pem", KeyPath: "/etc/ssl/private/key.pem", ChainPath: "", CertMode: "0644", KeyMode: "0600", Timeout: 30, } mock := &mockSSHClient{} connector := NewWithClient(cfg, mock, testLogger()) deployReq := target.DeploymentRequest{ CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBk...\n-----END CERTIFICATE-----", KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", ChainPEM: "", } _, err := connector.DeployCertificate(context.Background(), deployReq) if err != nil { t.Fatalf("deployment failed: %v", err) } if len(mock.writeFileCalls) < 2 { t.Fatalf("expected at least 2 WriteFile calls, got %d", len(mock.writeFileCalls)) } // Check cert file permissions (0644 = rw-r--r--) certMode := mock.writeFileCalls[0].Mode expectedCertMode := os.FileMode(0644) if certMode != expectedCertMode { t.Errorf("expected cert mode 0644, got %o", certMode) } // Check key file permissions (0600 = rw-------) keyMode := mock.writeFileCalls[1].Mode expectedKeyMode := os.FileMode(0600) if keyMode != expectedKeyMode { t.Errorf("expected key mode 0600, got %o", keyMode) } } // TestValidateDeployment_KeyNotFound tests that ValidateDeployment fails when // the key file is not found on the remote server. func TestValidateDeployment_KeyNotFound(t *testing.T) { keyFile := createTempKeyFile(t) cfg := &Config{ Host: "example.com", Port: 22, User: "deploy", AuthMethod: "key", PrivateKeyPath: keyFile, CertPath: "/etc/ssl/certs/cert.pem", KeyPath: "/etc/ssl/private/key.pem", ChainPath: "", CertMode: "0644", KeyMode: "0600", Timeout: 30, } // Create a custom mock that succeeds for cert but fails for key mock := &conditionalStatMockSSHClient{ base: &mockSSHClient{}, } connector := NewWithClient(cfg, mock, testLogger()) valReq := target.ValidationRequest{ Serial: "11111", } result, err := connector.ValidateDeployment(context.Background(), valReq) if err == nil { t.Error("expected validation to fail when key file is not found") } if result.Valid { t.Error("expected Valid=false when key file is missing") } if !containsString(result.Message, "key file not found") { t.Errorf("expected 'key file not found' in message, got: %s", result.Message) } } // conditionalStatMockSSHClient wraps mockSSHClient to fail on key path during StatFile. type conditionalStatMockSSHClient struct { base *mockSSHClient callCount int } func (m *conditionalStatMockSSHClient) Connect(ctx context.Context) error { return m.base.Connect(ctx) } func (m *conditionalStatMockSSHClient) WriteFile(remotePath string, data []byte, mode os.FileMode) error { return m.base.WriteFile(remotePath, data, mode) } func (m *conditionalStatMockSSHClient) Execute(ctx context.Context, command string) (string, error) { return m.base.Execute(ctx, command) } func (m *conditionalStatMockSSHClient) StatFile(remotePath string) (int64, error) { m.callCount++ // First call succeeds (cert), second call fails (key) if m.callCount == 2 { return 0, fmt.Errorf("file not found") } return 1024, nil } func (m *conditionalStatMockSSHClient) Close() error { return m.base.Close() } // --- Helpers --- // createTempKeyFile creates a temporary file that simulates an SSH private key. func createTempKeyFile(t *testing.T) string { t.Helper() dir := t.TempDir() keyFile := dir + "/id_rsa" if err := os.WriteFile(keyFile, []byte("fake-key-data"), 0600); err != nil { t.Fatalf("failed to create temp key file: %v", err) } return keyFile } // containsString is a helper to check if a string contains a substring. func containsString(s, substr string) bool { return len(s) >= len(substr) && stringIndex(s, substr) != -1 } // stringIndex returns the index of the first occurrence of substr in s, or -1 if not found. func stringIndex(s, substr string) int { for i := 0; i <= len(s)-len(substr); i++ { match := true for j := 0; j < len(substr); j++ { if s[i+j] != substr[j] { match = false break } } if match { return i } } return -1 }