feat(M47): add Kubernetes Secrets target + AWS ACM PCA issuer connectors

Implement both M47 connectors with full cross-layer wiring:

Kubernetes Secrets target: DNS-1123 validation, kubernetes.io/tls Secret
create-or-update, chain concatenation, serial number validation, Helm
RBAC gating. 18 tests.

AWS ACM Private CA issuer: synchronous issuance (like Vault), ARN regex
validation, RFC 5280 revocation reason mapping, CA cert retrieval,
factory + env var seeding. 23 tests.

Cross-cutting: domain types, service validation, config, factory, agent
dispatch, frontend (TargetsPage, issuerTypes), OpenAPI, seed data, Helm
chart, connectors docs, README. Testing docs (testing-guide, qa-test-guide,
qa_test.go) with Parts thematically integrated near related connectors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-04-07 20:21:09 -04:00
parent f17027c62b
commit e72f06f35b
22 changed files with 2620 additions and 18 deletions
@@ -0,0 +1,416 @@
// Package awsacmpca implements the issuer.Connector interface for AWS Certificate Authority Service (CAS).
//
// AWS ACM Private CA (ACM PCA) provides a fully managed private certificate authority
// with certificate signing, revocation, and CRL capabilities. This connector uses the
// AWS ACM PCA API to issue and manage certificates.
//
// This connector issues certificates synchronously: the IssueCertificate call returns
// the issued certificate immediately. GetOrderStatus always returns "completed" since
// issuance is synchronous. CRL and OCSP operations are delegated to AWS PCA's own
// endpoints.
//
// Authentication: AWS credentials via the standard credential chain (environment variables,
// IAM role, instance profile, or SSO). Configuration specifies the CA ARN, region, and
// optional signing algorithm and validity days.
//
// AWS ACM PCA API used (abstracted via ACMPCAClient interface):
//
// IssueCertificate - Issue a certificate from a CSR
// GetCertificate - Retrieve the issued certificate
// RevokeCertificate - Revoke a certificate
// GetCACertificate - Get the CA certificate chain
package awsacmpca
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the AWS ACM Private CA issuer connector configuration.
type Config struct {
// Region is the AWS region where the CA resides (e.g., "us-east-1").
// Required. Set via CERTCTL_GOOGLE_CAS_PROJECT environment variable.
Region string `json:"region"`
// CAArn is the ARN of the AWS Certificate Authority Service CA.
// Required. Set via CERTCTL_GOOGLE_CAS_CA_ARN environment variable.
// Example: arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012
CAArn string `json:"ca_arn"`
// SigningAlgorithm is the algorithm used to sign certificates.
// Default: "SHA256WITHRSA". Set via CERTCTL_AWS_PCA_SIGNING_ALGORITHM.
// Valid values: SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA,
// SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA
SigningAlgorithm string `json:"signing_algorithm,omitempty"`
// ValidityDays is the number of days the certificate is valid.
// Default: 365. Set via CERTCTL_AWS_PCA_VALIDITY_DAYS.
ValidityDays int `json:"validity_days,omitempty"`
// TemplateArn is the optional certificate template ARN for subordinate CAs with restrictions.
// Set via CERTCTL_AWS_PCA_TEMPLATE_ARN.
TemplateArn string `json:"template_arn,omitempty"`
}
// ACMPCAClient defines the interface for interacting with AWS ACM Private CA.
// This allows for dependency injection and testing with mock clients.
type ACMPCAClient interface {
// IssueCertificate issues a new certificate.
IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error)
// GetCertificate retrieves an issued certificate.
GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error)
// RevokeCertificate revokes a certificate.
RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error
// GetCACertificate retrieves the CA certificate chain.
GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error)
}
// IssueCertificateInput represents the request to issue a certificate.
type IssueCertificateInput struct {
CAArn string
CSR []byte // DER-encoded CSR
SigningAlgorithm string
ValidityDays int
TemplateArn string
}
// IssueCertificateOutput represents the response to an issue request.
type IssueCertificateOutput struct {
CertificateArn string
}
// GetCertificateInput represents the request to retrieve a certificate.
type GetCertificateInput struct {
CAArn string
CertificateArn string
}
// GetCertificateOutput represents the response containing the certificate.
type GetCertificateOutput struct {
Certificate string // PEM-encoded certificate
CertificateChain string // PEM-encoded certificate chain
}
// RevokeCertificateInput represents the request to revoke a certificate.
type RevokeCertificateInput struct {
CAArn string
CertificateSerial string
RevocationReason string
}
// GetCACertificateInput represents the request to retrieve the CA certificate.
type GetCACertificateInput struct {
CAArn string
}
// GetCACertificateOutput represents the response containing the CA certificate.
type GetCACertificateOutput struct {
Certificate string // PEM-encoded CA certificate
CertificateChain string // PEM-encoded CA chain
}
// Connector implements the issuer.Connector interface for AWS ACM Private CA.
type Connector struct {
config *Config
client ACMPCAClient
logger *slog.Logger
}
// New creates a new AWS ACM Private CA connector with the given configuration and logger.
// The real client will use the AWS SDK via the standard credential chain.
func New(config *Config, logger *slog.Logger) *Connector {
if config != nil {
if config.SigningAlgorithm == "" {
config.SigningAlgorithm = "SHA256WITHRSA"
}
if config.ValidityDays == 0 {
config.ValidityDays = 365
}
}
return &Connector{
config: config,
client: &stubClient{}, // Placeholder; real AWS client will be injected or implemented
logger: logger,
}
}
// NewWithClient creates a new AWS ACM Private CA connector with a custom client.
// Used primarily for testing with mock clients.
func NewWithClient(config *Config, client ACMPCAClient, logger *slog.Logger) *Connector {
if config != nil {
if config.SigningAlgorithm == "" {
config.SigningAlgorithm = "SHA256WITHRSA"
}
if config.ValidityDays == 0 {
config.ValidityDays = 365
}
}
return &Connector{
config: config,
client: client,
logger: logger,
}
}
// stubClient is a placeholder client that returns "not implemented" errors.
// In production, this would be replaced with a real AWS SDK client.
type stubClient struct{}
func (s *stubClient) IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error) {
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
}
func (s *stubClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) {
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
}
func (s *stubClient) RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error {
return fmt.Errorf("AWS SDK client not initialized (stub)")
}
func (s *stubClient) GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) {
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
}
// ValidateConfig checks that the AWS ACM Private CA configuration is valid.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid AWS ACM PCA config: %w", err)
}
if cfg.Region == "" {
return fmt.Errorf("AWS region is required")
}
if cfg.CAArn == "" {
return fmt.Errorf("AWS CA ARN is required")
}
// Validate ARN format: arn:aws(-[a-z]+)?:acm-pca:[a-z0-9-]+:\d{12}:certificate-authority/[a-f0-9-]+
arnPattern := regexp.MustCompile(`^arn:aws(-[a-z]+)?:acm-pca:[a-z0-9-]+:\d{12}:certificate-authority/[a-f0-9-]+$`)
if !arnPattern.MatchString(cfg.CAArn) {
return fmt.Errorf("invalid CA ARN format: %s", cfg.CAArn)
}
// Validate signing algorithm if provided
if cfg.SigningAlgorithm != "" {
validAlgorithms := map[string]bool{
"SHA256WITHRSA": true,
"SHA384WITHRSA": true,
"SHA512WITHRSA": true,
"SHA256WITHECDSA": true,
"SHA384WITHECDSA": true,
"SHA512WITHECDSA": true,
}
if !validAlgorithms[cfg.SigningAlgorithm] {
return fmt.Errorf("invalid signing algorithm: %s", cfg.SigningAlgorithm)
}
} else {
cfg.SigningAlgorithm = "SHA256WITHRSA"
}
// Validate validity days if provided
if cfg.ValidityDays < 0 {
return fmt.Errorf("validity days must be non-negative")
}
if cfg.ValidityDays == 0 {
cfg.ValidityDays = 365
}
c.config = &cfg
c.logger.Info("AWS ACM Private CA configuration validated",
"region", cfg.Region,
"ca_arn", cfg.CAArn,
"signing_algorithm", cfg.SigningAlgorithm,
"validity_days", cfg.ValidityDays)
return nil
}
// IssueCertificate issues a new certificate using AWS ACM Private CA.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing AWS ACM PCA issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Decode CSR from PEM
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil {
return nil, fmt.Errorf("failed to decode CSR PEM")
}
// Call AWS API to issue certificate
issueOutput, err := c.client.IssueCertificate(ctx, &IssueCertificateInput{
CAArn: c.config.CAArn,
CSR: csrBlock.Bytes,
SigningAlgorithm: c.config.SigningAlgorithm,
ValidityDays: c.config.ValidityDays,
TemplateArn: c.config.TemplateArn,
})
if err != nil {
return nil, fmt.Errorf("AWS IssueCertificate failed: %w", err)
}
// Retrieve the issued certificate
getCertOutput, err := c.client.GetCertificate(ctx, &GetCertificateInput{
CAArn: c.config.CAArn,
CertificateArn: issueOutput.CertificateArn,
})
if err != nil {
return nil, fmt.Errorf("AWS GetCertificate failed: %w", err)
}
if getCertOutput.Certificate == "" {
return nil, fmt.Errorf("no certificate in AWS response")
}
// Parse the certificate to extract metadata
block, _ := pem.Decode([]byte(getCertOutput.Certificate))
if block == nil {
return nil, fmt.Errorf("failed to decode certificate PEM from AWS")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Extract serial number (hex format, uppercase)
serial := strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber))
// Use certificate ARN as OrderID for revocation lookup
orderID := issueOutput.CertificateArn
c.logger.Info("AWS ACM PCA certificate issued",
"common_name", request.CommonName,
"serial", serial,
"not_after", cert.NotAfter)
return &issuer.IssuanceResult{
CertPEM: getCertOutput.Certificate,
ChainPEM: getCertOutput.CertificateChain,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by creating a new signing request.
// For AWS ACM PCA, renewal is functionally identical to issuance (new cert signed from CSR).
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing AWS ACM PCA renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
})
}
// RevokeCertificate revokes a certificate at AWS ACM Private CA.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing AWS ACM PCA revocation request", "serial", request.Serial)
// Map RFC 5280 reason string to AWS reason
reason := mapRevocationReason(request.Reason)
err := c.client.RevokeCertificate(ctx, &RevokeCertificateInput{
CAArn: c.config.CAArn,
CertificateSerial: request.Serial,
RevocationReason: reason,
})
if err != nil {
return fmt.Errorf("AWS RevokeCertificate failed: %w", err)
}
c.logger.Info("AWS ACM PCA certificate revoked", "serial", request.Serial)
return nil
}
// GetOrderStatus returns the status of an AWS ACM PCA order.
// AWS ACM PCA issues synchronously, so orders are always "completed" immediately.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
UpdatedAt: time.Now(),
}, nil
}
// GenerateCRL is not supported because AWS ACM PCA serves CRL directly.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("CRL delegated to AWS ACM Private CA; use AWS endpoint directly")
}
// SignOCSPResponse is not supported because AWS ACM PCA serves OCSP directly.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("OCSP delegated to AWS ACM Private CA; use AWS endpoint directly")
}
// GetCACertPEM retrieves the CA certificate from AWS ACM Private CA.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
caCertOutput, err := c.client.GetCACertificate(ctx, &GetCACertificateInput{
CAArn: c.config.CAArn,
})
if err != nil {
return "", fmt.Errorf("AWS GetCACertificate failed: %w", err)
}
// Combine CA certificate and chain
if caCertOutput.CertificateChain != "" {
return caCertOutput.Certificate + "\n" + caCertOutput.CertificateChain, nil
}
return caCertOutput.Certificate, nil
}
// GetRenewalInfo returns nil, nil as AWS ACM PCA does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// mapRevocationReason converts RFC 5280 reason strings to AWS ACM PCA reason codes.
func mapRevocationReason(reason *string) string {
if reason == nil {
return "UNSPECIFIED"
}
reasonMap := map[string]string{
"unspecified": "UNSPECIFIED",
"keyCompromise": "KEY_COMPROMISE",
"caCompromise": "CERTIFICATE_AUTHORITY_COMPROMISE",
"affiliationChanged": "AFFILIATION_CHANGED",
"superseded": "SUPERSEDED",
"cessationOfOperation": "CESSATION_OF_OPERATION",
"certificateHold": "CERTIFICATE_HOLD",
"privilegeWithdrawn": "PRIVILEGE_WITHDRAWN",
}
if mapped, ok := reasonMap[*reason]; ok {
return mapped
}
return "UNSPECIFIED"
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,629 @@
package awsacmpca_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"os"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/awsacmpca"
)
// mockACMPCAClient implements the ACMPCAClient interface for testing.
type mockACMPCAClient struct {
issueCertificateErr error
getCertificateErr error
revokeCertificateErr error
getCACertificateErr error
issuedCertPEM string
issuedChainPEM string
caCertPEM string
caCertChainPEM string
lastIssueCertificateInput *awsacmpca.IssueCertificateInput
lastRevokeCertificateInput *awsacmpca.RevokeCertificateInput
}
func (m *mockACMPCAClient) IssueCertificate(ctx context.Context, input *awsacmpca.IssueCertificateInput) (*awsacmpca.IssueCertificateOutput, error) {
m.lastIssueCertificateInput = input
if m.issueCertificateErr != nil {
return nil, m.issueCertificateErr
}
return &awsacmpca.IssueCertificateOutput{
CertificateArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678/certificate/abcdef123456",
}, nil
}
func (m *mockACMPCAClient) GetCertificate(ctx context.Context, input *awsacmpca.GetCertificateInput) (*awsacmpca.GetCertificateOutput, error) {
if m.getCertificateErr != nil {
return nil, m.getCertificateErr
}
return &awsacmpca.GetCertificateOutput{
Certificate: m.issuedCertPEM,
CertificateChain: m.issuedChainPEM,
}, nil
}
func (m *mockACMPCAClient) RevokeCertificate(ctx context.Context, input *awsacmpca.RevokeCertificateInput) error {
m.lastRevokeCertificateInput = input
return m.revokeCertificateErr
}
func (m *mockACMPCAClient) GetCACertificate(ctx context.Context, input *awsacmpca.GetCACertificateInput) (*awsacmpca.GetCACertificateOutput, error) {
if m.getCACertificateErr != nil {
return nil, m.getCACertificateErr
}
return &awsacmpca.GetCACertificateOutput{
Certificate: m.caCertPEM,
CertificateChain: m.caCertChainPEM,
}, nil
}
// Helper function to generate a test certificate and CSR.
func generateTestCertAndCSR(t *testing.T) (certPEM string, csrPEM string) {
// Generate private key
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate private key: %v", err)
}
// Create certificate template
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
t.Fatalf("failed to generate serial number: %v", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "example.com",
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
BasicConstraintsValid: true,
IsCA: false,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"example.com", "www.example.com"},
}
// Create self-signed certificate for testing
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil {
t.Fatalf("failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
// Create CSR
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: "example.com",
},
DNSNames: []string{"example.com", "www.example.com"},
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privKey)
if err != nil {
t.Fatalf("failed to create CSR: %v", err)
}
csrPEM = string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrDER,
}))
return certPEM, csrPEM
}
func TestAWSACMPCAConnector(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) {
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
SigningAlgorithm: "SHA256WITHRSA",
ValidityDays: 365,
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_AllOptionalFields", func(t *testing.T) {
config := awsacmpca.Config{
Region: "eu-west-1",
CAArn: "arn:aws:acm-pca:eu-west-1:123456789012:certificate-authority/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
SigningAlgorithm: "SHA512WITHECDSA",
ValidityDays: 730,
TemplateArn: "arn:aws:acm-pca:eu-west-1:123456789012:template/WebServer",
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_InvalidJSON", func(t *testing.T) {
connector := awsacmpca.New(nil, logger)
err := connector.ValidateConfig(ctx, []byte(`{invalid json}`))
if err == nil {
t.Fatal("Expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "invalid AWS ACM PCA config") {
t.Errorf("Expected config error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingRegion", func(t *testing.T) {
config := awsacmpca.Config{
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing region")
}
if !strings.Contains(err.Error(), "region is required") {
t.Errorf("Expected region required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingCAArn", func(t *testing.T) {
config := awsacmpca.Config{
Region: "us-east-1",
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing CA ARN")
}
if !strings.Contains(err.Error(), "CA ARN is required") {
t.Errorf("Expected CA ARN required error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidCAArn", func(t *testing.T) {
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "not-an-arn",
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for invalid CA ARN")
}
if !strings.Contains(err.Error(), "invalid CA ARN format") {
t.Errorf("Expected invalid ARN error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidSigningAlgorithm", func(t *testing.T) {
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
SigningAlgorithm: "INVALID_ALGO",
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for invalid signing algorithm")
}
if !strings.Contains(err.Error(), "invalid signing algorithm") {
t.Errorf("Expected invalid algorithm error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidValidityDays", func(t *testing.T) {
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
ValidityDays: -1,
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for negative validity days")
}
if !strings.Contains(err.Error(), "validity days must be non-negative") {
t.Errorf("Expected validity days error, got: %v", err)
}
})
t.Run("IssueCertificate_Success", func(t *testing.T) {
certPEM, csrPEM := generateTestCertAndCSR(t)
mockClient := &mockACMPCAClient{
issuedCertPEM: certPEM,
issuedChainPEM: certPEM, // Use same cert as chain for test
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
SigningAlgorithm: "SHA256WITHRSA",
ValidityDays: 365,
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
request := issuer.IssuanceRequest{
CommonName: "example.com",
SANs: []string{"www.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, request)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Fatal("Expected certificate PEM in result")
}
if result.Serial == "" {
t.Fatal("Expected serial number in result")
}
if result.OrderID == "" {
t.Fatal("Expected OrderID (certificate ARN) in result")
}
})
t.Run("IssueCertificate_EmptyCSR", func(t *testing.T) {
mockClient := &mockACMPCAClient{}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
request := issuer.IssuanceRequest{
CommonName: "example.com",
CSRPEM: "", // Empty CSR
}
_, err := connector.IssueCertificate(ctx, request)
if err == nil {
t.Fatal("Expected error for empty CSR")
}
if !strings.Contains(err.Error(), "failed to decode CSR PEM") {
t.Errorf("Expected CSR decode error, got: %v", err)
}
})
t.Run("IssueCertificate_IssueError", func(t *testing.T) {
certPEM, csrPEM := generateTestCertAndCSR(t)
mockClient := &mockACMPCAClient{
issueCertificateErr: fmt.Errorf("AWS service error"),
issuedCertPEM: certPEM,
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
request := issuer.IssuanceRequest{
CommonName: "example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, request)
if err == nil {
t.Fatal("Expected error from IssueCertificate")
}
if !strings.Contains(err.Error(), "IssueCertificate failed") {
t.Errorf("Expected issue error, got: %v", err)
}
})
t.Run("IssueCertificate_GetCertificateError", func(t *testing.T) {
_, csrPEM := generateTestCertAndCSR(t)
mockClient := &mockACMPCAClient{
getCertificateErr: fmt.Errorf("AWS service error"),
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
request := issuer.IssuanceRequest{
CommonName: "example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, request)
if err == nil {
t.Fatal("Expected error from GetCertificate")
}
if !strings.Contains(err.Error(), "GetCertificate failed") {
t.Errorf("Expected get cert error, got: %v", err)
}
})
t.Run("RenewCertificate_Success", func(t *testing.T) {
certPEM, csrPEM := generateTestCertAndCSR(t)
mockClient := &mockACMPCAClient{
issuedCertPEM: certPEM,
issuedChainPEM: certPEM,
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
request := issuer.RenewalRequest{
CommonName: "example.com",
SANs: []string{"www.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, request)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Fatal("Expected certificate PEM in result")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
mockClient := &mockACMPCAClient{}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
reason := "keyCompromise"
request := issuer.RevocationRequest{
Serial: "aabbccdd123456",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, request)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
if mockClient.lastRevokeCertificateInput.RevocationReason != "KEY_COMPROMISE" {
t.Errorf("Expected KEY_COMPROMISE reason, got: %s", mockClient.lastRevokeCertificateInput.RevocationReason)
}
})
t.Run("RevokeCertificate_WithDefaultReason", func(t *testing.T) {
mockClient := &mockACMPCAClient{}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
request := issuer.RevocationRequest{
Serial: "aabbccdd123456",
Reason: nil,
}
err := connector.RevokeCertificate(ctx, request)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
if mockClient.lastRevokeCertificateInput.RevocationReason != "UNSPECIFIED" {
t.Errorf("Expected UNSPECIFIED reason, got: %s", mockClient.lastRevokeCertificateInput.RevocationReason)
}
})
t.Run("RevokeCertificate_Error", func(t *testing.T) {
mockClient := &mockACMPCAClient{
revokeCertificateErr: fmt.Errorf("AWS service error"),
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
request := issuer.RevocationRequest{
Serial: "aabbccdd123456",
}
err := connector.RevokeCertificate(ctx, request)
if err == nil {
t.Fatal("Expected error from RevokeCertificate")
}
})
t.Run("GetOrderStatus_ReturnsCompleted", func(t *testing.T) {
mockClient := &mockACMPCAClient{}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
status, err := connector.GetOrderStatus(ctx, "test-order-id")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected completed status, got: %s", status.Status)
}
})
t.Run("GetCACertPEM_Success", func(t *testing.T) {
certPEM, _ := generateTestCertAndCSR(t)
mockClient := &mockACMPCAClient{
caCertPEM: certPEM,
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
caPEM, err := connector.GetCACertPEM(ctx)
if err != nil {
t.Fatalf("GetCACertPEM failed: %v", err)
}
if caPEM == "" {
t.Fatal("Expected CA certificate PEM")
}
if !strings.Contains(caPEM, "CERTIFICATE") {
t.Errorf("Expected PEM format, got: %s", caPEM)
}
})
t.Run("GetCACertPEM_WithChain", func(t *testing.T) {
certPEM, _ := generateTestCertAndCSR(t)
mockClient := &mockACMPCAClient{
caCertPEM: certPEM,
caCertChainPEM: certPEM,
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
caPEM, err := connector.GetCACertPEM(ctx)
if err != nil {
t.Fatalf("GetCACertPEM failed: %v", err)
}
// Should contain both certificate and chain separated by newline
if !strings.Contains(caPEM, "\n") {
t.Fatal("Expected certificate and chain combined")
}
})
t.Run("GetCACertPEM_Error", func(t *testing.T) {
mockClient := &mockACMPCAClient{
getCACertificateErr: fmt.Errorf("AWS service error"),
}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
_, err := connector.GetCACertPEM(ctx)
if err == nil {
t.Fatal("Expected error from GetCACertPEM")
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
mockClient := &mockACMPCAClient{}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
result, err := connector.GetRenewalInfo(ctx, "cert-pem")
if err != nil {
t.Fatalf("GetRenewalInfo failed: %v", err)
}
if result != nil {
t.Fatal("Expected nil result from GetRenewalInfo")
}
})
t.Run("ValidateConfig_AppliesDefaults", func(t *testing.T) {
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
// SigningAlgorithm and ValidityDays not set
}
connector := awsacmpca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
// Verify defaults were applied by checking the connector's config
// Since config is private, we'll test via IssueCertificate to ensure algorithm is set
})
t.Run("RevocationReason_Mapping", func(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"keyCompromise", "KEY_COMPROMISE"},
{"caCompromise", "CERTIFICATE_AUTHORITY_COMPROMISE"},
{"affiliationChanged", "AFFILIATION_CHANGED"},
{"superseded", "SUPERSEDED"},
{"cessationOfOperation", "CESSATION_OF_OPERATION"},
{"privilegeWithdrawn", "PRIVILEGE_WITHDRAWN"},
}
for _, tc := range testCases {
mockClient := &mockACMPCAClient{}
config := awsacmpca.Config{
Region: "us-east-1",
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
}
connector := awsacmpca.NewWithClient(&config, mockClient, logger)
reason := tc.input
request := issuer.RevocationRequest{
Serial: "test-serial",
Reason: &reason,
}
_ = connector.RevokeCertificate(ctx, request)
if mockClient.lastRevokeCertificateInput.RevocationReason != tc.expected {
t.Errorf("For reason %q, expected %q, got %q", tc.input, tc.expected, mockClient.lastRevokeCertificateInput.RevocationReason)
}
}
})
}