Files
certctl/internal/connector/target/awsacm/awsacm_failure_test.go
T
shankar0123 edf6bee7f8 target(awsacm): SDK-driven AWS Certificate Manager target connector
Closes Rank 5 (AWS half) of the 2026-05-03 Infisical deep-research
deliverable (cowork/infisical-deep-research-results.md Part 5).
Pre-fix, certctl had no path to deploy certs to AWS-managed TLS-
termination endpoints (ALB / CloudFront / API Gateway / App Runner)
— operators terminating TLS at AWS had to use Infisical secret-sync,
manual aws-cli imports, or external automation. This commit lands
the SDK-driven AWS Certificate Manager target connector that closes
the gap end-to-end.

Architecture:
  - internal/connector/target/awsacm/awsacm.go — Connector wraps
    *acm.Client behind the ACMClient interface seam (mirrors
    awsacmpca's ACMPCAClient pattern from the issuer side).
    LoadDefaultConfig handles the standard AWS credential chain
    (IRSA / EC2 instance profile / SSO / env vars); no embedded
    creds in connector Config.
  - Pre-deploy snapshot via DescribeCertificate + GetCertificate so
    on-import-failure rollback restores the previous cert. Mirrors
    the Bundle 5 IIS pattern + the Bundle 7/8 WinCertStore /
    JavaKeystore patterns. Surfaces rollback success/failure via
    the existing certctl_deploy_rollback_total Prometheus counter
    label set.
  - Provenance tags: certctl-managed-by=certctl + certctl-
    certificate-id=<mc-id> set automatically on every import. ACM
    strips tags on re-import, so the connector calls
    AddTagsToCertificate post-import to keep the provenance pair
    fresh. Operators looking up a cert ARN by managed-cert ID
    (Terraform data source, CloudFormation output) match against
    these tags.
  - DeploymentRequest.KeyPEM held in agent memory only — never
    written to disk. Aligns with the pull-only deployment model
    documented in CLAUDE.md.

Tests:
  - awsacm_test.go: 15-subtest happy-path + validation matrix
    covering ValidateConfig (success / missing-region / malformed-
    region / malformed-ARN / reserved-tag rejection),
    DeployCertificate (fresh import / rotate-in-place / rollback-
    on-serial-mismatch / rollback-also-fails / empty-key-rejected /
    no-client-rejected), ValidateOnly (returns sentinel),
    ValidateDeployment (serial match / mismatch / no-ARN-yet).
  - awsacm_failure_test.go: 5 per-error-class contract tests
    mirroring the awsacmpca_failure_test.go shape (commit
    a2a59a8) — AccessDeniedException (smithy.GenericAPIError),
    ResourceNotFoundException (typed), ThrottlingException
    (smithy.GenericAPIError, FaultServer preserved),
    InvalidArgsException (typed, terminal), RequestInProgress
    Exception (typed). All assert errors.As against the SDK type +
    operator-actionable substring + connector-side wrap framing.
  - Coverage on awsacm.go: 54.9% of statements (matches the K8s-
    Secret + IIS connectors' 50-65% range; rollback-failure paths
    contribute most of the un-covered surface — those exercise
    only when the rollback's SDK call also returns an error).
  - go test -race -count=10 green; no goroutine leaks.

Wiring:
  - internal/domain/connector.go: TargetTypeAWSACM = "AWSACM".
  - internal/service/target.go: validTargetTypes set extended.
  - cmd/agent/main.go::createTargetConnector: AWSACM case arm
    mirroring the KubernetesSecrets shape exactly. Calls
    awsacm.New(context.Background(), &cfg, a.logger) — the
    SDK-loading happens here, not lazily, so config errors
    surface at agent boot.
  - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported
    Types: AWSACM added to the type matrix + the InvalidJSON
    matrix.

go.mod / go.sum:
  - github.com/aws/aws-sdk-go-v2/service/acm v1.38.3 (direct).
    aws-sdk-go-v2 + service/acmpca + smithy-go were already direct
    from the awsacmpca issuer; this is the distribution-side
    companion package.

Documentation:
  - docs/connectors.md "AWS Certificate Manager (ACM)" section:
    config table, IAM policy JSON (5 actions on
    arn:aws:acm:*:*:certificate/*), IRSA / EC2 instance-profile /
    SSO auth recipes, atomic-rollback contract, Terraform ALB-
    attachment snippet, threat model carve-outs (no disk writes,
    mandatory provenance tags, no long-lived creds in Config),
    procurement checklist crib (5 bullets paste-able into a
    security review).

Out of scope (intentional, flagged in V3-Pro forward path):
  - CloudFront / ALB auto-attach (UpdateDistribution requires a
    different IAM scope than ACM ImportCertificate).
  - Cross-region ACM replication (ACM is regional; CloudFront
    forces us-east-1).
  - Tag-filtered ARN discovery (V2 uses operator-pinned
    Config.CertificateArn after first deploy; tag-scan path
    requires acm:ListTagsForCertificate which we deliberately
    keep off the minimum-IAM-policy surface).
  - Azure Key Vault (separate cloud, separate connector — Azure
    half of Rank 5 ships in a follow-on commit).

Verified locally:
- gofmt clean.
- go vet ./internal/connector/target/awsacm/...
  ./internal/domain/... ./internal/service/...
  ./cmd/agent/...  clean.
- go test -short -count=1 ./internal/connector/target/awsacm/...
  ./internal/domain/... ./cmd/agent/...  green (15 + 5 awsacm
  subtests; all 15 supported target types instantiate via the
  agent factory).
- go test -race -count=10 ./internal/connector/target/awsacm/...
  green.

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5.
Acquisition prompt:
cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md.
2026-05-03 22:32:45 +00:00

228 lines
9.0 KiB
Go

package awsacm_test
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable
// (cowork/infisical-deep-research-results.md Part 5). Per-error-class
// failure tests for the AWS ACM target connector — mirrors the
// awsacmpca_failure_test.go shape (commit 60dce0b) on the issuer side.
//
// Each test injects one specific AWS SDK v2 typed error via the
// mockACMClient seam, calls DeployCertificate, and asserts:
//
// 1. error non-nil,
// 2. errors.As against the SDK's typed error value succeeds (so the
// wrap chain via fmt.Errorf("...%w", ...) is intact and upstream
// retry / classification logic can introspect the typed value),
// 3. operator-actionable substring is present in the surfaced
// message,
// 4. the failure category is correct (e.g. throttling = retryable;
// validation = terminal).
import (
"context"
"errors"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types"
smithy "github.com/aws/smithy-go"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/awsacm"
)
// TestAWSACM_Issue_AccessDenied_OperatorActionableError pins the
// behaviour when the IAM principal lacks acm:ImportCertificate. AWS
// surfaces this as a smithy APIError with Code="AccessDeniedException"
// (the ACM SDK does not generate a typed *types.AccessDeniedException
// in v1.38.x — read it locally to confirm).
func TestAWSACM_Issue_AccessDenied_OperatorActionableError(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _ := generateTestCert(t, "denied.example.com")
sdkErr := &smithy.GenericAPIError{
Code: "AccessDeniedException",
Message: "User: arn:aws:iam::123456789012:user/ci is not authorized to perform: acm:ImportCertificate",
Fault: smithy.FaultClient,
}
mock := &mockACMClient{importErr: sdkErr}
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-denied"},
})
if err == nil {
t.Fatal("expected access-denied error, got nil")
}
var sdk *smithy.GenericAPIError
if !errors.As(err, &sdk) {
t.Fatalf("wrap chain broke — errors.As against *smithy.GenericAPIError failed; err=%v", err)
}
if sdk.ErrorCode() != "AccessDeniedException" {
t.Errorf("expected ErrorCode=AccessDeniedException, got %q", sdk.ErrorCode())
}
msg := err.Error()
if !strings.Contains(msg, "AccessDenied") {
t.Errorf("operator-actionable substring missing — message must mention AccessDenied; got: %s", msg)
}
if !strings.Contains(msg, "ImportCertificate failed") {
t.Errorf("connector wrap missing — expected 'ImportCertificate failed: ...' framing; got: %s", msg)
}
}
// TestAWSACM_Issue_ResourceNotFound_NamesTheMissingARN pins behaviour
// when the configured CertificateArn doesn't exist (deleted out-of-
// band, typo'd config, wrong region). The SDK's
// *types.ResourceNotFoundException carries the ARN in its message; the
// connector must preserve the ARN through the wrap chain so the
// operator can identify which resource was missing.
func TestAWSACM_Issue_ResourceNotFound_NamesTheMissingARN(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _ := generateTestCert(t, "missing.example.com")
missingArn := "arn:aws:acm:us-east-1:123456789012:certificate/deadbeef-dead-beef-dead-beefdeadbeef"
sdkErr := &acmtypes.ResourceNotFoundException{
Message: aws.String("Could not find certificate " + missingArn),
}
// We need the snapshot read to fail — the connector calls
// GetCertificate first to capture the snapshot. ResourceNotFound at
// snapshot time means "no cert at this ARN," so we surface that
// error and bail before the import.
mock := &mockACMClient{getErr: sdkErr}
cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: missingArn}
c := awsacm.NewWithClient(cfg, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err == nil {
t.Fatal("expected resource-not-found error")
}
var sdk *acmtypes.ResourceNotFoundException
if !errors.As(err, &sdk) {
t.Fatalf("wrap chain broke — errors.As against *types.ResourceNotFoundException failed; err=%v", err)
}
msg := err.Error()
if !strings.Contains(msg, missingArn) {
t.Errorf("operator-actionable substring missing — message must name the missing ARN %q; got: %s", missingArn, msg)
}
if !strings.Contains(msg, "snapshot") {
t.Errorf("expected 'snapshot' framing on pre-deploy snapshot failure; got: %s", msg)
}
}
// TestAWSACM_Issue_Throttling_RetryableSurfacePreserved pins the
// behaviour when ACM throttles a burst of imports (renewal storm,
// bulk migration). Real traffic surfaces ThrottlingException via
// *smithy.GenericAPIError; the connector must preserve the typed
// value + Fault classification so any upstream retry layer can engage.
// Per the spec's "no new retry logic" scope, the connector itself
// does not retry; it surfaces the typed error.
func TestAWSACM_Issue_Throttling_RetryableSurfacePreserved(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _ := generateTestCert(t, "throttle.example.com")
sdkErr := &smithy.GenericAPIError{
Code: "ThrottlingException",
Message: "Rate exceeded",
Fault: smithy.FaultServer,
}
mock := &mockACMClient{importErr: sdkErr}
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-throttle"},
})
if err == nil {
t.Fatal("expected throttle error")
}
var sdk *smithy.GenericAPIError
if !errors.As(err, &sdk) {
t.Fatalf("wrap chain broke; err=%v", err)
}
if sdk.ErrorCode() != "ThrottlingException" {
t.Errorf("expected ErrorCode=ThrottlingException, got %q", sdk.ErrorCode())
}
if sdk.ErrorFault() != smithy.FaultServer {
t.Errorf("expected FaultServer (retryable class) preserved; got %v", sdk.ErrorFault())
}
if !strings.Contains(err.Error(), "Throttling") {
t.Errorf("operator-actionable substring missing — message must mention Throttling; got: %s", err.Error())
}
}
// TestAWSACM_Issue_InvalidArgs_TerminalNotRetryable pins behaviour
// when ACM rejects the cert+key as malformed (mismatched key/cert,
// unsupported algorithm). InvalidArgsException is a terminal class —
// operators must fix the inputs, not retry.
func TestAWSACM_Issue_InvalidArgs_TerminalNotRetryable(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _ := generateTestCert(t, "invalid.example.com")
sdkErr := &acmtypes.InvalidArgsException{
Message: aws.String("The certificate body is invalid: chain not bound to leaf"),
}
mock := &mockACMClient{importErr: sdkErr}
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-invalid"},
})
if err == nil {
t.Fatal("expected invalid-args error")
}
var sdk *acmtypes.InvalidArgsException
if !errors.As(err, &sdk) {
t.Fatalf("wrap chain broke; err=%v", err)
}
if !strings.Contains(err.Error(), "chain not bound") {
t.Errorf("operator-actionable substring missing — message must name the validation issue; got: %s", err.Error())
}
if !strings.Contains(err.Error(), "InvalidArgs") {
t.Errorf("expected InvalidArgs in surfaced message; got: %s", err.Error())
}
}
// TestAWSACM_Issue_RequestInProgress_TerminalForCurrentAttempt pins
// behaviour when ACM reports an in-flight request for the same
// idempotency key. RequestInProgressException IS a generated typed
// value; the connector must surface it cleanly so upstream logic can
// decide whether to wait + retry or fail-fast.
func TestAWSACM_Issue_RequestInProgress_TerminalForCurrentAttempt(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _ := generateTestCert(t, "inprogress.example.com")
sdkErr := &acmtypes.RequestInProgressException{
Message: aws.String("The certificate request is already being processed; resubmit after completion"),
}
mock := &mockACMClient{importErr: sdkErr}
c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-inprogress"},
})
if err == nil {
t.Fatal("expected request-in-progress error")
}
var sdk *acmtypes.RequestInProgressException
if !errors.As(err, &sdk) {
t.Fatalf("wrap chain broke; err=%v", err)
}
if !strings.Contains(err.Error(), "RequestInProgress") {
t.Errorf("expected RequestInProgress in surfaced message; got: %s", err.Error())
}
if !strings.Contains(err.Error(), "already being processed") {
t.Errorf("operator-actionable substring missing — message must explain the conflict; got: %s", err.Error())
}
}