package sectigo_test import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "fmt" "io" "log/slog" "math/big" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" ) func TestSectigoConnector(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) ctx := context.Background() t.Run("ValidateConfig_Success", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/ssl/v1/types" { // Verify all 3 auth headers are present if r.Header.Get("customerUri") != "test-org" { t.Errorf("Expected customerUri 'test-org', got '%s'", r.Header.Get("customerUri")) } if r.Header.Get("login") != "api-user" { t.Errorf("Expected login 'api-user', got '%s'", r.Header.Get("login")) } if r.Header.Get("password") != "api-pass" { t.Errorf("Expected password 'api-pass', got '%s'", r.Header.Get("password")) } w.WriteHeader(http.StatusOK) w.Write([]byte(`[{"id":423,"name":"Sectigo OV SSL","term":[365,730]}]`)) return } http.NotFound(w, r) })) defer srv.Close() config := sectigo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, CertType: 423, Term: 365, BaseURL: srv.URL, } connector := sectigo.New(nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { t.Fatalf("ValidateConfig failed: %v", err) } }) t.Run("ValidateConfig_MissingCustomerURI", func(t *testing.T) { config := sectigo.Config{ Login: "api-user", Password: "api-pass", OrgID: 12345, } connector := sectigo.New(nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { t.Fatal("Expected error for missing customer_uri") } if !strings.Contains(err.Error(), "customer_uri is required") { t.Errorf("Expected customer_uri required error, got: %v", err) } }) t.Run("ValidateConfig_MissingLogin", func(t *testing.T) { config := sectigo.Config{ CustomerURI: "test-org", Password: "api-pass", OrgID: 12345, } connector := sectigo.New(nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { t.Fatal("Expected error for missing login") } if !strings.Contains(err.Error(), "login is required") { t.Errorf("Expected login required error, got: %v", err) } }) t.Run("ValidateConfig_MissingPassword", func(t *testing.T) { config := sectigo.Config{ CustomerURI: "test-org", Login: "api-user", OrgID: 12345, } connector := sectigo.New(nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { t.Fatal("Expected error for missing password") } if !strings.Contains(err.Error(), "password is required") { t.Errorf("Expected password required error, got: %v", err) } }) t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) { config := sectigo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", } connector := sectigo.New(nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { t.Fatal("Expected error for missing org_id") } if !strings.Contains(err.Error(), "org_id is required") { t.Errorf("Expected org_id required error, got: %v", err) } }) t.Run("ValidateConfig_InvalidCredentials", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/ssl/v1/types" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"code":0,"description":"Invalid credentials"}`)) return } http.NotFound(w, r) })) defer srv.Close() config := sectigo.Config{ CustomerURI: "bad-org", Login: "bad-user", Password: "bad-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { t.Fatal("Expected error for invalid credentials") } if !strings.Contains(err.Error(), "invalid") { t.Logf("Got error: %v", err) } }) t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) { testCertPEM, _ := generateTestCert(t) testChainPEM, _ := generateTestCert(t) pemBundle := testCertPEM + testChainPEM srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify auth headers on every request if r.Header.Get("customerUri") == "" || r.Header.Get("login") == "" || r.Header.Get("password") == "" { t.Error("Missing auth headers on request") } switch { case r.URL.Path == "/ssl/v1/enroll" && r.Method == http.MethodPost: // Verify request body structure body, _ := io.ReadAll(r.Body) var req map[string]interface{} json.Unmarshal(body, &req) if req["orgId"] == nil { t.Error("Expected orgId in enrollment request") } if req["certType"] == nil { t.Error("Expected certType in enrollment request") } // SANs should be comma-separated string, not array if sans, ok := req["subjAltNames"].(string); ok { if !strings.Contains(sans, ",") && len(sans) > 0 { // Single SAN is fine } } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55001,"renewId":"ren-abc"}`)) case r.URL.Path == "/ssl/v1/55001" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`)) case r.URL.Path == "/ssl/v1/collect/55001/pem" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) w.Write([]byte(pemBundle)) default: http.NotFound(w, r) } })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, CertType: 423, Term: 365, BaseURL: srv.URL, } connector := sectigo.New(config, logger) _, csrPEM := generateTestCSR(t, "app.example.com") req := issuer.IssuanceRequest{ CommonName: "app.example.com", SANs: []string{"app.example.com", "www.example.com"}, CSRPEM: csrPEM, } result, err := connector.IssueCertificate(ctx, req) if err != nil { t.Fatalf("IssueCertificate failed: %v", err) } if result.CertPEM == "" { t.Error("CertPEM should not be empty for immediate issuance") } if result.Serial == "" { t.Error("Serial should not be empty for immediate issuance") } if result.OrderID != "55001" { t.Errorf("Expected OrderID '55001', got '%s'", result.OrderID) } t.Logf("Sectigo issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID) }) t.Run("IssueCertificate_Pending", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/ssl/v1/enroll": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55002}`)) case "/ssl/v1/55002": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55002,"status":"Applied","commonName":"secure.example.com"}`)) default: http.NotFound(w, r) } })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, CertType: 423, Term: 365, BaseURL: srv.URL, } connector := sectigo.New(config, logger) _, csrPEM := generateTestCSR(t, "secure.example.com") req := issuer.IssuanceRequest{ CommonName: "secure.example.com", CSRPEM: csrPEM, } result, err := connector.IssueCertificate(ctx, req) if err != nil { t.Fatalf("IssueCertificate failed: %v", err) } if result.OrderID != "55002" { t.Errorf("Expected OrderID '55002', got '%s'", result.OrderID) } if result.CertPEM != "" { t.Error("CertPEM should be empty for pending order") } if result.Serial != "" { t.Error("Serial should be empty for pending order") } }) t.Run("IssueCertificate_ServerError", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"code":-14,"description":"Invalid CSR"}`)) })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, CertType: 423, Term: 365, BaseURL: srv.URL, } connector := sectigo.New(config, logger) req := issuer.IssuanceRequest{ CommonName: "test.example.com", CSRPEM: "invalid-csr", } _, err := connector.IssueCertificate(ctx, req) if err == nil { t.Fatal("Expected error for server error response") } }) t.Run("GetOrderStatus_Issued", func(t *testing.T) { testCertPEM, _ := generateTestCert(t) testChainPEM, _ := generateTestCert(t) pemBundle := testCertPEM + testChainPEM srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/ssl/v1/55001": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`)) case "/ssl/v1/collect/55001/pem": w.WriteHeader(http.StatusOK) w.Write([]byte(pemBundle)) default: http.NotFound(w, r) } })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(config, logger) status, err := connector.GetOrderStatus(ctx, "55001") if err != nil { t.Fatalf("GetOrderStatus failed: %v", err) } if status.Status != "completed" { t.Errorf("Expected status 'completed', got '%s'", status.Status) } if status.CertPEM == nil || *status.CertPEM == "" { t.Error("CertPEM should not be empty for issued order") } if status.Serial == nil || *status.Serial == "" { t.Error("Serial should not be empty for issued order") } }) t.Run("GetOrderStatus_Pending", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/ssl/v1/55002" { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55002,"status":"Applied"}`)) return } http.NotFound(w, r) })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(config, logger) status, err := connector.GetOrderStatus(ctx, "55002") if err != nil { t.Fatalf("GetOrderStatus failed: %v", err) } if status.Status != "pending" { t.Errorf("Expected status 'pending', got '%s'", status.Status) } if status.CertPEM != nil { t.Error("CertPEM should be nil for pending order") } }) t.Run("GetOrderStatus_Rejected", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/ssl/v1/55003" { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55003,"status":"Rejected"}`)) return } http.NotFound(w, r) })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(config, logger) status, err := connector.GetOrderStatus(ctx, "55003") if err != nil { t.Fatalf("GetOrderStatus failed: %v", err) } if status.Status != "failed" { t.Errorf("Expected status 'failed', got '%s'", status.Status) } }) t.Run("GetOrderStatus_CollectNotReady", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/ssl/v1/55004": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55004,"status":"Issued","commonName":"pending-collect.example.com"}`)) case "/ssl/v1/collect/55004/pem": // Sectigo returns 400 with code -183 when cert not yet generated w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"code":-183,"description":"Certificate is not available"}`)) default: http.NotFound(w, r) } })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(config, logger) status, err := connector.GetOrderStatus(ctx, "55004") if err != nil { t.Fatalf("GetOrderStatus failed: %v", err) } // Should be treated as pending (cert approved but not yet generated) if status.Status != "pending" { t.Errorf("Expected status 'pending' for collect-not-ready, got '%s'", status.Status) } }) t.Run("RenewCertificate_NewOrder", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/ssl/v1/enroll": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55010}`)) case "/ssl/v1/55010": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55010,"status":"Applied"}`)) default: http.NotFound(w, r) } })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, CertType: 423, Term: 365, BaseURL: srv.URL, } connector := sectigo.New(config, logger) _, csrPEM := generateTestCSR(t, "renew.example.com") renewReq := issuer.RenewalRequest{ CommonName: "renew.example.com", CSRPEM: csrPEM, } result, err := connector.RenewCertificate(ctx, renewReq) if err != nil { t.Fatalf("RenewCertificate failed: %v", err) } if result.OrderID == "" { t.Error("OrderID should not be empty") } }) t.Run("RevokeCertificate_Success", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") && r.Method == http.MethodPost { // Verify auth headers if r.Header.Get("customerUri") == "" { t.Error("Missing customerUri header on revoke request") } if r.Header.Get("login") == "" { t.Error("Missing login header on revoke request") } if r.Header.Get("password") == "" { t.Error("Missing password header on revoke request") } // Verify reason in body body, _ := io.ReadAll(r.Body) var req map[string]interface{} json.Unmarshal(body, &req) if req["reason"] == nil { t.Error("Expected reason in revoke request body") } w.WriteHeader(http.StatusNoContent) return } http.NotFound(w, r) })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(config, logger) reason := "keyCompromise" revokeReq := issuer.RevocationRequest{ Serial: "55001", Reason: &reason, } err := connector.RevokeCertificate(ctx, revokeReq) if err != nil { t.Fatalf("RevokeCertificate failed: %v", err) } }) t.Run("RevokeCertificate_Error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"code":-1,"description":"Certificate not found"}`)) })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(config, logger) revokeReq := issuer.RevocationRequest{ Serial: "00000", } err := connector.RevokeCertificate(ctx, revokeReq) if err == nil { t.Fatal("Expected error for revocation of nonexistent cert") } }) t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: "https://cert-manager.com/api", } connector := sectigo.New(config, logger) result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") if err != nil { t.Fatalf("GetRenewalInfo should not return error, got: %v", err) } if result != nil { t.Fatal("GetRenewalInfo should return nil for Sectigo") } }) t.Run("DefaultTerm", func(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, CertType: 423, // Term intentionally left as 0 } connector := sectigo.New(config, logger) // Verify the connector was created (the default is set in New()) if connector == nil { t.Fatal("Connector should not be nil") } // Verify via a request that uses the term srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/ssl/v1/enroll" { body, _ := io.ReadAll(r.Body) var req map[string]interface{} json.Unmarshal(body, &req) // Default term should be 365 if term, ok := req["term"].(float64); ok { if int(term) != 365 { t.Errorf("Expected default term 365, got %d", int(term)) } } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55099}`)) return } if r.URL.Path == "/ssl/v1/55099" { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55099,"status":"Applied"}`)) return } http.NotFound(w, r) })) defer srv.Close() // Reconfigure with test server URL config.BaseURL = srv.URL connector = sectigo.New(config, logger) _, csrPEM := generateTestCSR(t, "test.example.com") req := issuer.IssuanceRequest{ CommonName: "test.example.com", CSRPEM: csrPEM, } result, err := connector.IssueCertificate(ctx, req) if err != nil { t.Fatalf("IssueCertificate with default term failed: %v", err) } if result.OrderID == "" { t.Error("OrderID should not be empty") } }) t.Run("AuthHeaders_PresentOnAllRequests", func(t *testing.T) { requestCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ // Every single request must have all 3 auth headers if r.Header.Get("customerUri") != "verify-org" { t.Errorf("Request %d: expected customerUri 'verify-org', got '%s'", requestCount, r.Header.Get("customerUri")) } if r.Header.Get("login") != "verify-user" { t.Errorf("Request %d: expected login 'verify-user', got '%s'", requestCount, r.Header.Get("login")) } if r.Header.Get("password") != "verify-pass" { t.Errorf("Request %d: expected password 'verify-pass', got '%s'", requestCount, r.Header.Get("password")) } switch r.URL.Path { case "/ssl/v1/enroll": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55050}`)) case "/ssl/v1/55050": w.WriteHeader(http.StatusOK) w.Write([]byte(`{"sslId":55050,"status":"Applied"}`)) default: http.NotFound(w, r) } })) defer srv.Close() config := §igo.Config{ CustomerURI: "verify-org", Login: "verify-user", Password: "verify-pass", OrgID: 12345, CertType: 423, Term: 365, BaseURL: srv.URL, } connector := sectigo.New(config, logger) _, csrPEM := generateTestCSR(t, "auth-check.example.com") req := issuer.IssuanceRequest{ CommonName: "auth-check.example.com", CSRPEM: csrPEM, } _, err := connector.IssueCertificate(ctx, req) if err != nil { t.Fatalf("IssueCertificate failed: %v", err) } if requestCount < 2 { t.Errorf("Expected at least 2 requests (enroll + status), got %d", requestCount) } }) t.Run("RevocationReasonMapping", func(t *testing.T) { tests := []struct { input string expected string }{ {"keyCompromise", "Compromised"}, {"cessationOfOperation", "Cessation of Operation"}, {"affiliationChanged", "Affiliation Changed"}, {"superseded", "Superseded"}, {"unspecified", "Unspecified"}, {"unknown_reason", "Unspecified"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { var receivedReason string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") { body, _ := io.ReadAll(r.Body) var req map[string]interface{} json.Unmarshal(body, &req) receivedReason = req["reason"].(string) w.WriteHeader(http.StatusNoContent) return } http.NotFound(w, r) })) defer srv.Close() config := §igo.Config{ CustomerURI: "test-org", Login: "api-user", Password: "api-pass", OrgID: 12345, BaseURL: srv.URL, } connector := sectigo.New(config, logger) reason := tt.input err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{ Serial: "12345", Reason: &reason, }) if err != nil { t.Fatalf("RevokeCertificate failed: %v", err) } if receivedReason != tt.expected { t.Errorf("Expected reason '%s', got '%s'", tt.expected, receivedReason) } }) } }) } // generateTestCert creates a self-signed test certificate and returns the PEM strings. func generateTestCert(t *testing.T) (certPEM string, keyPEM string) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate key: %v", err) } serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]), }, DNSNames: []string{"test.example.com"}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { t.Fatalf("Failed to create certificate: %v", err) } certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})) keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) return certPEM, keyPEM } // generateTestCSR creates a test CSR for the given common name. func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate key: %v", err) } csrTemplate := x509.CertificateRequest{ Subject: pkix.Name{ CommonName: commonName, }, DNSNames: []string{commonName}, SignatureAlgorithm: x509.SHA256WithRSA, } csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key) if err != nil { t.Fatalf("Failed to create CSR: %v", err) } csrPEM := string(pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE REQUEST", Bytes: csrBytes, })) csr, err := x509.ParseCertificateRequest(csrBytes) if err != nil { t.Fatalf("Failed to parse CSR: %v", err) } return csr, csrPEM }