package f5 import ( "context" "encoding/json" "fmt" "log/slog" "os" "strings" "testing" "github.com/shankar0123/certctl/internal/connector/target" ) // --- Mock F5Client --- // mockCall records a single method call to the mock F5Client. type mockCall struct { Method string Args []string } // mockF5Client records all calls and returns configurable responses. type mockF5Client struct { calls []mockCall // Configurable responses per method authenticateErr error authenticateCount int // tracks number of Authenticate calls uploadFileErr error uploadFileErrOn string // only error when filename contains this substring installCertErr error installCertErrOn string installKeyErr error createTransactionID string createTransactionErr error commitTransactionErr error updateSSLProfileErr error getSSLProfileResult *SSLProfileInfo getSSLProfileErr error deleteCertErr error deleteKeyErr error // Track cleanup calls specifically deletedCerts []string deletedKeys []string } func newMockF5Client() *mockF5Client { return &mockF5Client{ createTransactionID: "12345", } } func (m *mockF5Client) Authenticate(ctx context.Context) error { m.calls = append(m.calls, mockCall{Method: "Authenticate"}) m.authenticateCount++ return m.authenticateErr } func (m *mockF5Client) UploadFile(ctx context.Context, filename string, data []byte) error { m.calls = append(m.calls, mockCall{Method: "UploadFile", Args: []string{filename, fmt.Sprintf("%d bytes", len(data))}}) if m.uploadFileErrOn != "" && strings.Contains(filename, m.uploadFileErrOn) { return m.uploadFileErr } if m.uploadFileErrOn == "" && m.uploadFileErr != nil { return m.uploadFileErr } return nil } func (m *mockF5Client) InstallCert(ctx context.Context, name, localFile string) error { m.calls = append(m.calls, mockCall{Method: "InstallCert", Args: []string{name, localFile}}) if m.installCertErrOn != "" && strings.Contains(name, m.installCertErrOn) { return m.installCertErr } if m.installCertErrOn == "" && m.installCertErr != nil { return m.installCertErr } return nil } func (m *mockF5Client) InstallKey(ctx context.Context, name, localFile string) error { m.calls = append(m.calls, mockCall{Method: "InstallKey", Args: []string{name, localFile}}) return m.installKeyErr } func (m *mockF5Client) CreateTransaction(ctx context.Context) (string, error) { m.calls = append(m.calls, mockCall{Method: "CreateTransaction"}) return m.createTransactionID, m.createTransactionErr } func (m *mockF5Client) CommitTransaction(ctx context.Context, transID string) error { m.calls = append(m.calls, mockCall{Method: "CommitTransaction", Args: []string{transID}}) return m.commitTransactionErr } func (m *mockF5Client) UpdateSSLProfile(ctx context.Context, partition, profile string, certName, keyName, chainName string, transID string) error { m.calls = append(m.calls, mockCall{Method: "UpdateSSLProfile", Args: []string{partition, profile, certName, keyName, chainName, transID}}) return m.updateSSLProfileErr } func (m *mockF5Client) GetSSLProfile(ctx context.Context, partition, profile string) (*SSLProfileInfo, error) { m.calls = append(m.calls, mockCall{Method: "GetSSLProfile", Args: []string{partition, profile}}) return m.getSSLProfileResult, m.getSSLProfileErr } func (m *mockF5Client) DeleteCert(ctx context.Context, partition, name string) error { m.calls = append(m.calls, mockCall{Method: "DeleteCert", Args: []string{partition, name}}) m.deletedCerts = append(m.deletedCerts, name) return m.deleteCertErr } func (m *mockF5Client) DeleteKey(ctx context.Context, partition, name string) error { m.calls = append(m.calls, mockCall{Method: "DeleteKey", Args: []string{partition, name}}) m.deletedKeys = append(m.deletedKeys, name) return m.deleteKeyErr } // hasCalled returns true if the mock received a call to the given method. func (m *mockF5Client) hasCalled(method string) bool { for _, c := range m.calls { if c.Method == method { return true } } return false } // callCount returns the number of times a method was called. func (m *mockF5Client) callCount(method string) int { count := 0 for _, c := range m.calls { if c.Method == method { count++ } } return count } func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) } // --- ValidateConfig tests --- func TestValidateConfig(t *testing.T) { t.Run("Success", func(t *testing.T) { mock := newMockF5Client() cfg := &Config{Host: "f5.test.com", Username: "admin", Password: "secret", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) rawConfig, _ := json.Marshal(map[string]interface{}{ "host": "f5.test.com", "username": "admin", "password": "secret", "ssl_profile": "myprofile", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err != nil { t.Fatalf("ValidateConfig failed: %v", err) } if !mock.hasCalled("Authenticate") { t.Error("expected Authenticate to be called") } }) t.Run("DefaultsApplied", func(t *testing.T) { mock := newMockF5Client() cfg := &Config{} conn := NewWithClient(cfg, testLogger(), mock) rawConfig, _ := json.Marshal(map[string]interface{}{ "host": "f5.test.com", "username": "admin", "password": "secret", "ssl_profile": "myprofile", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err != nil { t.Fatalf("ValidateConfig failed: %v", err) } // Check defaults were applied if conn.config.Port != 443 { t.Errorf("expected port 443, got %d", conn.config.Port) } if conn.config.Partition != "Common" { t.Errorf("expected partition Common, got %s", conn.config.Partition) } if conn.config.Timeout != 30 { t.Errorf("expected timeout 30, got %d", conn.config.Timeout) } }) t.Run("InvalidJSON", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) err := conn.ValidateConfig(context.Background(), json.RawMessage(`{invalid}`)) if err == nil { t.Fatal("expected error for invalid JSON") } if !strings.Contains(err.Error(), "invalid F5 config") { t.Errorf("expected 'invalid F5 config' in error, got: %v", err) } }) t.Run("MissingHost", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]string{ "username": "admin", "password": "secret", "ssl_profile": "prof", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "host is required") { t.Errorf("expected 'host is required', got: %v", err) } }) t.Run("MissingUsername", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]string{ "host": "f5.test.com", "password": "secret", "ssl_profile": "prof", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "username is required") { t.Errorf("expected 'username is required', got: %v", err) } }) t.Run("MissingPassword", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]string{ "host": "f5.test.com", "username": "admin", "ssl_profile": "prof", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "password is required") { t.Errorf("expected 'password is required', got: %v", err) } }) t.Run("MissingSSLProfile", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]string{ "host": "f5.test.com", "username": "admin", "password": "secret", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "ssl_profile is required") { t.Errorf("expected 'ssl_profile is required', got: %v", err) } }) t.Run("InvalidPort", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]interface{}{ "host": "f5.test.com", "username": "admin", "password": "secret", "ssl_profile": "prof", "port": 70000, }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "port must be between") { t.Errorf("expected port range error, got: %v", err) } }) t.Run("AuthFailure", func(t *testing.T) { mock := newMockF5Client() mock.authenticateErr = fmt.Errorf("connection refused") conn := NewWithClient(&Config{}, testLogger(), mock) rawConfig, _ := json.Marshal(map[string]string{ "host": "f5.test.com", "username": "admin", "password": "bad", "ssl_profile": "prof", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "authentication failed") { t.Errorf("expected auth failure error, got: %v", err) } }) t.Run("InvalidPartitionChars", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]string{ "host": "f5.test.com", "username": "admin", "password": "secret", "ssl_profile": "prof", "partition": "Common; rm -rf /", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "partition contains invalid characters") { t.Errorf("expected partition validation error, got: %v", err) } }) t.Run("InvalidSSLProfileChars", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]string{ "host": "f5.test.com", "username": "admin", "password": "secret", "ssl_profile": "prof; echo pwned", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "ssl_profile contains invalid characters") { t.Errorf("expected ssl_profile validation error, got: %v", err) } }) t.Run("InvalidHostChars", func(t *testing.T) { conn := NewWithClient(&Config{}, testLogger(), newMockF5Client()) rawConfig, _ := json.Marshal(map[string]string{ "host": "f5.test.com/../../etc/passwd", "username": "admin", "password": "secret", "ssl_profile": "prof", }) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil || !strings.Contains(err.Error(), "host contains invalid characters") { t.Errorf("expected host validation error, got: %v", err) } }) } // --- DeployCertificate tests --- const testCertPEM = `-----BEGIN CERTIFICATE----- MIIBhTCCASugAwIBAgIRAJ1gCL7hBmSj6g0gYOr2FzMwCgYIKoZIzj0EAwIwEjEQ MA4GA1UEChMHY2VydGN0bDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBa MBIxEDAOBgNVBAoTB2NlcnRjdGwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQr H2kMjsgP+FZuyMjJLNfewN0EDkN0s4Lz2Y1IqFqD8DlGN3zI3lPQ7hGdQbiCklPk 1YXNmfmI6L2JKxB/d9Gxo1cwVTAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYI KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBQAAAAAAAAAAAAAAAAA AAAAADAKBggqhkjOPQQDAgNIADBFAiEA4JIlRKL22y6c2JGwVtM60z2bGm9Lb9rq 3BSSLE8xF3UCIGSKd9bP0BBFIO20daxEP7g3/kTSSYpNMIG6yc6acdHH -----END CERTIFICATE-----` const testKeyPEM = `-----BEGIN EC PRIVATE KEY----- MHQCAQEEIKj7N0fDjLaI9bGmJ/TY3PBvIxwclLOPIdOi6yWI2B5CoAcGBSuBBAAi oWQDYgAEhLS0ynMvDJH5o0F5e6jVnXOBqRT2bHkVxQng+eqaXdY3gJoFIIxvR/q0 Vy4p3LZFQsKQfBwt3A8LLvOJY6E8bF4MNPrn0O1bQkeMjb8tSxdKfH0bARJdllD h9oAPTR1 -----END EC PRIVATE KEY-----` const testChainPEM = `-----BEGIN CERTIFICATE----- MIIBYzCCAQmgAwIBAgIRAKR1G0hS1jBOQH2VtNTzpHowCgYIKoZIzj0EAwIwEjEQ MA4GA1UEChMHY2VydGN0bDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBa MBIxEDAOBgNVBAoTB2NlcnRjdGwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASE tLTKcy8MkfmjQXl7qNWdc4GpFPZseRXFCeD56ppd1jeAmgUgjG9H+rRXLinctkVC wpB8HC3cDwsu84ljoTxso0IwQDAOBgNVHQ8BAf8EBAMCAoQwDwYDVR0TAQH/BAUw AwEB/zAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIDSAAw RQIhAJ2K5VVTBiWBrZgdxNthZ7FEqrpNL9LiuD3bWx0xCaoAAiAh9+2p4PQmNuqN R7kSqe/p0W0VnFx1nOJz/sDyPM+2qg== -----END CERTIFICATE-----` func TestDeployCertificate(t *testing.T) { t.Run("FullSuccessWithChain", func(t *testing.T) { mock := newMockF5Client() cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{ CertPEM: testCertPEM, KeyPEM: testKeyPEM, ChainPEM: testChainPEM, } result, err := conn.DeployCertificate(context.Background(), request) if err != nil { t.Fatalf("DeployCertificate failed: %v", err) } if !result.Success { t.Fatalf("expected success, got: %s", result.Message) } // Verify call sequence if !mock.hasCalled("Authenticate") { t.Error("expected Authenticate call") } if mock.callCount("UploadFile") != 3 { t.Errorf("expected 3 UploadFile calls (cert, key, chain), got %d", mock.callCount("UploadFile")) } if mock.callCount("InstallCert") != 2 { // cert + chain t.Errorf("expected 2 InstallCert calls (cert + chain), got %d", mock.callCount("InstallCert")) } if mock.callCount("InstallKey") != 1 { t.Errorf("expected 1 InstallKey call, got %d", mock.callCount("InstallKey")) } if !mock.hasCalled("CreateTransaction") { t.Error("expected CreateTransaction call") } if !mock.hasCalled("UpdateSSLProfile") { t.Error("expected UpdateSSLProfile call") } if !mock.hasCalled("CommitTransaction") { t.Error("expected CommitTransaction call") } // Verify metadata if result.Metadata["host"] != "f5.test.com" { t.Errorf("expected host f5.test.com in metadata, got %s", result.Metadata["host"]) } if result.Metadata["partition"] != "Common" { t.Errorf("expected partition Common in metadata, got %s", result.Metadata["partition"]) } if result.Metadata["ssl_profile"] != "myprofile" { t.Errorf("expected ssl_profile myprofile in metadata, got %s", result.Metadata["ssl_profile"]) } if result.Metadata["cert_object_name"] == "" { t.Error("expected cert_object_name in metadata") } if result.Metadata["duration_ms"] == "" { t.Error("expected duration_ms in metadata") } }) t.Run("SuccessWithoutChain", func(t *testing.T) { mock := newMockF5Client() cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{ CertPEM: testCertPEM, KeyPEM: testKeyPEM, } result, err := conn.DeployCertificate(context.Background(), request) if err != nil { t.Fatalf("DeployCertificate failed: %v", err) } if !result.Success { t.Fatalf("expected success, got: %s", result.Message) } // Should only upload cert + key (no chain) if mock.callCount("UploadFile") != 2 { t.Errorf("expected 2 UploadFile calls, got %d", mock.callCount("UploadFile")) } if mock.callCount("InstallCert") != 1 { // only cert, no chain t.Errorf("expected 1 InstallCert call (cert only), got %d", mock.callCount("InstallCert")) } if result.Metadata["chain_object_name"] != "" { t.Errorf("expected empty chain_object_name, got %s", result.Metadata["chain_object_name"]) } }) t.Run("MissingKeyPEM", func(t *testing.T) { mock := newMockF5Client() cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{ CertPEM: testCertPEM, } result, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for missing KeyPEM") } if result.Success { t.Error("expected Success=false") } if !strings.Contains(err.Error(), "KeyPEM") { t.Errorf("expected KeyPEM in error, got: %v", err) } }) t.Run("AuthFailure", func(t *testing.T) { mock := newMockF5Client() mock.authenticateErr = fmt.Errorf("connection refused") cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "bad", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM} result, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for auth failure") } if result.Success { t.Error("expected Success=false") } if !strings.Contains(err.Error(), "authentication failed") { t.Errorf("expected auth failure in error, got: %v", err) } }) t.Run("CertUploadFailure", func(t *testing.T) { mock := newMockF5Client() mock.uploadFileErr = fmt.Errorf("upload timeout") mock.uploadFileErrOn = "cert" cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM} _, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for cert upload failure") } // No cleanup needed — nothing installed yet if len(mock.deletedCerts) > 0 || len(mock.deletedKeys) > 0 { t.Error("expected no cleanup calls when upload fails before install") } }) t.Run("CertInstallFailure", func(t *testing.T) { mock := newMockF5Client() mock.installCertErr = fmt.Errorf("install failed") // Don't set installCertErrOn — all InstallCert calls will fail cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM} _, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for cert install failure") } if !strings.Contains(err.Error(), "cert crypto object") { t.Errorf("expected cert install error, got: %v", err) } // No cleanup — cert install failed so nothing to clean up // (the cert object wasn't successfully installed) }) t.Run("KeyInstallFailure_CleansCert", func(t *testing.T) { mock := newMockF5Client() mock.installKeyErr = fmt.Errorf("key install failed") cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM} _, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for key install failure") } // Should have cleaned up the cert that was installed if len(mock.deletedCerts) != 1 { t.Errorf("expected 1 cert cleanup, got %d", len(mock.deletedCerts)) } }) t.Run("TransactionCreateFailure_CleansObjects", func(t *testing.T) { mock := newMockF5Client() mock.createTransactionErr = fmt.Errorf("transaction service unavailable") cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM} _, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for transaction create failure") } // Should clean up cert + key if len(mock.deletedCerts) != 1 { t.Errorf("expected 1 cert cleanup, got %d", len(mock.deletedCerts)) } if len(mock.deletedKeys) != 1 { t.Errorf("expected 1 key cleanup, got %d", len(mock.deletedKeys)) } }) t.Run("ProfileUpdateFailure_CleansObjects", func(t *testing.T) { mock := newMockF5Client() mock.updateSSLProfileErr = fmt.Errorf("profile not found") cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "nonexistent"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM, ChainPEM: testChainPEM} _, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for profile update failure") } // Should clean up cert + chain + key if len(mock.deletedCerts) != 2 { // cert + chain t.Errorf("expected 2 cert cleanups (cert + chain), got %d", len(mock.deletedCerts)) } if len(mock.deletedKeys) != 1 { t.Errorf("expected 1 key cleanup, got %d", len(mock.deletedKeys)) } }) t.Run("CommitFailure_CleansObjects", func(t *testing.T) { mock := newMockF5Client() mock.commitTransactionErr = fmt.Errorf("transaction validation failed") cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM} _, err := conn.DeployCertificate(context.Background(), request) if err == nil { t.Fatal("expected error for commit failure") } if !strings.Contains(err.Error(), "commit") { t.Errorf("expected commit error, got: %v", err) } // Should clean up installed objects if len(mock.deletedCerts) < 1 { t.Error("expected cert cleanup on commit failure") } if len(mock.deletedKeys) < 1 { t.Error("expected key cleanup on commit failure") } }) t.Run("MetadataVerification", func(t *testing.T) { mock := newMockF5Client() cfg := &Config{Host: "bigip.prod.internal", Port: 8443, Username: "admin", Password: "secret", Partition: "Production", SSLProfile: "api-ssl"} conn := NewWithClient(cfg, testLogger(), mock) request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM} result, err := conn.DeployCertificate(context.Background(), request) if err != nil { t.Fatalf("DeployCertificate failed: %v", err) } if result.Metadata["host"] != "bigip.prod.internal" { t.Errorf("expected host bigip.prod.internal, got %s", result.Metadata["host"]) } if result.Metadata["partition"] != "Production" { t.Errorf("expected partition Production, got %s", result.Metadata["partition"]) } if result.Metadata["ssl_profile"] != "api-ssl" { t.Errorf("expected ssl_profile api-ssl, got %s", result.Metadata["ssl_profile"]) } if !strings.HasPrefix(result.Metadata["cert_object_name"], "certctl-cert-") { t.Errorf("expected cert_object_name to start with certctl-cert-, got %s", result.Metadata["cert_object_name"]) } if result.TargetAddress != "bigip.prod.internal:8443" { t.Errorf("expected target address bigip.prod.internal:8443, got %s", result.TargetAddress) } }) } // --- ValidateDeployment tests --- func TestValidateDeployment(t *testing.T) { t.Run("Success", func(t *testing.T) { mock := newMockF5Client() mock.getSSLProfileResult = &SSLProfileInfo{ Name: "myprofile", Cert: "/Common/certctl-cert-1234567890", Key: "/Common/certctl-key-1234567890", Chain: "/Common/certctl-chain-1234567890", } cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.ValidationRequest{ CertificateID: "mc-test-cert", Serial: "abc123", } result, err := conn.ValidateDeployment(context.Background(), request) if err != nil { t.Fatalf("ValidateDeployment failed: %v", err) } if !result.Valid { t.Fatalf("expected valid, got: %s", result.Message) } if result.Metadata["current_cert"] != "/Common/certctl-cert-1234567890" { t.Errorf("expected cert in metadata, got %s", result.Metadata["current_cert"]) } }) t.Run("ProfileNotFound", func(t *testing.T) { mock := newMockF5Client() mock.getSSLProfileErr = fmt.Errorf("object not found (404)") cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "nonexistent"} conn := NewWithClient(cfg, testLogger(), mock) request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"} result, err := conn.ValidateDeployment(context.Background(), request) if err == nil { t.Fatal("expected error for profile not found") } if result.Valid { t.Error("expected Valid=false") } }) t.Run("AuthFailure", func(t *testing.T) { mock := newMockF5Client() mock.authenticateErr = fmt.Errorf("auth failed") cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "bad", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"} _, err := conn.ValidateDeployment(context.Background(), request) if err == nil { t.Fatal("expected error for auth failure") } if !strings.Contains(err.Error(), "authentication failed") { t.Errorf("expected auth failure error, got: %v", err) } }) t.Run("UnexpectedCert_StillValid", func(t *testing.T) { mock := newMockF5Client() mock.getSSLProfileResult = &SSLProfileInfo{ Name: "myprofile", Cert: "/Common/some-other-cert", Key: "/Common/some-other-key", } cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"} result, err := conn.ValidateDeployment(context.Background(), request) if err != nil { t.Fatalf("ValidateDeployment failed: %v", err) } // We report what's there — it's valid (profile exists with a cert) if !result.Valid { t.Error("expected Valid=true (profile has a cert)") } if result.Metadata["current_cert"] != "/Common/some-other-cert" { t.Errorf("expected current cert reported, got %s", result.Metadata["current_cert"]) } }) t.Run("EmptyCertField", func(t *testing.T) { mock := newMockF5Client() mock.getSSLProfileResult = &SSLProfileInfo{ Name: "myprofile", Cert: "", Key: "", } cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"} conn := NewWithClient(cfg, testLogger(), mock) request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"} result, err := conn.ValidateDeployment(context.Background(), request) if err == nil { t.Fatal("expected error for empty cert field") } if result.Valid { t.Error("expected Valid=false") } if !strings.Contains(err.Error(), "no certificate configured") { t.Errorf("expected 'no certificate configured' error, got: %v", err) } }) } // --- Helper tests --- func TestObjectName(t *testing.T) { name1 := objectName("cert") if !strings.HasPrefix(name1, "certctl-cert-") { t.Errorf("expected prefix certctl-cert-, got %s", name1) } // Verify format is correct: certctl-- if len(name1) < len("certctl-cert-") { t.Errorf("expected non-empty object name, got %s", name1) } // Verify the name contains digits after the prefix withoutPrefix := strings.TrimPrefix(name1, "certctl-cert-") if withoutPrefix == "" { t.Error("expected digits in object name after prefix") } } func TestPartitionPath(t *testing.T) { path := partitionPath("Common", "certctl-cert-123") if path != "/Common/certctl-cert-123" { t.Errorf("expected /Common/certctl-cert-123, got %s", path) } path = partitionPath("Production", "my-cert") if path != "/Production/my-cert" { t.Errorf("expected /Production/my-cert, got %s", path) } } func TestCleanup_MixedResults(t *testing.T) { mock := newMockF5Client() mock.deleteCertErr = fmt.Errorf("cert in use") // cert delete fails // key delete succeeds (nil error) cfg := &Config{Host: "f5.test.com", Port: 443, Partition: "Common"} conn := NewWithClient(cfg, testLogger(), mock) // Should not panic and should attempt all deletions conn.cleanupCryptoObjects(context.Background(), "Common", []string{"cert1", "cert2"}, []string{"key1"}, ) // Both cert deletes attempted despite errors if len(mock.deletedCerts) != 2 { t.Errorf("expected 2 cert delete attempts, got %d", len(mock.deletedCerts)) } if len(mock.deletedKeys) != 1 { t.Errorf("expected 1 key delete attempt, got %d", len(mock.deletedKeys)) } } func TestCleanup_EmptyNames(t *testing.T) { mock := newMockF5Client() cfg := &Config{Host: "f5.test.com", Port: 443, Partition: "Common"} conn := NewWithClient(cfg, testLogger(), mock) // Empty names should be skipped conn.cleanupCryptoObjects(context.Background(), "Common", []string{"", "cert1", ""}, []string{"", ""}, ) if len(mock.deletedCerts) != 1 { t.Errorf("expected 1 cert delete (skipping empties), got %d", len(mock.deletedCerts)) } if len(mock.deletedKeys) != 0 { t.Errorf("expected 0 key deletes (all empty), got %d", len(mock.deletedKeys)) } } // TestDeployCertificate_TransactionRollbackOnProfileFailure tests that when the // UpdateSSLProfile call fails, the transaction is NOT committed and cleanup is called. func TestDeployCertificate_TransactionRollbackOnProfileFailure(t *testing.T) { cfg := &Config{ Host: "f5.example.com", Username: "admin", Password: "password", SSLProfile: "clientssl", Partition: "Common", Insecure: true, Timeout: 30, } mock := newMockF5Client() // Make UpdateSSLProfile fail mock.updateSSLProfileErr = fmt.Errorf("profile update failed") mock.createTransactionID = "txn-999" connector := NewWithClient(cfg, testLogger(), mock) deployReq := target.DeploymentRequest{ CertPEM: testCertPEM, KeyPEM: testKeyPEM, ChainPEM: testChainPEM, } result, err := connector.DeployCertificate(context.Background(), deployReq) // Should fail if err == nil { t.Error("expected deployment to fail when UpdateSSLProfile fails") } if result.Success { t.Error("expected result.Success=false when UpdateSSLProfile fails") } // Verify transaction was committed (it commits even on failure for rollback) // but the update itself failed } // TestDeployCertificate_ChainUpload tests that when both CertPEM, KeyPEM, and ChainPEM // are provided, all three are uploaded and installed separately. func TestDeployCertificate_ChainUpload(t *testing.T) { cfg := &Config{ Host: "f5.example.com", Username: "admin", Password: "password", SSLProfile: "clientssl", Partition: "Common", Insecure: true, Timeout: 30, } mock := newMockF5Client() mock.createTransactionID = "txn-123" connector := NewWithClient(cfg, testLogger(), mock) deployReq := target.DeploymentRequest{ CertPEM: testCertPEM, KeyPEM: testKeyPEM, ChainPEM: testChainPEM, } result, err := connector.DeployCertificate(context.Background(), deployReq) if err != nil { t.Fatalf("deployment failed: %v", err) } if !result.Success { t.Fatalf("deployment was not successful: %s", result.Message) } // Verify that the calls were made hasUpload := false hasInstall := false hasUpdateSSL := false for _, call := range mock.calls { if call.Method == "UploadFile" { hasUpload = true } if call.Method == "InstallCert" || call.Method == "InstallKey" { hasInstall = true } if call.Method == "UpdateSSLProfile" { hasUpdateSSL = true } } if !hasUpload { t.Error("expected UploadFile to be called") } if !hasInstall { t.Error("expected InstallCert/InstallKey to be called") } if !hasUpdateSSL { t.Error("expected UpdateSSLProfile to be called") } } func TestNew_NilConfig(t *testing.T) { _, err := New(nil, testLogger()) if err == nil { t.Fatal("expected error for nil config") } if !strings.Contains(err.Error(), "config is required") { t.Errorf("expected 'config is required' error, got: %v", err) } }