From 7416da8569a8800341d319a09dc5bd75ceadc917 Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:11:00 +0100 Subject: [PATCH 01/10] refactor: extract LambdaAPI interface from concrete *lambda.Client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define a narrow LambdaAPI interface scoped to the two SDK methods we actually use (ListFunctions, GetFunctionConfiguration). Refactor getFilteredLambdaFuncs, getAndProcessOneLambdaFunc, and the new getLambdaPackageDataFromClient to accept the interface instead of *lambda.Client. The public GetLambdaPackageData creates the real client and delegates — command layer is untouched. This is Slice 1 of the fakes & contract tests work (#758). Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 14 ++++++++++++++ internal/aws/aws.go | 20 ++++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index b8fd42d6f..fa2e22e87 100644 --- a/TODO.md +++ b/TODO.md @@ -54,3 +54,17 @@ - [x] Slice 2: Add `--params` flag across all three commands - [x] Slice 3: Show params in `--show-input` output - [x] Slice 4: Update help text and examples + +## Fakes & contract tests for cloud provider integrations (#758) + +- [ ] Slice 1: Define `LambdaAPI` interface and refactor signatures ← active + - [ ] Define `LambdaAPI` interface with `ListFunctions` and `GetFunctionConfiguration` + - [ ] Change `getFilteredLambdaFuncs` to accept `LambdaAPI` + - [ ] Change `getAndProcessOneLambdaFunc` to accept `LambdaAPI` + - [ ] Extract `getLambdaPackageDataFromClient(LambdaAPI, filter)` from `GetLambdaPackageData` + - [ ] All existing tests still pass +- [ ] Slice 2: Contract test suite against real AWS +- [ ] Slice 3: Build `FakeLambdaClient` that passes the contract +- [ ] Slice 4: Fake-backed unit tests for filtering and pagination +- [ ] Slice 5: Fake-backed unit tests for orchestration +- [ ] Slice 6: Trim existing integration tests diff --git a/internal/aws/aws.go b/internal/aws/aws.go index e18694011..27865d308 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -139,6 +139,13 @@ func (staticCreds *AWSStaticCreds) NewLambdaClient() (*lambda.Client, error) { return lambda.NewFromConfig(cfg), nil } +// LambdaAPI is the interface for AWS Lambda operations used by this package. +// The real *lambda.Client satisfies this implicitly. +type LambdaAPI interface { + ListFunctions(ctx context.Context, params *lambda.ListFunctionsInput, optFns ...func(*lambda.Options)) (*lambda.ListFunctionsOutput, error) + GetFunctionConfiguration(ctx context.Context, params *lambda.GetFunctionConfigurationInput, optFns ...func(*lambda.Options)) (*lambda.GetFunctionConfigurationOutput, error) +} + // NewECSClient returns a new ECS API client func (staticCreds *AWSStaticCreds) NewECSClient() (*ecs.Client, error) { cfg, err := staticCreds.NewAWSConfigFromEnvOrFlags() @@ -149,7 +156,7 @@ func (staticCreds *AWSStaticCreds) NewECSClient() (*ecs.Client, error) { } // getFilteredLambdaFuncs fetches a filtered set of lambda functions recursively (50 at a time) and returns a list of FunctionConfiguration -func getFilteredLambdaFuncs(client *lambda.Client, nextMarker *string, allFunctions *[]types.FunctionConfiguration, +func getFilteredLambdaFuncs(client LambdaAPI, nextMarker *string, allFunctions *[]types.FunctionConfiguration, filter *filters.ResourceFilterOptions) (*[]types.FunctionConfiguration, error) { params := &lambda.ListFunctionsInput{} if nextMarker != nil { @@ -187,11 +194,16 @@ func getFilteredLambdaFuncs(client *lambda.Client, nextMarker *string, allFuncti // GetLambdaPackageData returns a digest and metadata of a Lambda function package func (staticCreds *AWSStaticCreds) GetLambdaPackageData(filter *filters.ResourceFilterOptions) ([]*LambdaData, error) { - lambdaData := []*LambdaData{} client, err := staticCreds.NewLambdaClient() if err != nil { - return lambdaData, err + return []*LambdaData{}, err } + return getLambdaPackageDataFromClient(client, filter) +} + +// getLambdaPackageDataFromClient fetches Lambda function data using the provided LambdaAPI client. +func getLambdaPackageDataFromClient(client LambdaAPI, filter *filters.ResourceFilterOptions) ([]*LambdaData, error) { + lambdaData := []*LambdaData{} filteredFunctions, err := getFilteredLambdaFuncs(client, nil, &[]types.FunctionConfiguration{}, filter) if err != nil { @@ -247,7 +259,7 @@ func (staticCreds *AWSStaticCreds) GetLambdaPackageData(filter *filters.Resource } // getAndProcessOneLambdaFunc get a lambda function by its name and return a LambdaData object from it -func getAndProcessOneLambdaFunc(client *lambda.Client, functionName string) (*LambdaData, error) { +func getAndProcessOneLambdaFunc(client LambdaAPI, functionName string) (*LambdaData, error) { params := &lambda.GetFunctionConfigurationInput{ FunctionName: aws.String(functionName), } From c9f70331cba254be209b83f416587205894a766e Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:12:54 +0100 Subject: [PATCH 02/10] test: add LambdaAPI contract test suite against real AWS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared runLambdaContractTests function exercises the behaviours we depend on: listing functions, marker-based pagination, getting function config, and error on missing function. Wired to real *lambda.Client in TestLambdaContract_RealAWS, env-gated behind AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY. This establishes the contract grounded in real AWS behaviour — the fake (next slice) must pass the same suite. Slice 2 of fakes & contract tests work (#758). Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 15 +++--- internal/aws/lambda_contract_test.go | 80 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 internal/aws/lambda_contract_test.go diff --git a/TODO.md b/TODO.md index fa2e22e87..9a1ccfdd6 100644 --- a/TODO.md +++ b/TODO.md @@ -57,13 +57,14 @@ ## Fakes & contract tests for cloud provider integrations (#758) -- [ ] Slice 1: Define `LambdaAPI` interface and refactor signatures ← active - - [ ] Define `LambdaAPI` interface with `ListFunctions` and `GetFunctionConfiguration` - - [ ] Change `getFilteredLambdaFuncs` to accept `LambdaAPI` - - [ ] Change `getAndProcessOneLambdaFunc` to accept `LambdaAPI` - - [ ] Extract `getLambdaPackageDataFromClient(LambdaAPI, filter)` from `GetLambdaPackageData` - - [ ] All existing tests still pass -- [ ] Slice 2: Contract test suite against real AWS +- [x] Slice 1: Define `LambdaAPI` interface and refactor signatures +- [ ] Slice 2: Contract test suite against real AWS ← active + - [ ] Create `runLambdaContractTests(t, client LambdaAPI)` shared test function + - [ ] Test: ListFunctions returns results (non-empty) + - [ ] Test: ListFunctions pagination — marker-based, returns all functions across pages + - [ ] Test: GetFunctionConfiguration for existing function returns config + - [ ] Test: GetFunctionConfiguration for missing function returns error + - [ ] Run suite against real `*lambda.Client`, env-gated - [ ] Slice 3: Build `FakeLambdaClient` that passes the contract - [ ] Slice 4: Fake-backed unit tests for filtering and pagination - [ ] Slice 5: Fake-backed unit tests for orchestration diff --git a/internal/aws/lambda_contract_test.go b/internal/aws/lambda_contract_test.go new file mode 100644 index 000000000..322fd8ddc --- /dev/null +++ b/internal/aws/lambda_contract_test.go @@ -0,0 +1,80 @@ +package aws + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/lambda" + "github.com/kosli-dev/cli/internal/testHelpers" + "github.com/stretchr/testify/require" +) + +// runLambdaContractTests exercises the LambdaAPI contract. It verifies the +// behaviours we depend on — pagination, function config retrieval, and error +// responses for missing functions. +// +// Any implementation that passes this suite is a valid stand-in for the real +// AWS Lambda API as far as this codebase is concerned. +// +// existingFunctionName must name a function that the client can see. +func runLambdaContractTests(t *testing.T, client LambdaAPI, existingFunctionName string) { + t.Helper() + + t.Run("ListFunctions returns without error", func(t *testing.T) { + out, err := client.ListFunctions(context.TODO(), &lambda.ListFunctionsInput{}) + require.NoError(t, err) + require.NotNil(t, out) + }) + + t.Run("ListFunctions with MaxItems paginates via Marker", func(t *testing.T) { + // Request one function per page to force pagination + maxItems := int32(1) + out, err := client.ListFunctions(context.TODO(), &lambda.ListFunctionsInput{ + MaxItems: &maxItems, + }) + require.NoError(t, err) + require.NotNil(t, out) + require.LessOrEqual(t, len(out.Functions), 1) + + if out.NextMarker != nil { + // Follow the marker to prove pagination works + out2, err := client.ListFunctions(context.TODO(), &lambda.ListFunctionsInput{ + MaxItems: &maxItems, + Marker: out.NextMarker, + }) + require.NoError(t, err) + require.NotNil(t, out2) + require.LessOrEqual(t, len(out2.Functions), 1) + } + }) + + t.Run("GetFunctionConfiguration returns config for existing function", func(t *testing.T) { + out, err := client.GetFunctionConfiguration(context.TODO(), &lambda.GetFunctionConfigurationInput{ + FunctionName: &existingFunctionName, + }) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.FunctionName) + require.Equal(t, existingFunctionName, *out.FunctionName) + require.NotNil(t, out.CodeSha256, "CodeSha256 should be present") + require.NotNil(t, out.LastModified, "LastModified should be present") + }) + + t.Run("GetFunctionConfiguration errors for missing function", func(t *testing.T) { + missingName := "nonexistent-function-that-should-not-exist-" + t.Name() + _, err := client.GetFunctionConfiguration(context.TODO(), &lambda.GetFunctionConfigurationInput{ + FunctionName: &missingName, + }) + require.Error(t, err) + }) +} + +func TestLambdaContract_RealAWS(t *testing.T) { + testHelpers.SkipIfEnvVarUnset(t, []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}) + + creds := &AWSStaticCreds{Region: "eu-central-1"} + client, err := creds.NewLambdaClient() + require.NoError(t, err) + + runLambdaContractTests(t, client, "cli-tests") +} From 9b892595e899497ba27bb3da6cab55fe7a594020 Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:15:01 +0100 Subject: [PATCH 03/10] feat: add FakeLambdaClient that passes the LambdaAPI contract FakeLambdaClient is an in-memory implementation of LambdaAPI with marker-based pagination and error responses for missing functions. It passes the same runLambdaContractTests suite that validates the real *lambda.Client, proving it is a trustworthy stand-in. Slice 3 of fakes & contract tests work (#758). Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 14 +++--- internal/aws/fake_lambda.go | 74 ++++++++++++++++++++++++++++ internal/aws/lambda_contract_test.go | 51 ++++++++++++++----- 3 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 internal/aws/fake_lambda.go diff --git a/TODO.md b/TODO.md index 9a1ccfdd6..cee90c128 100644 --- a/TODO.md +++ b/TODO.md @@ -58,14 +58,12 @@ ## Fakes & contract tests for cloud provider integrations (#758) - [x] Slice 1: Define `LambdaAPI` interface and refactor signatures -- [ ] Slice 2: Contract test suite against real AWS ← active - - [ ] Create `runLambdaContractTests(t, client LambdaAPI)` shared test function - - [ ] Test: ListFunctions returns results (non-empty) - - [ ] Test: ListFunctions pagination — marker-based, returns all functions across pages - - [ ] Test: GetFunctionConfiguration for existing function returns config - - [ ] Test: GetFunctionConfiguration for missing function returns error - - [ ] Run suite against real `*lambda.Client`, env-gated -- [ ] Slice 3: Build `FakeLambdaClient` that passes the contract +- [x] Slice 2: Contract test suite against real AWS +- [ ] Slice 3: Build `FakeLambdaClient` that passes the contract ← active + - [ ] Create `FakeLambdaClient` struct with in-memory function list + - [ ] Implement `ListFunctions` with marker-based pagination + - [ ] Implement `GetFunctionConfiguration` with error for missing functions + - [ ] Pass `runLambdaContractTests` against the fake - [ ] Slice 4: Fake-backed unit tests for filtering and pagination - [ ] Slice 5: Fake-backed unit tests for orchestration - [ ] Slice 6: Trim existing integration tests diff --git a/internal/aws/fake_lambda.go b/internal/aws/fake_lambda.go new file mode 100644 index 000000000..4372496bb --- /dev/null +++ b/internal/aws/fake_lambda.go @@ -0,0 +1,74 @@ +package aws + +import ( + "context" + "fmt" + "strconv" + + "github.com/aws/aws-sdk-go-v2/service/lambda" + "github.com/aws/aws-sdk-go-v2/service/lambda/types" +) + +// FakeLambdaClient is an in-memory implementation of LambdaAPI for testing. +// It simulates marker-based pagination and returns errors for missing functions. +type FakeLambdaClient struct { + Functions []types.FunctionConfiguration + // PageSize controls how many functions are returned per ListFunctions call. + // Defaults to 50 (matching the AWS default) if zero. + PageSize int +} + +func (f *FakeLambdaClient) pageSize() int { + if f.PageSize > 0 { + return f.PageSize + } + return 50 +} + +func (f *FakeLambdaClient) ListFunctions(_ context.Context, params *lambda.ListFunctionsInput, _ ...func(*lambda.Options)) (*lambda.ListFunctionsOutput, error) { + pageSize := f.pageSize() + if params.MaxItems != nil && int(*params.MaxItems) < pageSize { + pageSize = int(*params.MaxItems) + } + + start := 0 + if params.Marker != nil { + parsed, err := strconv.Atoi(*params.Marker) + if err != nil { + return nil, fmt.Errorf("invalid marker: %s", *params.Marker) + } + start = parsed + } + + end := start + pageSize + if end > len(f.Functions) { + end = len(f.Functions) + } + + out := &lambda.ListFunctionsOutput{ + Functions: f.Functions[start:end], + } + if end < len(f.Functions) { + marker := strconv.Itoa(end) + out.NextMarker = &marker + } + + return out, nil +} + +func (f *FakeLambdaClient) GetFunctionConfiguration(_ context.Context, params *lambda.GetFunctionConfigurationInput, _ ...func(*lambda.Options)) (*lambda.GetFunctionConfigurationOutput, error) { + if params.FunctionName == nil { + return nil, fmt.Errorf("FunctionName is required") + } + for _, fn := range f.Functions { + if fn.FunctionName != nil && *fn.FunctionName == *params.FunctionName { + return &lambda.GetFunctionConfigurationOutput{ + FunctionName: fn.FunctionName, + CodeSha256: fn.CodeSha256, + LastModified: fn.LastModified, + PackageType: fn.PackageType, + }, nil + } + } + return nil, fmt.Errorf("function not found: %s", *params.FunctionName) +} diff --git a/internal/aws/lambda_contract_test.go b/internal/aws/lambda_contract_test.go index 322fd8ddc..21b6d82ca 100644 --- a/internal/aws/lambda_contract_test.go +++ b/internal/aws/lambda_contract_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/aws/aws-sdk-go-v2/service/lambda" + "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/kosli-dev/cli/internal/testHelpers" "github.com/stretchr/testify/require" ) @@ -27,7 +28,10 @@ func runLambdaContractTests(t *testing.T, client LambdaAPI, existingFunctionName }) t.Run("ListFunctions with MaxItems paginates via Marker", func(t *testing.T) { - // Request one function per page to force pagination + // Request one function per page to force pagination. + // The test setup must seed at least 2 functions for this to exercise + // the marker path — if there's only one, NextMarker will be nil and + // the test degrades to a no-op. maxItems := int32(1) out, err := client.ListFunctions(context.TODO(), &lambda.ListFunctionsInput{ MaxItems: &maxItems, @@ -36,16 +40,16 @@ func runLambdaContractTests(t *testing.T, client LambdaAPI, existingFunctionName require.NotNil(t, out) require.LessOrEqual(t, len(out.Functions), 1) - if out.NextMarker != nil { - // Follow the marker to prove pagination works - out2, err := client.ListFunctions(context.TODO(), &lambda.ListFunctionsInput{ - MaxItems: &maxItems, - Marker: out.NextMarker, - }) - require.NoError(t, err) - require.NotNil(t, out2) - require.LessOrEqual(t, len(out2.Functions), 1) - } + // Follow the marker to prove pagination works + require.NotNil(t, out.NextMarker, "expected NextMarker when more functions exist") + out2, err := client.ListFunctions(context.TODO(), &lambda.ListFunctionsInput{ + MaxItems: &maxItems, + Marker: out.NextMarker, + }) + require.NoError(t, err) + require.NotNil(t, out2) + require.LessOrEqual(t, len(out2.Functions), 1) + require.NotEmpty(t, out2.Functions, "second page should return at least one function") }) t.Run("GetFunctionConfiguration returns config for existing function", func(t *testing.T) { @@ -69,6 +73,31 @@ func runLambdaContractTests(t *testing.T, client LambdaAPI, existingFunctionName }) } +func TestLambdaContract_Fake(t *testing.T) { + fnName1 := "my-function" + fnName2 := "other-function" + lastModified := "2024-01-15T10:30:00.000+0000" + codeSha256 := "abc123" + client := &FakeLambdaClient{ + Functions: []types.FunctionConfiguration{ + { + FunctionName: &fnName1, + CodeSha256: &codeSha256, + LastModified: &lastModified, + PackageType: types.PackageTypeZip, + }, + { + FunctionName: &fnName2, + CodeSha256: &codeSha256, + LastModified: &lastModified, + PackageType: types.PackageTypeZip, + }, + }, + PageSize: 1, + } + runLambdaContractTests(t, client, fnName1) +} + func TestLambdaContract_RealAWS(t *testing.T) { testHelpers.SkipIfEnvVarUnset(t, []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}) From d3f7fa2091ea760b03a45d1c6e9cb412c6c0ef09 Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:16:08 +0100 Subject: [PATCH 04/10] test: add fake-backed unit tests for Lambda filtering and pagination Tests getFilteredLambdaFuncs with the FakeLambdaClient covering: IncludeNames, IncludeNamesRegex, ExcludeNames, ExcludeNamesRegex, combined exclude filters, multi-page pagination with filtering, empty function lists, and invalid regex error handling. These tests run without AWS credentials and complete in milliseconds. Slice 4 of fakes & contract tests work (#758). Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 16 +++-- internal/aws/aws_test.go | 123 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index cee90c128..97f4ebedb 100644 --- a/TODO.md +++ b/TODO.md @@ -59,11 +59,15 @@ - [x] Slice 1: Define `LambdaAPI` interface and refactor signatures - [x] Slice 2: Contract test suite against real AWS -- [ ] Slice 3: Build `FakeLambdaClient` that passes the contract ← active - - [ ] Create `FakeLambdaClient` struct with in-memory function list - - [ ] Implement `ListFunctions` with marker-based pagination - - [ ] Implement `GetFunctionConfiguration` with error for missing functions - - [ ] Pass `runLambdaContractTests` against the fake -- [ ] Slice 4: Fake-backed unit tests for filtering and pagination +- [x] Slice 3: Build `FakeLambdaClient` that passes the contract +- [ ] Slice 4: Fake-backed unit tests for filtering and pagination ← active + - [ ] Test: no filter returns all functions + - [ ] Test: IncludeNames filter + - [ ] Test: IncludeNamesRegex filter + - [ ] Test: ExcludeNames filter + - [ ] Test: ExcludeNamesRegex filter + - [ ] Test: combined exclude + exclude-regex + - [ ] Test: multi-page results with filtering + - [ ] Test: empty function list returns empty result - [ ] Slice 5: Fake-backed unit tests for orchestration - [ ] Slice 6: Trim existing integration tests diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index 210298ab7..cdb438533 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/aws/aws-sdk-go-v2/service/lambda/types" "github.com/kosli-dev/cli/internal/filters" "github.com/kosli-dev/cli/internal/logger" "github.com/kosli-dev/cli/internal/testHelpers" @@ -668,6 +669,128 @@ func (suite *AWSTestSuite) TestGetEcsTasksData() { } } +// helper to build a FakeLambdaClient with named functions for testing +func fakeLambdaClientWithFunctions(names ...string) *FakeLambdaClient { + fns := make([]types.FunctionConfiguration, len(names)) + lastModified := "2024-01-15T10:30:00.000+0000" + codeSha256 := "abc123" + for i, name := range names { + n := name + fns[i] = types.FunctionConfiguration{ + FunctionName: &n, + CodeSha256: &codeSha256, + LastModified: &lastModified, + PackageType: types.PackageTypeZip, + } + } + return &FakeLambdaClient{Functions: fns} +} + +func functionNames(result *[]types.FunctionConfiguration) []string { + names := make([]string, len(*result)) + for i, f := range *result { + names[i] = *f.FunctionName + } + return names +} + +func (suite *AWSTestSuite) TestGetFilteredLambdaFuncs() { + for _, t := range []struct { + name string + functions []string + filter *filters.ResourceFilterOptions + pageSize int + expectedNames []string + wantErr bool + }{ + { + name: "no filter returns all functions", + functions: []string{"alpha", "beta", "gamma"}, + filter: &filters.ResourceFilterOptions{}, + expectedNames: []string{"alpha", "beta", "gamma"}, + }, + { + name: "empty function list returns empty result", + functions: []string{}, + filter: &filters.ResourceFilterOptions{}, + expectedNames: []string{}, + }, + { + name: "IncludeNames filters to matching functions", + functions: []string{"alpha", "beta", "gamma"}, + filter: &filters.ResourceFilterOptions{IncludeNames: []string{"beta"}}, + expectedNames: []string{"beta"}, + }, + { + name: "IncludeNames with multiple names", + functions: []string{"alpha", "beta", "gamma"}, + filter: &filters.ResourceFilterOptions{IncludeNames: []string{"alpha", "gamma"}}, + expectedNames: []string{"alpha", "gamma"}, + }, + { + name: "IncludeNamesRegex filters by pattern", + functions: []string{"alpha", "beta", "gamma"}, + filter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"^a.*"}}, + expectedNames: []string{"alpha"}, + }, + { + name: "ExcludeNames removes matching functions", + functions: []string{"alpha", "beta", "gamma"}, + filter: &filters.ResourceFilterOptions{ExcludeNames: []string{"beta"}}, + expectedNames: []string{"alpha", "gamma"}, + }, + { + name: "ExcludeNamesRegex removes matching pattern", + functions: []string{"alpha", "beta", "gamma"}, + filter: &filters.ResourceFilterOptions{ExcludeNamesRegex: []string{"^[bg].*"}}, + expectedNames: []string{"alpha"}, + }, + { + name: "combined ExcludeNames and ExcludeNamesRegex", + functions: []string{"alpha", "beta", "gamma", "delta"}, + filter: &filters.ResourceFilterOptions{ + ExcludeNames: []string{"alpha"}, + ExcludeNamesRegex: []string{"^d.*"}, + }, + expectedNames: []string{"beta", "gamma"}, + }, + { + name: "multi-page results with filtering across pages", + functions: []string{"alpha", "beta", "gamma", "delta"}, + filter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"^[ag].*"}}, + pageSize: 2, + expectedNames: []string{"alpha", "gamma"}, + }, + { + name: "multi-page results without filtering", + functions: []string{"alpha", "beta", "gamma"}, + filter: &filters.ResourceFilterOptions{}, + pageSize: 1, + expectedNames: []string{"alpha", "beta", "gamma"}, + }, + { + name: "invalid regex causes an error", + functions: []string{"alpha"}, + filter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"invalid["}}, + wantErr: true, + }, + } { + suite.Run(t.name, func() { + client := fakeLambdaClientWithFunctions(t.functions...) + if t.pageSize > 0 { + client.PageSize = t.pageSize + } + result, err := getFilteredLambdaFuncs(client, nil, &[]types.FunctionConfiguration{}, t.filter) + if t.wantErr { + require.Error(suite.T(), err) + return + } + require.NoError(suite.T(), err) + require.ElementsMatch(suite.T(), t.expectedNames, functionNames(result)) + }) + } +} + func skipOrSetCreds(T *testing.T, requireEnvVars bool, creds *AWSStaticCreds) { if requireEnvVars { // skips the test case if it requires env vars and they are not set From 4e9789f6b2d7c7a0bcda54c259c99ef5e0b99f3b Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:17:45 +0100 Subject: [PATCH 05/10] test: add fake-backed unit tests for Lambda orchestration Tests getLambdaPackageDataFromClient with the FakeLambdaClient covering: Zip fingerprint decoding, Image raw CodeSha256, concurrent multi-function processing, empty function list, and error propagation from GetFunctionConfiguration. Also adds GetFunctionConfigurationErr field to FakeLambdaClient for error injection in tests. Slice 5 of fakes & contract tests work (#758). Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 15 +++--- internal/aws/aws_test.go | 97 +++++++++++++++++++++++++++++++++++++ internal/aws/fake_lambda.go | 6 +++ 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index 97f4ebedb..896457cad 100644 --- a/TODO.md +++ b/TODO.md @@ -60,14 +60,11 @@ - [x] Slice 1: Define `LambdaAPI` interface and refactor signatures - [x] Slice 2: Contract test suite against real AWS - [x] Slice 3: Build `FakeLambdaClient` that passes the contract -- [ ] Slice 4: Fake-backed unit tests for filtering and pagination ← active - - [ ] Test: no filter returns all functions - - [ ] Test: IncludeNames filter - - [ ] Test: IncludeNamesRegex filter - - [ ] Test: ExcludeNames filter - - [ ] Test: ExcludeNamesRegex filter - - [ ] Test: combined exclude + exclude-regex - - [ ] Test: multi-page results with filtering +- [x] Slice 4: Fake-backed unit tests for filtering and pagination +- [ ] Slice 5: Fake-backed unit tests for orchestration ← active + - [ ] Test: single Zip function — returns decoded fingerprint + - [ ] Test: single Image function — returns raw CodeSha256 + - [ ] Test: multiple functions processed concurrently + - [ ] Test: error in GetFunctionConfiguration propagates - [ ] Test: empty function list returns empty result -- [ ] Slice 5: Fake-backed unit tests for orchestration - [ ] Slice 6: Trim existing integration tests diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index cdb438533..020997be0 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -2,6 +2,7 @@ package aws import ( "context" + "fmt" "testing" "time" @@ -791,6 +792,102 @@ func (suite *AWSTestSuite) TestGetFilteredLambdaFuncs() { } } +func (suite *AWSTestSuite) TestGetLambdaPackageDataFromClient() { + // base64-encoded SHA256 that decodes to a known hex fingerprint + zipCodeSha256 := "Mh48OOkSYuXHLfS9QF6bF3tvTXUOGvC3jKLiuF1vkbQ=" + expectedZipFingerprint := "321e3c38e91262e5c72df4bd405e9b177b6f4d750e1af0b78ca2e2b85d6f91b4" + // Image package types use the raw CodeSha256 (not base64-decoded) + imageCodeSha256 := "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e" + lastModified := "2024-01-15T10:30:00.000+0000" + + for _, t := range []struct { + name string + client *FakeLambdaClient + filter *filters.ResourceFilterOptions + expectedDigests map[string]string // functionName -> fingerprint + wantErr bool + wantErrMsgSubstring string + }{ + { + name: "single Zip function returns decoded fingerprint", + client: func() *FakeLambdaClient { + fnName := "zip-func" + return &FakeLambdaClient{Functions: []types.FunctionConfiguration{ + {FunctionName: &fnName, CodeSha256: &zipCodeSha256, LastModified: &lastModified, PackageType: types.PackageTypeZip}, + }} + }(), + filter: &filters.ResourceFilterOptions{}, + expectedDigests: map[string]string{"zip-func": expectedZipFingerprint}, + }, + { + name: "single Image function returns raw CodeSha256", + client: func() *FakeLambdaClient { + fnName := "image-func" + return &FakeLambdaClient{Functions: []types.FunctionConfiguration{ + {FunctionName: &fnName, CodeSha256: &imageCodeSha256, LastModified: &lastModified, PackageType: types.PackageTypeImage}, + }} + }(), + filter: &filters.ResourceFilterOptions{}, + expectedDigests: map[string]string{"image-func": imageCodeSha256}, + }, + { + name: "multiple functions processed concurrently", + client: func() *FakeLambdaClient { + fn1 := "zip-func" + fn2 := "image-func" + return &FakeLambdaClient{Functions: []types.FunctionConfiguration{ + {FunctionName: &fn1, CodeSha256: &zipCodeSha256, LastModified: &lastModified, PackageType: types.PackageTypeZip}, + {FunctionName: &fn2, CodeSha256: &imageCodeSha256, LastModified: &lastModified, PackageType: types.PackageTypeImage}, + }} + }(), + filter: &filters.ResourceFilterOptions{}, + expectedDigests: map[string]string{ + "zip-func": expectedZipFingerprint, + "image-func": imageCodeSha256, + }, + }, + { + name: "empty function list returns empty result", + client: &FakeLambdaClient{ + Functions: []types.FunctionConfiguration{}, + }, + filter: &filters.ResourceFilterOptions{}, + expectedDigests: map[string]string{}, + }, + { + name: "GetFunctionConfiguration error propagates", + client: func() *FakeLambdaClient { + fnName := "will-fail" + return &FakeLambdaClient{ + Functions: []types.FunctionConfiguration{ + {FunctionName: &fnName, CodeSha256: &zipCodeSha256, LastModified: &lastModified, PackageType: types.PackageTypeZip}, + }, + GetFunctionConfigurationErr: fmt.Errorf("simulated AWS error"), + } + }(), + filter: &filters.ResourceFilterOptions{}, + wantErr: true, + }, + } { + suite.Run(t.name, func() { + data, err := getLambdaPackageDataFromClient(t.client, t.filter) + if t.wantErr { + require.Error(suite.T(), err) + return + } + require.NoError(suite.T(), err) + + gotDigests := map[string]string{} + for _, d := range data { + for name, fp := range d.Digests { + gotDigests[name] = fp + } + } + require.Equal(suite.T(), t.expectedDigests, gotDigests) + }) + } +} + func skipOrSetCreds(T *testing.T, requireEnvVars bool, creds *AWSStaticCreds) { if requireEnvVars { // skips the test case if it requires env vars and they are not set diff --git a/internal/aws/fake_lambda.go b/internal/aws/fake_lambda.go index 4372496bb..86c5ea7a3 100644 --- a/internal/aws/fake_lambda.go +++ b/internal/aws/fake_lambda.go @@ -16,6 +16,9 @@ type FakeLambdaClient struct { // PageSize controls how many functions are returned per ListFunctions call. // Defaults to 50 (matching the AWS default) if zero. PageSize int + // GetFunctionConfigurationErr, if set, is returned by GetFunctionConfiguration + // for any function. Useful for testing error propagation. + GetFunctionConfigurationErr error } func (f *FakeLambdaClient) pageSize() int { @@ -60,6 +63,9 @@ func (f *FakeLambdaClient) GetFunctionConfiguration(_ context.Context, params *l if params.FunctionName == nil { return nil, fmt.Errorf("FunctionName is required") } + if f.GetFunctionConfigurationErr != nil { + return nil, f.GetFunctionConfigurationErr + } for _, fn := range f.Functions { if fn.FunctionName != nil && *fn.FunctionName == *params.FunctionName { return &lambda.GetFunctionConfigurationOutput{ From 0c87bc5ab8a0e9244b2798c21aeb6372bff41694 Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:19:03 +0100 Subject: [PATCH 06/10] test: trim Lambda integration tests to focused smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove filtering-focused integration test cases (IncludeNamesRegex, ExcludeNames, ExcludeNamesRegex, combined filters, wrong region, invalid regex) — these are now covered by fake-backed unit tests in TestGetFilteredLambdaFuncs and TestGetLambdaPackageDataFromClient. Keep three smoke tests: invalid credentials error, one Zip function happy path, and one Image function happy path. These prove real AWS SDK wiring works without duplicating logic tests. Reduces Lambda integration tests from 8 cases to 3. Slice 6 of fakes & contract tests work (#758). Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 11 ++--- internal/aws/aws_test.go | 100 +++++---------------------------------- 2 files changed, 17 insertions(+), 94 deletions(-) diff --git a/TODO.md b/TODO.md index 896457cad..0cd30f4fe 100644 --- a/TODO.md +++ b/TODO.md @@ -61,10 +61,7 @@ - [x] Slice 2: Contract test suite against real AWS - [x] Slice 3: Build `FakeLambdaClient` that passes the contract - [x] Slice 4: Fake-backed unit tests for filtering and pagination -- [ ] Slice 5: Fake-backed unit tests for orchestration ← active - - [ ] Test: single Zip function — returns decoded fingerprint - - [ ] Test: single Image function — returns raw CodeSha256 - - [ ] Test: multiple functions processed concurrently - - [ ] Test: error in GetFunctionConfiguration propagates - - [ ] Test: empty function list returns empty result -- [ ] Slice 6: Trim existing integration tests +- [x] Slice 5: Fake-backed unit tests for orchestration +- [ ] Slice 6: Trim existing integration tests ← active + - [ ] Remove filtering-focused integration tests (now covered by fakes) + - [ ] Keep: invalid credentials error, one Zip happy path, one Image happy path diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index 020997be0..130b943af 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -251,6 +251,10 @@ func (suite *AWSTestSuite) TestAWSClients() { // they are skipped // All cases will run in CI +// TestGetLambdaPackageData is a smoke test for the real AWS integration. +// Filtering logic and fingerprint processing are covered by fake-backed tests +// (TestGetFilteredLambdaFuncs, TestGetLambdaPackageDataFromClient). +// These cases prove the real SDK wiring works. func (suite *AWSTestSuite) TestGetLambdaPackageData() { type expectedFunction struct { name string @@ -258,7 +262,7 @@ func (suite *AWSTestSuite) TestGetLambdaPackageData() { } for _, t := range []struct { name string - requireEnvVars bool // indicates that a test case needs real credentials from env vars + requireEnvVars bool creds *AWSStaticCreds filter *filters.ResourceFilterOptions expectedFunctions []expectedFunction @@ -275,16 +279,7 @@ func (suite *AWSTestSuite) TestGetLambdaPackageData() { wantErr: true, }, { - name: "providing the wrong region gives an empty result", - creds: &AWSStaticCreds{ - Region: "ap-south-1", - }, - filter: &filters.ResourceFilterOptions{IncludeNames: []string{"cli-tests"}}, - requireEnvVars: true, - expectedFunctions: []expectedFunction{}, - }, - { - name: "can get zip package lambda function data from name", + name: "can get zip package lambda function data", creds: &AWSStaticCreds{ Region: "eu-central-1", }, @@ -294,7 +289,7 @@ func (suite *AWSTestSuite) TestGetLambdaPackageData() { requireEnvVars: true, }, { - name: "can get image package lambda function data from name", + name: "can get image package lambda function data", creds: &AWSStaticCreds{ Region: "eu-central-1", }, @@ -303,67 +298,6 @@ func (suite *AWSTestSuite) TestGetLambdaPackageData() { fingerprint: "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e"}}, requireEnvVars: true, }, - { - name: "can get a list of lambda functions data from names", - creds: &AWSStaticCreds{ - Region: "eu-central-1", - }, - filter: &filters.ResourceFilterOptions{IncludeNames: []string{"cli-tests-docker", "cli-tests"}}, - expectedFunctions: []expectedFunction{ - {name: "cli-tests", - fingerprint: "321e3c38e91262e5c72df4bd405e9b177b6f4d750e1af0b78ca2e2b85d6f91b4"}, - {name: "cli-tests-docker", - fingerprint: "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e"}}, - requireEnvVars: true, - }, - { - name: "can get a list of lambda functions data from names regex", - creds: &AWSStaticCreds{ - Region: "eu-central-1", - }, - filter: &filters.ResourceFilterOptions{IncludeNamesRegex: []string{"^cli-test.*"}}, - expectedFunctions: []expectedFunction{ - {name: "cli-tests", - fingerprint: "321e3c38e91262e5c72df4bd405e9b177b6f4d750e1af0b78ca2e2b85d6f91b4"}, - {name: "cli-tests-docker", - fingerprint: "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e"}}, - requireEnvVars: true, - }, - { - name: "can exclude lambda functions matching a regex pattern", - creds: &AWSStaticCreds{ - Region: "eu-central-1", - }, - filter: &filters.ResourceFilterOptions{ExcludeNamesRegex: []string{"^([^c]|c[^l]|cl[^i]|cli[^-]).*$"}}, - expectedFunctions: []expectedFunction{ - {name: "cli-tests", - fingerprint: "321e3c38e91262e5c72df4bd405e9b177b6f4d750e1af0b78ca2e2b85d6f91b4"}, - {name: "cli-tests-docker", - fingerprint: "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e"}}, - requireEnvVars: true, - }, - { - name: "invalid exclude name regex pattern causes an error", - creds: &AWSStaticCreds{ - Region: "eu-central-1", - }, - filter: &filters.ResourceFilterOptions{ExcludeNamesRegex: []string{"invalid["}}, - requireEnvVars: true, - wantErr: true, - }, - { - name: "can combine exclude and exclude-regex and they are joined", - creds: &AWSStaticCreds{ - Region: "eu-central-1", - }, - filter: &filters.ResourceFilterOptions{ - ExcludeNames: []string{"cli-tests"}, - ExcludeNamesRegex: []string{"^([^c]|c[^l]|cl[^i]|cli[^-]).*$"}}, - expectedFunctions: []expectedFunction{ - {name: "cli-tests-docker", - fingerprint: "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e"}}, - requireEnvVars: true, - }, } { suite.Run(t.name, func() { skipOrSetCreds(suite.T(), t.requireEnvVars, t.creds) @@ -371,25 +305,17 @@ func (suite *AWSTestSuite) TestGetLambdaPackageData() { require.False(suite.T(), (err != nil) != t.wantErr, "GetLambdaPackageData() error = %v, wantErr %v", err, t.wantErr) if !t.wantErr { - matchFound := false require.Len(suite.T(), data, len(t.expectedFunctions)) - if len(t.expectedFunctions) == 0 { - matchFound = true - } - loop1: - for _, expectedFunction := range t.expectedFunctions { + for _, expected := range t.expectedFunctions { + found := false for _, item := range data { - if fingerprint, ok := item.Digests[expectedFunction.name]; ok { - if expectedFunction.fingerprint == fingerprint { - matchFound = true - break loop1 - } else { - suite.T().Logf("fingerprint did not match: GOT %s -- WANT %s", fingerprint, expectedFunction.fingerprint) - } + if fingerprint, ok := item.Digests[expected.name]; ok && fingerprint == expected.fingerprint { + found = true + break } } + require.True(suite.T(), found, "expected function %s with fingerprint %s not found", expected.name, expected.fingerprint) } - require.True(suite.T(), matchFound) } }) } From cb9aa6827089939e858924d79a8b15fad601173f Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:19:18 +0100 Subject: [PATCH 07/10] chore: mark all Lambda fakes & contract slices complete in TODO Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 0cd30f4fe..ffa4919dd 100644 --- a/TODO.md +++ b/TODO.md @@ -62,6 +62,4 @@ - [x] Slice 3: Build `FakeLambdaClient` that passes the contract - [x] Slice 4: Fake-backed unit tests for filtering and pagination - [x] Slice 5: Fake-backed unit tests for orchestration -- [ ] Slice 6: Trim existing integration tests ← active - - [ ] Remove filtering-focused integration tests (now covered by fakes) - - [ ] Keep: invalid credentials error, one Zip happy path, one Image happy path +- [x] Slice 6: Trim existing integration tests From ef346fe03ef628f86b124e1a3d4f6ec9cd785484 Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:34:46 +0100 Subject: [PATCH 08/10] feat: inject FakeLambdaClient into command tests via factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NewLambdaClientFunc package-level factory to internal/aws. GetLambdaPackageData uses the factory instead of creating a client directly. Tests replace the factory to inject a FakeLambdaClient. snapshotLambda_test.go now injects the fake in SetupTest and resets in TearDownTest. All test cases run without AWS credentials — the requireAuthToBeSet/SkipIfEnvVarUnset pattern is removed entirely. Also adds make test_smoke_aws target for running contract and smoke tests against real AWS before release. Slice 7 of fakes & contract tests work (#758). Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 5 ++ TODO.md | 6 ++ cmd/kosli/snapshotLambda_test.go | 95 ++++++++++++++++---------------- internal/aws/aws.go | 16 +++++- 4 files changed, 73 insertions(+), 49 deletions(-) diff --git a/Makefile b/Makefile index f5be6dd61..b6e7d8132 100644 --- a/Makefile +++ b/Makefile @@ -144,6 +144,11 @@ test_integration_restart_server: test_setup_restart_server test_integration_single: test_setup @export KOSLI_TESTS=true $(FAKE_CI_ENV) && $(GOTESTSUM) -- -p=1 ./... -run "${TARGET}" +test_smoke_aws: ## Run AWS contract and smoke tests against real AWS (requires AWS creds) + @echo "Running AWS contract and smoke tests against real AWS..." + @echo "Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to be set" + @$(GOTESTSUM) -- -v -p=1 -run "LambdaContract_RealAWS|AWSTestSuite/TestGetLambdaPackageData|AWSTestSuite/TestGetEcsTasksData|AWSTestSuite/TestGetS3Data" ./internal/aws/ + test_docs: deps vet ensure_network test_setup ## Test docs ./bin/test_docs_cmds.sh docs.kosli.com/content/use_cases/simulating_a_devops_system/_index.md diff --git a/TODO.md b/TODO.md index ffa4919dd..f4b391ded 100644 --- a/TODO.md +++ b/TODO.md @@ -63,3 +63,9 @@ - [x] Slice 4: Fake-backed unit tests for filtering and pagination - [x] Slice 5: Fake-backed unit tests for orchestration - [x] Slice 6: Trim existing integration tests +- [ ] Slice 7: Package-level factory + fake-backed command tests ← active + - [ ] Add `NewLambdaClientFunc` factory variable to `internal/aws` + - [ ] `GetLambdaPackageData` uses factory instead of direct client creation + - [ ] `snapshotLambda_test.go` injects fake in SetupTest, resets in TearDownTest + - [ ] Remove `requireAuthToBeSet` from command test cases — they always run + - [ ] Add Makefile target for AWS smoke tests diff --git a/cmd/kosli/snapshotLambda_test.go b/cmd/kosli/snapshotLambda_test.go index de3b3a65f..d469e0992 100644 --- a/cmd/kosli/snapshotLambda_test.go +++ b/cmd/kosli/snapshotLambda_test.go @@ -4,7 +4,8 @@ import ( "fmt" "testing" - "github.com/kosli-dev/cli/internal/testHelpers" + "github.com/aws/aws-sdk-go-v2/service/lambda/types" + "github.com/kosli-dev/cli/internal/aws" "github.com/stretchr/testify/suite" ) @@ -19,10 +20,6 @@ type SnapshotLambdaTestSuite struct { imageFunctionName string } -type snapshotLambdaTestConfig struct { - requireAuthToBeSet bool -} - func (suite *SnapshotLambdaTestSuite) SetupTest() { suite.envName = "snapshot-lambda-env" suite.zipFunctionName = "cli-tests" @@ -35,67 +32,75 @@ func (suite *SnapshotLambdaTestSuite) SetupTest() { suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) CreateEnv(global.Org, suite.envName, "lambda", suite.T()) + + // Inject fake Lambda client so tests run without AWS credentials. + // The fake is seeded with two functions matching the names used in test cases. + zipCodeSha256 := "Mh48OOkSYuXHLfS9QF6bF3tvTXUOGvC3jKLiuF1vkbQ=" + imageCodeSha256 := "e908950659e56bb886acbb0ecf9b8f38bf6e0382ede71095e166269ee4db601e" + lastModified := "2024-01-15T10:30:00.000+0000" + zipName := suite.zipFunctionName + imageName := suite.imageFunctionName + aws.NewLambdaClientFunc = func(_ *aws.AWSStaticCreds) (aws.LambdaAPI, error) { + return &aws.FakeLambdaClient{ + Functions: []types.FunctionConfiguration{ + { + FunctionName: &zipName, + CodeSha256: &zipCodeSha256, + LastModified: &lastModified, + PackageType: types.PackageTypeZip, + }, + { + FunctionName: &imageName, + CodeSha256: &imageCodeSha256, + LastModified: &lastModified, + PackageType: types.PackageTypeImage, + }, + }, + }, nil + } +} + +func (suite *SnapshotLambdaTestSuite) TearDownTest() { + aws.ResetLambdaClientFactory() } func (suite *SnapshotLambdaTestSuite) TestSnapshotLambdaCmd() { tests := []cmdTestCase{ { - name: "snapshot lambda works with deprecated --function-name for Zip package type", - cmd: fmt.Sprintf(`snapshot lambda %s %s --function-name %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, + name: "snapshot lambda works with deprecated --function-name for Zip package type", + cmd: fmt.Sprintf(`snapshot lambda %s %s --function-name %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), golden: fmt.Sprintf("Flag --function-name has been deprecated, use --function-names instead\n1 lambda functions were reported to environment %s\n", suite.envName), }, { - name: "snapshot lambda works with --function-names for Zip package type", - cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, + name: "snapshot lambda works with --function-names for Zip package type", + cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), golden: fmt.Sprintf("1 lambda functions were reported to environment %s\n", suite.envName), }, { - name: "snapshot lambda works with --function-names taking a list of functions", - cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s,%s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName, suite.imageFunctionName), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, + name: "snapshot lambda works with --function-names taking a list of functions", + cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s,%s`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName, suite.imageFunctionName), golden: fmt.Sprintf("2 lambda functions were reported to environment %s\n", suite.envName), }, { - name: "snapshot lambda works with --function-names for Image package type", - cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.imageFunctionName), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, + name: "snapshot lambda works with --function-names for Image package type", + cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s`, suite.envName, suite.defaultKosliArguments, suite.imageFunctionName), golden: fmt.Sprintf("1 lambda functions were reported to environment %s\n", suite.envName), }, { - name: "snapshot lambda works with --function-names and deprecated --function-version which is ignored", - cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s --function-version 317`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, + name: "snapshot lambda works with --function-names and deprecated --function-version which is ignored", + cmd: fmt.Sprintf(`snapshot lambda %s %s --function-names %s --function-version 317`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), golden: fmt.Sprintf("Flag --function-version has been deprecated, --function-version is no longer supported. It will be removed in a future release.\n1 lambda functions were reported to environment %s\n", suite.envName), }, { - wantError: false, - name: "snapshot lambda without --function-names will report all lambdas in the AWS account", - cmd: fmt.Sprintf(`snapshot lambda %s %s`, suite.envName, suite.defaultKosliArguments), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, + name: "snapshot lambda without --function-names will report all lambdas", + cmd: fmt.Sprintf(`snapshot lambda %s %s`, suite.envName, suite.defaultKosliArguments), goldenRegex: fmt.Sprintf("[0-9]+ lambda functions were reported to environment %s\n", suite.envName), }, { wantError: true, name: "snapshot lambda fails when both of --function-name and --function-names are set", cmd: fmt.Sprintf(`snapshot lambda %s --function-name foo --function-names foo %s`, suite.envName, suite.defaultKosliArguments), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, - golden: "Flag --function-name has been deprecated, use --function-names instead\nError: only one of --function-name, --function-names, --exclude is allowed\n", + golden: "Flag --function-name has been deprecated, use --function-names instead\nError: only one of --function-name, --function-names, --exclude is allowed\n", }, { wantError: true, @@ -122,19 +127,13 @@ func (suite *SnapshotLambdaTestSuite) TestSnapshotLambdaCmd() { golden: "Error: only one of --function-name, --function-names, --exclude-regex is allowed\n", }, { - name: "snapshot lambda works if both --exclude and --exclude-regex are set", - cmd: fmt.Sprintf(`snapshot lambda %s %s --exclude %s --exclude-regex function1`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), - additionalConfig: snapshotLambdaTestConfig{ - requireAuthToBeSet: true, - }, + name: "snapshot lambda works if both --exclude and --exclude-regex are set", + cmd: fmt.Sprintf(`snapshot lambda %s %s --exclude %s --exclude-regex function1`, suite.envName, suite.defaultKosliArguments, suite.zipFunctionName), goldenRegex: fmt.Sprintf("[0-9]+ lambda functions were reported to environment %s\n", suite.envName), }, } for _, t := range tests { - if t.additionalConfig != nil && t.additionalConfig.(snapshotLambdaTestConfig).requireAuthToBeSet { - testHelpers.SkipIfEnvVarUnset(suite.T(), []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}) - } runTestCmd(suite.T(), []cmdTestCase{t}) } } diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 27865d308..589fc3561 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -146,6 +146,20 @@ type LambdaAPI interface { GetFunctionConfiguration(ctx context.Context, params *lambda.GetFunctionConfigurationInput, optFns ...func(*lambda.Options)) (*lambda.GetFunctionConfigurationOutput, error) } +// defaultNewLambdaClient creates a real Lambda client from credentials. +func defaultNewLambdaClient(creds *AWSStaticCreds) (LambdaAPI, error) { + return creds.NewLambdaClient() +} + +// NewLambdaClientFunc is the factory used by GetLambdaPackageData to create a +// LambdaAPI client. Tests can replace this to inject a FakeLambdaClient. +var NewLambdaClientFunc = defaultNewLambdaClient + +// ResetLambdaClientFactory restores the default (real AWS) client factory. +func ResetLambdaClientFactory() { + NewLambdaClientFunc = defaultNewLambdaClient +} + // NewECSClient returns a new ECS API client func (staticCreds *AWSStaticCreds) NewECSClient() (*ecs.Client, error) { cfg, err := staticCreds.NewAWSConfigFromEnvOrFlags() @@ -194,7 +208,7 @@ func getFilteredLambdaFuncs(client LambdaAPI, nextMarker *string, allFunctions * // GetLambdaPackageData returns a digest and metadata of a Lambda function package func (staticCreds *AWSStaticCreds) GetLambdaPackageData(filter *filters.ResourceFilterOptions) ([]*LambdaData, error) { - client, err := staticCreds.NewLambdaClient() + client, err := NewLambdaClientFunc(staticCreds) if err != nil { return []*LambdaData{}, err } From 6b2462e04150580740e09db1b129a23033f9f976 Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 08:34:57 +0100 Subject: [PATCH 09/10] chore: mark Slice 7 complete in TODO Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index f4b391ded..37a5860ad 100644 --- a/TODO.md +++ b/TODO.md @@ -63,9 +63,4 @@ - [x] Slice 4: Fake-backed unit tests for filtering and pagination - [x] Slice 5: Fake-backed unit tests for orchestration - [x] Slice 6: Trim existing integration tests -- [ ] Slice 7: Package-level factory + fake-backed command tests ← active - - [ ] Add `NewLambdaClientFunc` factory variable to `internal/aws` - - [ ] `GetLambdaPackageData` uses factory instead of direct client creation - - [ ] `snapshotLambda_test.go` injects fake in SetupTest, resets in TearDownTest - - [ ] Remove `requireAuthToBeSet` from command test cases — they always run - - [ ] Add Makefile target for AWS smoke tests +- [x] Slice 7: Package-level factory + fake-backed command tests From f8b2aa03d8fb9a98820b11a09ff4995e6319a7f2 Mon Sep 17 00:00:00 2001 From: Steve Tooke Date: Fri, 3 Apr 2026 10:53:28 +0100 Subject: [PATCH 10/10] docs: add TODO checklist for remaining cloud provider fakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists the next steps for ECS, S3, Azure Apps, Docker, and Kubernetes following the pattern established by the Lambda work: interface → contract tests (real first) → fake → unit tests → factory injection into command tests → trim integration tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/TODO.md b/TODO.md index 37a5860ad..2c3ed9d3f 100644 --- a/TODO.md +++ b/TODO.md @@ -57,6 +57,8 @@ ## Fakes & contract tests for cloud provider integrations (#758) +### Lambda (done — this PR) + - [x] Slice 1: Define `LambdaAPI` interface and refactor signatures - [x] Slice 2: Contract test suite against real AWS - [x] Slice 3: Build `FakeLambdaClient` that passes the contract @@ -64,3 +66,51 @@ - [x] Slice 5: Fake-backed unit tests for orchestration - [x] Slice 6: Trim existing integration tests - [x] Slice 7: Package-level factory + fake-backed command tests + +### ECS (next) + +- [ ] Define `ECSAPI` interface (`ListClusters`, `DescribeClusters`, `ListServices`, `ListTasks`, `DescribeTasks`) and refactor signatures +- [ ] Contract test suite against real AWS (env-gated) +- [ ] Build `FakeECSClient` that passes the contract (nested pagination: clusters → services → tasks) +- [ ] Fake-backed unit tests for filtering (cluster names, service names, regex, exclude patterns) +- [ ] Fake-backed unit tests for orchestration (concurrent cluster/service/task fetching, error propagation) +- [ ] `NewECSClientFunc` factory + inject fake into `snapshotECS_test.go` command tests +- [ ] Trim existing ECS integration tests to smoke tests +- [ ] Add ECS to `make test_smoke_aws` + +### S3 + +- [ ] Define `S3API` interface (decide: fake at paginator level or raw `ListObjectsV2` level) +- [ ] Contract test suite against real AWS (env-gated) +- [ ] Build `FakeS3Client` that passes the contract +- [ ] Fake-backed unit tests for path include/exclude filtering and digest computation +- [ ] `NewS3ClientFunc` factory + inject fake into `snapshotS3_test.go` command tests +- [ ] Trim existing S3 integration tests to smoke tests +- [ ] Add S3 to `make test_smoke_aws` + +### Azure Apps + +- [ ] Define interfaces for ARM AppService + Azure Container Registry clients +- [ ] Contract test suite against real Azure (env-gated) +- [ ] Build fakes that pass the contracts +- [ ] Fake-backed unit tests for app listing, image fingerprinting, error propagation +- [ ] Factory + inject fakes into `snapshotAzureApps_test.go` command tests +- [ ] Trim existing Azure integration tests to smoke tests + +### Docker + +- [ ] Define `DockerAPI` interface (Pull, Push, Tag, Remove, Run, container operations) +- [ ] Contract test suite against real Docker daemon +- [ ] Build `FakeDockerClient` that passes the contract +- [ ] Fake-backed unit tests +- [ ] Factory + inject fake into `snapshotDocker_test.go` command tests +- [ ] Trim existing Docker integration tests to smoke tests + +### Kubernetes + +- [ ] Define interface for Kubernetes clientset operations (pod listing, namespace listing) +- [ ] Contract test suite against real cluster (KIND, env-gated) +- [ ] Build fake that passes the contract (semaphore pattern, namespace filtering) +- [ ] Fake-backed unit tests for filtering, large-scale concurrency, error propagation +- [ ] Factory + inject fake into `snapshotK8S_test.go` command tests +- [ ] Trim existing Kube integration tests to smoke tests