Skip to content

BCH-1290: Implement ACM DataFetcher with paginated multi-region certi…#2

Closed
saltpy-cs wants to merge 1 commit into
mainfrom
feat/BCH-1294
Closed

BCH-1290: Implement ACM DataFetcher with paginated multi-region certi…#2
saltpy-cs wants to merge 1 commit into
mainfrom
feat/BCH-1294

Conversation

@saltpy-cs
Copy link
Copy Markdown
Collaborator

@saltpy-cs saltpy-cs commented May 29, 2026

…ficate collection

Replaces the stub FetchData() with a full implementation that calls ListCertificates (paginated), DescribeCertificate, and ListTagsForCertificate for every configured region. The ACMClient interface enables unit tests with a mock client; AWS_ENDPOINT_URL is honoured automatically by the SDK for LocalStack compatibility (BCH-1294).

Summary by CodeRabbit

  • New Features

    • Implemented full AWS ACM certificate data retrieval functionality, including support for multiple regions and comprehensive certificate metadata collection (status, domain information, tags, and validation options).
  • Tests

    • Added comprehensive unit tests covering certificate data fetching scenarios, pagination handling, multi-region support, and data validation.

Review Change Stack

…ficate collection

Replaces the stub FetchData() with a full implementation that calls
ListCertificates (paginated), DescribeCertificate, and ListTagsForCertificate
for every configured region. The ACMClient interface enables unit tests
with a mock client; AWS_ENDPOINT_URL is honoured automatically by the SDK
for LocalStack compatibility (BCH-1294).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

📝 Walkthrough

Walkthrough

This PR implements AWS ACM certificate data retrieval with multi-region support, adding certificate listing and detail fetching through paginated ACM API calls. The implementation integrates into the plugin evaluation pipeline, includes comprehensive test coverage, and updates AWS SDK v2 dependencies.

Changes

ACM Certificate Data Fetching

Layer / File(s) Summary
ACM data contracts and types
internal/data.go
Introduces ACMClient interface and exported data models (CertificateContext, DomainValidationOption) defining certificate representation.
DataFetcher initialization
internal/data.go
Implements NewDataFetcher to construct a fetcher with AWS SDK default config and region-specific ACM client creation.
Certificate retrieval and assembly
internal/data.go
Implements FetchData(ctx) to iterate configured regions, paginate ListCertificates, fetch certificate details via DescribeCertificate and ListTagsForCertificate, and extract account IDs from ARNs; handles errors and missing fields with defaults.
Test infrastructure and coverage
internal/data_test.go
Defines mockACMClient test double and test helpers; covers empty results, single certificate mapping, pagination via NextToken, multi-region client instantiation, and ARN account ID extraction.
AWS SDK dependency updates
go.mod
Declares aws-sdk-go-v2, its config package, and service/acm as direct dependencies with adjusted indirect dependency set.
Plugin evaluation integration
main.go
Updates CompliancePlugin.Eval to call FetchData(ctx) with error handling and restructures policy input as a map with certificates key.

Sequence Diagram

sequenceDiagram
  participant Eval as CompliancePlugin.Eval
  participant Fetcher as DataFetcher
  participant ACM as ACMClient
  Eval->>Fetcher: FetchData(ctx)
  loop per configured region
    Fetcher->>ACM: ListCertificates(ctx)
    loop paginated results with NextToken
      ACM-->>Fetcher: certificate summaries
      loop per certificate with ARN
        Fetcher->>ACM: DescribeCertificate(arn)
        ACM-->>Fetcher: certificate details
        Fetcher->>ACM: ListTagsForCertificate(arn)
        ACM-->>Fetcher: tags
        Fetcher->>Fetcher: assemble CertificateContext
      end
    end
  end
  Fetcher-->>Eval: []CertificateContext or error
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly Related PRs

Poem

🐰 Certificates now flow in streams,
Through regions, paginated dreams,
With tags and domains all alight,
The compliance checks shine bright! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: implementing ACM DataFetcher with paginated multi-region certificate collection, which is the primary objective of this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
main.go (1)

