mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 11:28:52 +00:00
7382e5f03b
Close coverage gaps identified by dual-audit (qualitative + quantitative). New test files for config (0%→98%), router (0%→100%), handler validation, health, audit, response helpers, webhook notifier (0%→88%), email notifier, middleware (recovery, rate limiter), domain profile, service nil-safety, config helpers, issuer bootstrap, and server bootstrap wiring. Expanded existing tests for ACME (34%→42%), step-ca (42%→52%), F5, SSH, agent (43%→63%), scheduler (88%→99%), renewal service, and issuerfactory. All tests pass: go test -short, go vet, go test -race clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
917 lines
31 KiB
Go
917 lines
31 KiB
Go
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-<type>-<nanotime>
|
|
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)
|
|
}
|
|
}
|