diff --git a/internal/connector/issuerfactory/factory.go b/internal/connector/issuerfactory/factory.go index 1bed206..44784b1 100644 --- a/internal/connector/issuerfactory/factory.go +++ b/internal/connector/issuerfactory/factory.go @@ -29,84 +29,84 @@ func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.L } switch issuerType { - case "local", "GenericCA": + case "local", "local_ca", "GenericCA", "genericca": var cfg local.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid Local CA config: %w", err) } return local.New(&cfg, logger), nil - case "ACME": + case "ACME", "acme": var cfg acme.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid ACME config: %w", err) } return acme.New(&cfg, logger), nil - case "StepCA": + case "StepCA", "stepca": var cfg stepca.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid step-ca config: %w", err) } return stepca.New(&cfg, logger), nil - case "OpenSSL": + case "OpenSSL", "openssl": var cfg openssl.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid OpenSSL config: %w", err) } return openssl.New(&cfg, logger), nil - case "VaultPKI": + case "VaultPKI", "vaultpki": var cfg vault.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid Vault PKI config: %w", err) } return vault.New(&cfg, logger), nil - case "DigiCert": + case "DigiCert", "digicert": var cfg digicert.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid DigiCert config: %w", err) } return digicert.New(&cfg, logger), nil - case "Sectigo": + case "Sectigo", "sectigo": var cfg sectigo.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid Sectigo config: %w", err) } return sectigo.New(&cfg, logger), nil - case "GoogleCAS": + case "GoogleCAS", "googlecas": var cfg googlecas.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid Google CAS config: %w", err) } return googlecas.New(&cfg, logger), nil - case "AWSACMPCA": + case "AWSACMPCA", "awsacmpca": var cfg awsacmpca.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid AWS ACM PCA config: %w", err) } return awsacmpca.New(&cfg, logger), nil - case "Entrust": + case "Entrust", "entrust": var cfg entrust.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid Entrust config: %w", err) } return entrust.New(&cfg, logger), nil - case "GlobalSign": + case "GlobalSign", "globalsign": var cfg globalsign.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid GlobalSign config: %w", err) } return globalsign.New(&cfg, logger), nil - case "EJBCA": + case "EJBCA", "ejbca": var cfg ejbca.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid EJBCA config: %w", err) diff --git a/internal/service/issuer.go b/internal/service/issuer.go index 357c959..90ab537 100644 --- a/internal/service/issuer.go +++ b/internal/service/issuer.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "strings" "time" "github.com/shankar0123/certctl/internal/config" @@ -82,15 +83,53 @@ func (s *IssuerService) Get(ctx context.Context, id string) (*domain.Issuer, err // validIssuerTypes is the set of allowed issuer types for validation. var validIssuerTypes = map[domain.IssuerType]bool{ - domain.IssuerTypeACME: true, - domain.IssuerTypeGenericCA: true, - domain.IssuerTypeStepCA: true, - domain.IssuerTypeOpenSSL: true, - domain.IssuerTypeVault: true, - domain.IssuerTypeDigiCert: true, - domain.IssuerTypeSectigo: true, - domain.IssuerTypeGoogleCAS: true, - domain.IssuerTypeAWSACMPCA: true, + domain.IssuerTypeACME: true, + domain.IssuerTypeGenericCA: true, + domain.IssuerTypeStepCA: true, + domain.IssuerTypeOpenSSL: true, + domain.IssuerTypeVault: true, + domain.IssuerTypeDigiCert: true, + domain.IssuerTypeSectigo: true, + domain.IssuerTypeGoogleCAS: true, + domain.IssuerTypeAWSACMPCA: true, + domain.IssuerTypeEntrust: true, + domain.IssuerTypeGlobalSign: true, + domain.IssuerTypeEJBCA: true, +} + +// issuerTypeAliases maps lowercase and legacy type strings to their canonical +// domain.IssuerType constants. This allows older frontends and curl users to +// send case-insensitive type strings (e.g., "acme" instead of "ACME"). +var issuerTypeAliases = map[string]domain.IssuerType{ + "acme": domain.IssuerTypeACME, + "local": domain.IssuerTypeGenericCA, + "local_ca": domain.IssuerTypeGenericCA, + "genericca": domain.IssuerTypeGenericCA, + "stepca": domain.IssuerTypeStepCA, + "openssl": domain.IssuerTypeOpenSSL, + "vaultpki": domain.IssuerTypeVault, + "digicert": domain.IssuerTypeDigiCert, + "sectigo": domain.IssuerTypeSectigo, + "googlecas": domain.IssuerTypeGoogleCAS, + "awsacmpca": domain.IssuerTypeAWSACMPCA, + "entrust": domain.IssuerTypeEntrust, + "globalsign": domain.IssuerTypeGlobalSign, + "ejbca": domain.IssuerTypeEJBCA, +} + +// normalizeIssuerType maps a raw type string to its canonical domain.IssuerType. +// It first checks exact match in validIssuerTypes (fast path for correctly-cased +// input), then falls back to case-insensitive alias lookup. +func normalizeIssuerType(t domain.IssuerType) domain.IssuerType { + // Fast path: already canonical + if validIssuerTypes[t] { + return t + } + // Slow path: case-insensitive lookup + if canonical, ok := issuerTypeAliases[strings.ToLower(string(t))]; ok { + return canonical + } + return t // Return as-is; validation will reject it } // isValidIssuerType checks if a type string is a known issuer type. @@ -103,6 +142,7 @@ func (s *IssuerService) Create(ctx context.Context, iss *domain.Issuer, actor st if iss.Name == "" { return fmt.Errorf("issuer name is required") } + iss.Type = normalizeIssuerType(iss.Type) if !isValidIssuerType(iss.Type) { return fmt.Errorf("unsupported issuer type: %s", iss.Type) } @@ -601,6 +641,7 @@ func (s *IssuerService) GetIssuer(id string) (*domain.Issuer, error) { // CreateIssuer creates a new issuer (handler interface method). func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error) { + iss.Type = normalizeIssuerType(iss.Type) if !isValidIssuerType(iss.Type) { return nil, fmt.Errorf("unsupported issuer type: %s", iss.Type) } diff --git a/internal/service/issuer_test.go b/internal/service/issuer_test.go index 7ed3cb6..92d4d5a 100644 --- a/internal/service/issuer_test.go +++ b/internal/service/issuer_test.go @@ -614,3 +614,160 @@ func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) { t.Fatalf("DeleteIssuer failed: %v", err) } } + +// TestNormalizeIssuerType tests case-insensitive issuer type normalization. +func TestNormalizeIssuerType(t *testing.T) { + tests := []struct { + input domain.IssuerType + expected domain.IssuerType + }{ + // Canonical values pass through unchanged + {domain.IssuerTypeACME, domain.IssuerTypeACME}, + {domain.IssuerTypeGenericCA, domain.IssuerTypeGenericCA}, + {domain.IssuerTypeStepCA, domain.IssuerTypeStepCA}, + {domain.IssuerTypeVault, domain.IssuerTypeVault}, + {domain.IssuerTypeDigiCert, domain.IssuerTypeDigiCert}, + {domain.IssuerTypeSectigo, domain.IssuerTypeSectigo}, + {domain.IssuerTypeGoogleCAS, domain.IssuerTypeGoogleCAS}, + {domain.IssuerTypeAWSACMPCA, domain.IssuerTypeAWSACMPCA}, + {domain.IssuerTypeEntrust, domain.IssuerTypeEntrust}, + {domain.IssuerTypeGlobalSign, domain.IssuerTypeGlobalSign}, + {domain.IssuerTypeEJBCA, domain.IssuerTypeEJBCA}, + + // Lowercase aliases (the actual bug: old frontends send these) + {"acme", domain.IssuerTypeACME}, + {"local", domain.IssuerTypeGenericCA}, + {"local_ca", domain.IssuerTypeGenericCA}, + {"stepca", domain.IssuerTypeStepCA}, + {"openssl", domain.IssuerTypeOpenSSL}, + {"vaultpki", domain.IssuerTypeVault}, + {"digicert", domain.IssuerTypeDigiCert}, + {"sectigo", domain.IssuerTypeSectigo}, + {"googlecas", domain.IssuerTypeGoogleCAS}, + {"awsacmpca", domain.IssuerTypeAWSACMPCA}, + {"entrust", domain.IssuerTypeEntrust}, + {"globalsign", domain.IssuerTypeGlobalSign}, + {"ejbca", domain.IssuerTypeEJBCA}, + + // Mixed case + {"Acme", domain.IssuerTypeACME}, + {"STEPCA", domain.IssuerTypeStepCA}, + {"vaultPKI", domain.IssuerTypeVault}, + {"GenericCA", domain.IssuerTypeGenericCA}, + {"genericca", domain.IssuerTypeGenericCA}, + + // Unknown types pass through for validation to reject + {"FakeCA", "FakeCA"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(string(tt.input), func(t *testing.T) { + result := normalizeIssuerType(tt.input) + if result != tt.expected { + t.Errorf("normalizeIssuerType(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestIssuerService_Create_LowercaseType tests that Create normalizes lowercase type strings. +func TestIssuerService_Create_LowercaseType(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) + + config := map[string]interface{}{"endpoint": "https://acme.example.com"} + configJSON, _ := json.Marshal(config) + + issuer := &domain.Issuer{ + Name: "Test Lowercase ACME", + Type: "acme", // lowercase — this is the bug from issue #7 + Config: configJSON, + Enabled: true, + } + + err := service.Create(ctx, issuer, "user-test") + if err != nil { + t.Fatalf("Create with lowercase 'acme' should succeed, got: %v", err) + } + + // Verify the type was normalized to canonical form + if issuer.Type != domain.IssuerTypeACME { + t.Errorf("expected type to be normalized to %q, got %q", domain.IssuerTypeACME, issuer.Type) + } +} + +// TestIssuerService_CreateIssuer_LowercaseType tests handler interface path with lowercase type. +func TestIssuerService_CreateIssuer_LowercaseType(t *testing.T) { + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) + + config := map[string]interface{}{"url": "https://example.com"} + configJSON, _ := json.Marshal(config) + + issuer := domain.Issuer{ + Name: "Lowercase StepCA Test", + Type: "stepca", // lowercase + Config: configJSON, + Enabled: true, + } + + result, err := service.CreateIssuer(issuer) + if err != nil { + t.Fatalf("CreateIssuer with lowercase 'stepca' should succeed, got: %v", err) + } + + if result.Type != domain.IssuerTypeStepCA { + t.Errorf("expected type to be normalized to %q, got %q", domain.IssuerTypeStepCA, result.Type) + } +} + +// TestIssuerService_Create_M49Types tests that M49 issuer types (Entrust, GlobalSign, EJBCA) are accepted. +func TestIssuerService_Create_M49Types(t *testing.T) { + ctx := context.Background() + + m49Types := []struct { + name string + issuerType domain.IssuerType + }{ + {"Entrust", domain.IssuerTypeEntrust}, + {"GlobalSign", domain.IssuerTypeGlobalSign}, + {"EJBCA", domain.IssuerTypeEJBCA}, + } + + for _, tt := range m49Types { + t.Run(tt.name, func(t *testing.T) { + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) + + config := map[string]interface{}{"api_url": "https://example.com"} + configJSON, _ := json.Marshal(config) + + issuer := &domain.Issuer{ + Name: "Test " + tt.name, + Type: tt.issuerType, + Config: configJSON, + Enabled: true, + } + + err := service.Create(ctx, issuer, "user-test") + if err != nil { + t.Fatalf("Create with type %q should succeed, got: %v", tt.issuerType, err) + } + }) + } +}