59-67: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a timeout (deadline) to the context used for FetchData in CompliancePlugin.Eval.

main.go creates ctx := context.Background() and passes it to dataFetcher.FetchData(ctx); without a deadline on this call path, ACM/AWS SDK requests can block indefinitely. Use context.WithTimeout/WithDeadline (config-driven if available) before calling FetchData.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.go` around lines 59 - 67, Replace creating a bare context in
CompliancePlugin.Eval with a context that has a deadline: create ctx, cancel :=
context.WithTimeout(context.Background(), <timeout>) (or use a config-driven
duration from l.config), defer cancel() immediately, and pass that ctx to
dataFetcher.FetchData(ctx); ensure the timeout value is configurable (use a
sensible default if missing) and handle context deadline/cancellation errors
returned by FetchData so the function returns a failure status when the fetch is
aborted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/data_test.go`:
- Around line 150-160: The pagination mock in the test's listCertificates
function ignores the incoming request, so the test won't catch if FetchData
fails to forward NextToken; update the mock to inspect its input parameter (the
*acm.ListCertificatesInput) and assert that input.NextToken is nil/empty on the
first call and equals "page2" on the second call (use callCount to distinguish
calls), and if the value is not as expected return a descriptive error to fail
the test; reference the listCertificates mock, callCount and the FetchData
behavior to locate where to add these checks.
- Around line 209-210: The test currently asserts clientRegions in a specific
order (clientRegions[0] == "us-east-1" etc.), which is fragile; change the
assertion in the test that checks multi-region client creation to be
order-insensitive by either converting clientRegions into a set/map and
asserting it contains "us-east-1" and "eu-west-1", or by sorting clientRegions
(e.g., sort.Strings(clientRegions)) and then comparing against the expected
slice, and update the t.Errorf message to report the actual slice when the
assertion fails; locate and update the assertion that references clientRegions
in the test (the if statement comparing length and indices) to use one of these
order-insensitive approaches.
- Around line 42-229: Add tests that assert FetchData returns errors when
underlying ACM calls fail by extending the test suite with cases that inject
failures via mockACMClient: create TestFetchData_ListCertificatesError where
mock.listCertificates returns an error and ensure FetchData returns that error;
TestFetchData_DescribeCertificateError where listCertificates returns a
certificate ARN but mock.describeCertificate returns an error and FetchData
propagates it; and TestFetchData_ListTagsForCertificateError where
describeCertificate succeeds but mock.listTagsForCertificate returns an error
and FetchData fails accordingly. Use the existing newTestFetcher/DataFetcher
setup, call FetchData(context.Background()), and assert non-nil error and that
the error message or type matches the injected error to lock down propagation.

In `@internal/data.go`:
- Line 127: After assigning detail := descOut.Certificate, add a nil check for
descOut.Certificate and handle it gracefully (e.g., return an error or wrap with
context) instead of proceeding to access detail.Options,
detail.DomainValidationOptions, detail.InUseBy, detail.DomainName,
detail.Status, detail.NotAfter, or detail.KeyAlgorithm; locate the assignment to
detail (using the symbols descOut and detail) in internal/data.go and if
descOut.Certificate == nil return a descriptive error like "DescribeCertificate
returned nil Certificate" so callers won't hit a nil pointer panic.

---

Outside diff comments:
In `@main.go`:
- Around line 59-67: Replace creating a bare context in CompliancePlugin.Eval
with a context that has a deadline: create ctx, cancel :=
context.WithTimeout(context.Background(), <timeout>) (or use a config-driven
duration from l.config), defer cancel() immediately, and pass that ctx to
dataFetcher.FetchData(ctx); ensure the timeout value is configurable (use a
sensible default if missing) and handle context deadline/cancellation errors
returned by FetchData so the function returns a failure status when the fetch is
aborted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 015123a2-dcad-4a97-a96d-15a9eb1bc366

📥 Commits

Reviewing files that changed from the base of the PR and between 04a3e5a and 57ff980.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (4)
  • go.mod
  • internal/data.go
  • internal/data_test.go
  • main.go

Comment thread internal/data_test.go
Comment on lines +42 to +229
func TestFetchData_Empty(t *testing.T) {
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{}, nil
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 0 {
t.Errorf("expected 0 certs, got %d", len(certs))
}
}

func TestFetchData_SingleCertificate(t *testing.T) {
arn := "arn:aws:acm:us-east-1:123456789012:certificate/abc-123"
notAfter := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{
{CertificateArn: aws.String(arn)},
},
}, nil
},
describeCertificate: func(_ context.Context, _ *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
return &acm.DescribeCertificateOutput{
Certificate: &types.CertificateDetail{
CertificateArn: aws.String(arn),
DomainName: aws.String("example.com"),
Status: types.CertificateStatus("ISSUED"),
NotAfter: &notAfter,
KeyAlgorithm: types.KeyAlgorithm("RSA_2048"),
Options: &types.CertificateOptions{
CertificateTransparencyLoggingPreference: types.CertificateTransparencyLoggingPreference("ENABLED"),
},
DomainValidationOptions: []types.DomainValidation{
{DomainName: aws.String("example.com"), ValidationMethod: types.ValidationMethod("DNS")},
},
InUseBy: []string{"arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/foo/bar"},
},
}, nil
},
listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
return &acm.ListTagsForCertificateOutput{
Tags: []types.Tag{
{Key: aws.String("env"), Value: aws.String("prod")},
},
}, nil
},
}

f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 1 {
t.Fatalf("expected 1 cert, got %d", len(certs))
}

c := certs[0]
if c.CertificateArn != arn {
t.Errorf("arn: want %q, got %q", arn, c.CertificateArn)
}
if c.AccountID != "123456789012" {
t.Errorf("account_id: want %q, got %q", "123456789012", c.AccountID)
}
if c.Region != "us-east-1" {
t.Errorf("region: want %q, got %q", "us-east-1", c.Region)
}
if c.DomainName != "example.com" {
t.Errorf("domain_name: want %q, got %q", "example.com", c.DomainName)
}
if c.Status != "ISSUED" {
t.Errorf("status: want %q, got %q", "ISSUED", c.Status)
}
if c.NotAfter == nil || !c.NotAfter.Equal(notAfter) {
t.Errorf("not_after: want %v, got %v", notAfter, c.NotAfter)
}
if c.KeyAlgorithm != "RSA_2048" {
t.Errorf("key_algorithm: want %q, got %q", "RSA_2048", c.KeyAlgorithm)
}
if c.TransparencyLoggingPreference != "ENABLED" {
t.Errorf("transparency_logging_preference: want %q, got %q", "ENABLED", c.TransparencyLoggingPreference)
}
if len(c.DomainValidationOptions) != 1 || c.DomainValidationOptions[0].ValidationMethod != "DNS" {
t.Errorf("domain_validation_options: unexpected %v", c.DomainValidationOptions)
}
if len(c.InUseBy) != 1 {
t.Errorf("in_use_by: expected 1 entry, got %v", c.InUseBy)
}
if c.Tags["env"] != "prod" {
t.Errorf("tags: expected env=prod, got %v", c.Tags)
}
}

func TestFetchData_Pagination(t *testing.T) {
arns := []string{
"arn:aws:acm:us-east-1:123456789012:certificate/aaa",
"arn:aws:acm:us-east-1:123456789012:certificate/bbb",
}
callCount := 0

mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
callCount++
if callCount == 1 {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}},
NextToken: aws.String("page2"),
}, nil
}
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}},
}, nil
},
describeCertificate: func(_ context.Context, params *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
return &acm.DescribeCertificateOutput{
Certificate: &types.CertificateDetail{
CertificateArn: params.CertificateArn,
DomainName: aws.String("example.com"),
Status: types.CertificateStatus("ISSUED"),
},
}, nil
},
listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
return &acm.ListTagsForCertificateOutput{}, nil
},
}

f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 2 {
t.Errorf("expected 2 certs, got %d", len(certs))
}
if callCount != 2 {
t.Errorf("expected 2 ListCertificates calls, got %d", callCount)
}
}

func TestFetchData_MultipleRegions(t *testing.T) {
var clientRegions []string

f := &DataFetcher{
logger: hclog.NewNullLogger(),
config: &PluginConfig{Regions: []string{"us-east-1", "eu-west-1"}},
newClient: func(_ context.Context, region string) (ACMClient, error) {
clientRegions = append(clientRegions, region)
return &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{}, nil
},
}, nil
},
}

_, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(clientRegions) != 2 || clientRegions[0] != "us-east-1" || clientRegions[1] != "eu-west-1" {
t.Errorf("expected clients for us-east-1 and eu-west-1, got %v", clientRegions)
}
}

func TestFetchData_AccountIDFromARN(t *testing.T) {
cases := []struct {
arn string
accountID string
}{
{"arn:aws:acm:us-east-1:123456789012:certificate/abc", "123456789012"},
{"arn:aws:acm:eu-west-1:999999999999:certificate/xyz", "999999999999"},
{"invalid", ""},
}
for _, tc := range cases {
got := arnAccountID(tc.arn)
if got != tc.accountID {
t.Errorf("arnAccountID(%q): want %q, got %q", tc.arn, tc.accountID, got)
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add explicit error-path tests for AWS API failures.

The suite currently validates happy paths; add at least one case each for ListCertificates, DescribeCertificate, and ListTagsForCertificate failures to lock down error propagation behavior.

Suggested test additions (skeleton)
+func TestFetchData_ListCertificatesError(t *testing.T) {
+	mock := &mockACMClient{
+		listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
+			return nil, errors.New("list failed")
+		},
+	}
+	f := newTestFetcher([]string{"us-east-1"}, mock)
+	if _, err := f.FetchData(context.Background()); err == nil {
+		t.Fatal("expected error, got nil")
+	}
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestFetchData_Empty(t *testing.T) {
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{}, nil
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 0 {
t.Errorf("expected 0 certs, got %d", len(certs))
}
}
func TestFetchData_SingleCertificate(t *testing.T) {
arn := "arn:aws:acm:us-east-1:123456789012:certificate/abc-123"
notAfter := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{
{CertificateArn: aws.String(arn)},
},
}, nil
},
describeCertificate: func(_ context.Context, _ *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
return &acm.DescribeCertificateOutput{
Certificate: &types.CertificateDetail{
CertificateArn: aws.String(arn),
DomainName: aws.String("example.com"),
Status: types.CertificateStatus("ISSUED"),
NotAfter: &notAfter,
KeyAlgorithm: types.KeyAlgorithm("RSA_2048"),
Options: &types.CertificateOptions{
CertificateTransparencyLoggingPreference: types.CertificateTransparencyLoggingPreference("ENABLED"),
},
DomainValidationOptions: []types.DomainValidation{
{DomainName: aws.String("example.com"), ValidationMethod: types.ValidationMethod("DNS")},
},
InUseBy: []string{"arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/foo/bar"},
},
}, nil
},
listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
return &acm.ListTagsForCertificateOutput{
Tags: []types.Tag{
{Key: aws.String("env"), Value: aws.String("prod")},
},
}, nil
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 1 {
t.Fatalf("expected 1 cert, got %d", len(certs))
}
c := certs[0]
if c.CertificateArn != arn {
t.Errorf("arn: want %q, got %q", arn, c.CertificateArn)
}
if c.AccountID != "123456789012" {
t.Errorf("account_id: want %q, got %q", "123456789012", c.AccountID)
}
if c.Region != "us-east-1" {
t.Errorf("region: want %q, got %q", "us-east-1", c.Region)
}
if c.DomainName != "example.com" {
t.Errorf("domain_name: want %q, got %q", "example.com", c.DomainName)
}
if c.Status != "ISSUED" {
t.Errorf("status: want %q, got %q", "ISSUED", c.Status)
}
if c.NotAfter == nil || !c.NotAfter.Equal(notAfter) {
t.Errorf("not_after: want %v, got %v", notAfter, c.NotAfter)
}
if c.KeyAlgorithm != "RSA_2048" {
t.Errorf("key_algorithm: want %q, got %q", "RSA_2048", c.KeyAlgorithm)
}
if c.TransparencyLoggingPreference != "ENABLED" {
t.Errorf("transparency_logging_preference: want %q, got %q", "ENABLED", c.TransparencyLoggingPreference)
}
if len(c.DomainValidationOptions) != 1 || c.DomainValidationOptions[0].ValidationMethod != "DNS" {
t.Errorf("domain_validation_options: unexpected %v", c.DomainValidationOptions)
}
if len(c.InUseBy) != 1 {
t.Errorf("in_use_by: expected 1 entry, got %v", c.InUseBy)
}
if c.Tags["env"] != "prod" {
t.Errorf("tags: expected env=prod, got %v", c.Tags)
}
}
func TestFetchData_Pagination(t *testing.T) {
arns := []string{
"arn:aws:acm:us-east-1:123456789012:certificate/aaa",
"arn:aws:acm:us-east-1:123456789012:certificate/bbb",
}
callCount := 0
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
callCount++
if callCount == 1 {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}},
NextToken: aws.String("page2"),
}, nil
}
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}},
}, nil
},
describeCertificate: func(_ context.Context, params *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
return &acm.DescribeCertificateOutput{
Certificate: &types.CertificateDetail{
CertificateArn: params.CertificateArn,
DomainName: aws.String("example.com"),
Status: types.CertificateStatus("ISSUED"),
},
}, nil
},
listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
return &acm.ListTagsForCertificateOutput{}, nil
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 2 {
t.Errorf("expected 2 certs, got %d", len(certs))
}
if callCount != 2 {
t.Errorf("expected 2 ListCertificates calls, got %d", callCount)
}
}
func TestFetchData_MultipleRegions(t *testing.T) {
var clientRegions []string
f := &DataFetcher{
logger: hclog.NewNullLogger(),
config: &PluginConfig{Regions: []string{"us-east-1", "eu-west-1"}},
newClient: func(_ context.Context, region string) (ACMClient, error) {
clientRegions = append(clientRegions, region)
return &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{}, nil
},
}, nil
},
}
_, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(clientRegions) != 2 || clientRegions[0] != "us-east-1" || clientRegions[1] != "eu-west-1" {
t.Errorf("expected clients for us-east-1 and eu-west-1, got %v", clientRegions)
}
}
func TestFetchData_AccountIDFromARN(t *testing.T) {
cases := []struct {
arn string
accountID string
}{
{"arn:aws:acm:us-east-1:123456789012:certificate/abc", "123456789012"},
{"arn:aws:acm:eu-west-1:999999999999:certificate/xyz", "999999999999"},
{"invalid", ""},
}
for _, tc := range cases {
got := arnAccountID(tc.arn)
if got != tc.accountID {
t.Errorf("arnAccountID(%q): want %q, got %q", tc.arn, tc.accountID, got)
}
}
}
func TestFetchData_Empty(t *testing.T) {
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{}, nil
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 0 {
t.Errorf("expected 0 certs, got %d", len(certs))
}
}
func TestFetchData_SingleCertificate(t *testing.T) {
arn := "arn:aws:acm:us-east-1:123456789012:certificate/abc-123"
notAfter := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{
{CertificateArn: aws.String(arn)},
},
}, nil
},
describeCertificate: func(_ context.Context, _ *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
return &acm.DescribeCertificateOutput{
Certificate: &types.CertificateDetail{
CertificateArn: aws.String(arn),
DomainName: aws.String("example.com"),
Status: types.CertificateStatus("ISSUED"),
NotAfter: &notAfter,
KeyAlgorithm: types.KeyAlgorithm("RSA_2048"),
Options: &types.CertificateOptions{
CertificateTransparencyLoggingPreference: types.CertificateTransparencyLoggingPreference("ENABLED"),
},
DomainValidationOptions: []types.DomainValidation{
{DomainName: aws.String("example.com"), ValidationMethod: types.ValidationMethod("DNS")},
},
InUseBy: []string{"arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/foo/bar"},
},
}, nil
},
listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
return &acm.ListTagsForCertificateOutput{
Tags: []types.Tag{
{Key: aws.String("env"), Value: aws.String("prod")},
},
}, nil
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 1 {
t.Fatalf("expected 1 cert, got %d", len(certs))
}
c := certs[0]
if c.CertificateArn != arn {
t.Errorf("arn: want %q, got %q", arn, c.CertificateArn)
}
if c.AccountID != "123456789012" {
t.Errorf("account_id: want %q, got %q", "123456789012", c.AccountID)
}
if c.Region != "us-east-1" {
t.Errorf("region: want %q, got %q", "us-east-1", c.Region)
}
if c.DomainName != "example.com" {
t.Errorf("domain_name: want %q, got %q", "example.com", c.DomainName)
}
if c.Status != "ISSUED" {
t.Errorf("status: want %q, got %q", "ISSUED", c.Status)
}
if c.NotAfter == nil || !c.NotAfter.Equal(notAfter) {
t.Errorf("not_after: want %v, got %v", notAfter, c.NotAfter)
}
if c.KeyAlgorithm != "RSA_2048" {
t.Errorf("key_algorithm: want %q, got %q", "RSA_2048", c.KeyAlgorithm)
}
if c.TransparencyLoggingPreference != "ENABLED" {
t.Errorf("transparency_logging_preference: want %q, got %q", "ENABLED", c.TransparencyLoggingPreference)
}
if len(c.DomainValidationOptions) != 1 || c.DomainValidationOptions[0].ValidationMethod != "DNS" {
t.Errorf("domain_validation_options: unexpected %v", c.DomainValidationOptions)
}
if len(c.InUseBy) != 1 {
t.Errorf("in_use_by: expected 1 entry, got %v", c.InUseBy)
}
if c.Tags["env"] != "prod" {
t.Errorf("tags: expected env=prod, got %v", c.Tags)
}
}
func TestFetchData_Pagination(t *testing.T) {
arns := []string{
"arn:aws:acm:us-east-1:123456789012:certificate/aaa",
"arn:aws:acm:us-east-1:123456789012:certificate/bbb",
}
callCount := 0
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
callCount++
if callCount == 1 {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}},
NextToken: aws.String("page2"),
}, nil
}
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}},
}, nil
},
describeCertificate: func(_ context.Context, params *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
return &acm.DescribeCertificateOutput{
Certificate: &types.CertificateDetail{
CertificateArn: params.CertificateArn,
DomainName: aws.String("example.com"),
Status: types.CertificateStatus("ISSUED"),
},
}, nil
},
listTagsForCertificate: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
return &acm.ListTagsForCertificateOutput{}, nil
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
certs, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(certs) != 2 {
t.Errorf("expected 2 certs, got %d", len(certs))
}
if callCount != 2 {
t.Errorf("expected 2 ListCertificates calls, got %d", callCount)
}
}
func TestFetchData_MultipleRegions(t *testing.T) {
var clientRegions []string
f := &DataFetcher{
logger: hclog.NewNullLogger(),
config: &PluginConfig{Regions: []string{"us-east-1", "eu-west-1"}},
newClient: func(_ context.Context, region string) (ACMClient, error) {
clientRegions = append(clientRegions, region)
return &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return &acm.ListCertificatesOutput{}, nil
},
}, nil
},
}
_, err := f.FetchData(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(clientRegions) != 2 || clientRegions[0] != "us-east-1" || clientRegions[1] != "eu-west-1" {
t.Errorf("expected clients for us-east-1 and eu-west-1, got %v", clientRegions)
}
}
func TestFetchData_AccountIDFromARN(t *testing.T) {
cases := []struct {
arn string
accountID string
}{
{"arn:aws:acm:us-east-1:123456789012:certificate/abc", "123456789012"},
{"arn:aws:acm:eu-west-1:999999999999:certificate/xyz", "999999999999"},
{"invalid", ""},
}
for _, tc := range cases {
got := arnAccountID(tc.arn)
if got != tc.accountID {
t.Errorf("arnAccountID(%q): want %q, got %q", tc.arn, tc.accountID, got)
}
}
}
func TestFetchData_ListCertificatesError(t *testing.T) {
mock := &mockACMClient{
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
return nil, errors.New("list failed")
},
}
f := newTestFetcher([]string{"us-east-1"}, mock)
if _, err := f.FetchData(context.Background()); err == nil {
t.Fatal("expected error, got nil")
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/data_test.go` around lines 42 - 229, Add tests that assert FetchData
returns errors when underlying ACM calls fail by extending the test suite with
cases that inject failures via mockACMClient: create
TestFetchData_ListCertificatesError where mock.listCertificates returns an error
and ensure FetchData returns that error; TestFetchData_DescribeCertificateError
where listCertificates returns a certificate ARN but mock.describeCertificate
returns an error and FetchData propagates it; and
TestFetchData_ListTagsForCertificateError where describeCertificate succeeds but
mock.listTagsForCertificate returns an error and FetchData fails accordingly.
Use the existing newTestFetcher/DataFetcher setup, call
FetchData(context.Background()), and assert non-nil error and that the error
message or type matches the injected error to lock down propagation.

Comment thread internal/data_test.go
Comment on lines +150 to +160
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
callCount++
if callCount == 1 {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}},
NextToken: aws.String("page2"),
}, nil
}
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}},
}, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Assert request NextToken in the pagination mock.

Line 150 currently ignores the request input, so pagination can still “pass” even if FetchData never forwards NextToken.

Proposed test hardening
 	listCertificates: func(_ context.Context, in *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
 		callCount++
 		if callCount == 1 {
+			if in.NextToken != nil {
+				t.Fatalf("first page should not include NextToken, got %q", aws.ToString(in.NextToken))
+			}
 			return &acm.ListCertificatesOutput{
 				CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}},
 				NextToken:              aws.String("page2"),
 			}, nil
 		}
+		if aws.ToString(in.NextToken) != "page2" {
+			t.Fatalf("second page should include NextToken=page2, got %q", aws.ToString(in.NextToken))
+		}
 		return &acm.ListCertificatesOutput{
 			CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}},
 		}, nil
 	},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
listCertificates: func(_ context.Context, _ *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
callCount++
if callCount == 1 {
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}},
NextToken: aws.String("page2"),
}, nil
}
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}},
}, nil
listCertificates: func(_ context.Context, in *acm.ListCertificatesInput, _ ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) {
callCount++
if callCount == 1 {
if in.NextToken != nil {
t.Fatalf("first page should not include NextToken, got %q", aws.ToString(in.NextToken))
}
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[0])}},
NextToken: aws.String("page2"),
}, nil
}
if aws.ToString(in.NextToken) != "page2" {
t.Fatalf("second page should include NextToken=page2, got %q", aws.ToString(in.NextToken))
}
return &acm.ListCertificatesOutput{
CertificateSummaryList: []types.CertificateSummary{{CertificateArn: aws.String(arns[1])}},
}, nil
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/data_test.go` around lines 150 - 160, The pagination mock in the
test's listCertificates function ignores the incoming request, so the test won't
catch if FetchData fails to forward NextToken; update the mock to inspect its
input parameter (the *acm.ListCertificatesInput) and assert that input.NextToken
is nil/empty on the first call and equals "page2" on the second call (use
callCount to distinguish calls), and if the value is not as expected return a
descriptive error to fail the test; reference the listCertificates mock,
callCount and the FetchData behavior to locate where to add these checks.

Comment thread internal/data_test.go
Comment on lines +209 to +210
if len(clientRegions) != 2 || clientRegions[0] != "us-east-1" || clientRegions[1] != "eu-west-1" {
t.Errorf("expected clients for us-east-1 and eu-west-1, got %v", clientRegions)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Avoid order-coupled assertion for multi-region client creation.

Line 209 hard-codes region order; this makes the test fragile if region processing becomes concurrent.

Order-insensitive assertion
-	if len(clientRegions) != 2 || clientRegions[0] != "us-east-1" || clientRegions[1] != "eu-west-1" {
-		t.Errorf("expected clients for us-east-1 and eu-west-1, got %v", clientRegions)
-	}
+	got := map[string]int{}
+	for _, r := range clientRegions {
+		got[r]++
+	}
+	if got["us-east-1"] != 1 || got["eu-west-1"] != 1 || len(clientRegions) != 2 {
+		t.Errorf("expected one client each for us-east-1 and eu-west-1, got %v", clientRegions)
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if len(clientRegions) != 2 || clientRegions[0] != "us-east-1" || clientRegions[1] != "eu-west-1" {
t.Errorf("expected clients for us-east-1 and eu-west-1, got %v", clientRegions)
got := map[string]int{}
for _, r := range clientRegions {
got[r]++
}
if got["us-east-1"] != 1 || got["eu-west-1"] != 1 || len(clientRegions) != 2 {
t.Errorf("expected one client each for us-east-1 and eu-west-1, got %v", clientRegions)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/data_test.go` around lines 209 - 210, The test currently asserts
clientRegions in a specific order (clientRegions[0] == "us-east-1" etc.), which
is fragile; change the assertion in the test that checks multi-region client
creation to be order-insensitive by either converting clientRegions into a
set/map and asserting it contains "us-east-1" and "eu-west-1", or by sorting
clientRegions (e.g., sort.Strings(clientRegions)) and then comparing against the
expected slice, and update the t.Errorf message to report the actual slice when
the assertion fails; locate and update the assertion that references
clientRegions in the test (the if statement comparing length and indices) to use
one of these order-insensitive approaches.

Comment thread internal/data.go
return CertificateContext{}, fmt.Errorf("ListTagsForCertificate: %w", err)
}

detail := descOut.Certificate
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add nil check for descOut.Certificate.

If DescribeCertificate returns successfully but with a nil Certificate field (edge case), subsequent accesses to detail.Options, detail.DomainValidationOptions, detail.InUseBy, detail.DomainName, detail.Status, detail.NotAfter, and detail.KeyAlgorithm will cause a nil pointer panic.

🛡️ Proposed fix to guard against nil Certificate
 	detail := descOut.Certificate
+	if detail == nil {
+		return CertificateContext{}, fmt.Errorf("DescribeCertificate returned nil certificate")
+	}
 
 	transparencyPref := ""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
detail := descOut.Certificate
detail := descOut.Certificate
if detail == nil {
return CertificateContext{}, fmt.Errorf("DescribeCertificate returned nil certificate")
}
transparencyPref := ""
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/data.go` at line 127, After assigning detail := descOut.Certificate,
add a nil check for descOut.Certificate and handle it gracefully (e.g., return
an error or wrap with context) instead of proceeding to access detail.Options,
detail.DomainValidationOptions, detail.InUseBy, detail.DomainName,
detail.Status, detail.NotAfter, or detail.KeyAlgorithm; locate the assignment to
detail (using the symbols descOut and detail) in internal/data.go and if
descOut.Certificate == nil return a descriptive error like "DescribeCertificate
returned nil Certificate" so callers won't hit a nil pointer panic.

@saltpy-cs saltpy-cs closed this Jun 1, 2026
@saltpy-cs saltpy-cs deleted the feat/BCH-1294 branch June 1, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant