From 1725d61859b6ebc90a178c6d345a4e081d868f01 Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 16:51:23 +0100 Subject: [PATCH 1/8] add end-to-end test suite for Granted --- .github/workflows/test.yml | 30 +++ pkg/integration_testing/E2E_TESTING.md | 119 ++++++++++ pkg/integration_testing/README.md | 66 ++++++ pkg/integration_testing/assume_e2e_test.go | 245 +++++++++++++++++++++ pkg/integration_testing/test_e2e.sh | 39 ++++ 5 files changed, 499 insertions(+) create mode 100644 pkg/integration_testing/E2E_TESTING.md create mode 100644 pkg/integration_testing/README.md create mode 100644 pkg/integration_testing/assume_e2e_test.go create mode 100755 pkg/integration_testing/test_e2e.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 201ec86a..7f0d7cda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,36 @@ jobs: - name: Run ShellCheck uses: ludeeus/action-shellcheck@94e0aab03ca135d11a35e5bfc14e6746dc56e7e9 + integration-test: + name: Integration Tests + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.22.1" + + - name: Download Linux Binaries + uses: actions/download-artifact@v4 + with: + name: go-binaries-linux + path: ./bin/linux/ + + - name: Make Binaries Executable + run: chmod +x ./bin/linux/* + + - name: Run Integration Tests + env: + GRANTED_BINARY_PATH: ${{ github.workspace }}/bin/linux/dgranted + GRANTED_E2E_TESTING: "true" + CGO_ENABLED: 1 + run: | + go test -v ./pkg/integration_testing/... -run TestAssumeCommandE2E + # linux-installs: # needs: test # name: Smoke Test (Linux) diff --git a/pkg/integration_testing/E2E_TESTING.md b/pkg/integration_testing/E2E_TESTING.md new file mode 100644 index 00000000..1c9c44ad --- /dev/null +++ b/pkg/integration_testing/E2E_TESTING.md @@ -0,0 +1,119 @@ +# End-to-End Integration Testing for Granted Assume Command + +This directory contains integration tests that verify the `assume` command works correctly in a realistic environment with mocked AWS APIs. + +## Architecture + +The integration test suite consists of: + +1. **Mock AWS Server** (`assume_e2e_test.go`) + - Simulates AWS SSO, OIDC, and STS endpoints + - Returns mock credentials without requiring network access + - Tracks access for verification + +2. **E2E Test** (`TestAssumeCommandE2E`) + - Uses pre-built binary from CI or builds one locally + - Creates isolated test environment (temp directories) + - Runs the assume command with real AWS config files + - Verifies credential output format + +## Running the Tests + +### Locally + +```bash +# Run the E2E test (builds binary if needed) +GRANTED_E2E_TESTING=true go test -v -run TestAssumeCommandE2E ./pkg/integration_testing/... + +# Use with pre-built binary +GRANTED_E2E_TESTING=true GRANTED_BINARY_PATH=/path/to/dgranted go test -v -run TestAssumeCommandE2E ./pkg/integration_testing/... + +# Or use the test script (checks for GRANTED_E2E_TESTING automatically) +GRANTED_E2E_TESTING=true ./pkg/integration_testing/test_e2e.sh +``` + +### In CI (GitHub Actions) + +The test runs automatically on push/PR via `.github/workflows/test.yml` in the `integration-test` job: +- Uses binaries built in the `test` job +- Downloads the Linux binaries artifact +- Sets `GRANTED_BINARY_PATH` environment variable +- Runs the integration test suite + +## Test Flow + +1. **Binary Setup** + - CI: Uses pre-built binary from artifacts + - Local: Builds binary if `GRANTED_BINARY_PATH` not set + +2. **Environment Setup** + - Creates temporary home directory + - Sets up AWS config with test IAM profile + - Configures granted settings + - Starts mock AWS server + +3. **Execution Phase** + - Runs `dgranted test-iam` command + - Captures stdout/stderr + - Mock server handles any AWS API calls + +4. **Verification Phase** + - Checks output contains "GrantedAssume" marker + - Verifies credential format + - Validates access key, secret key presence + - Ensures session token is "None" for IAM profiles + +## Environment Variables + +The test uses these environment variables: + +- `GRANTED_E2E_TESTING=true`: **Required** to enable E2E tests +- `GRANTED_BINARY_PATH`: Path to pre-built binary (optional) +- `HOME`: Temp directory for test isolation +- `AWS_CONFIG_FILE`: Points to test AWS config +- `GRANTED_STATE_DIR`: Test granted config directory +- `GRANTED_QUIET=true`: Suppresses info messages +- `FORCE_NO_ALIAS=true`: Skips shell alias setup +- `FORCE_ASSUME_CLI=true`: Forces assume mode + +## Key Components + +### Test AWS Config + +```ini +[profile test-iam] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +region = us-east-1 +``` + +### Expected Output Format + +``` +GrantedAssume AKIAIOSFODNN7EXAMPLE None test-iam us-east-1 ... +``` + +## Extending the Tests + +To add new test scenarios: + +1. Add new profiles to the AWS config +2. Create new test functions following the pattern +3. Use mock server for SSO/OIDC flows +4. Verify expected output format + +## Troubleshooting + +- If build fails: Check Go version and CGO settings +- If assume fails: Check environment variables +- If output unexpected: Enable debug logging by removing `GRANTED_QUIET` +- In CI: Check that binary artifacts are properly downloaded + +## Benefits + +- **Realistic Testing**: Uses actual binary, not unit tests +- **CI/CD Integration**: Runs in main GitHub Actions workflow +- **Build Efficiency**: Reuses pre-built binaries in CI +- **No External Dependencies**: Mock server avoids AWS calls +- **Fast Execution**: No network or auth delays +- **Isolated**: Temp directories prevent conflicts \ No newline at end of file diff --git a/pkg/integration_testing/README.md b/pkg/integration_testing/README.md new file mode 100644 index 00000000..2b27a7ab --- /dev/null +++ b/pkg/integration_testing/README.md @@ -0,0 +1,66 @@ +# Granted Integration Testing + +This directory contains integration tests for the Granted CLI tool, focusing on the `assume` command with mocked AWS APIs. + +## Quick Start + +```bash +# Run E2E tests locally +GRANTED_E2E_TESTING=true go test ./pkg/integration_testing/... + +# Run with pre-built binary +GRANTED_E2E_TESTING=true GRANTED_BINARY_PATH=/path/to/dgranted go test ./pkg/integration_testing/... -run TestAssumeCommandE2E + +# Use the test script +GRANTED_E2E_TESTING=true ./pkg/integration_testing/test_e2e.sh +``` + +## Overview + +The integration test suite validates the core functionality of Granted's `assume` command by: +- Building (or using pre-built) Granted binary +- Creating isolated test environments +- Running the actual CLI command +- Verifying credential output format +- Using mock AWS servers to avoid external dependencies + +## Test Structure + +- **`assume_e2e_test.go`** - End-to-end test for assume command +- **`simple_mock_server.go`** - Lightweight AWS API mock server +- **`simple_sso_test.go`** - Basic SSO workflow tests +- **`sso_test.go`** - SSO profile and token tests +- **`test_e2e.sh`** - Helper script to run E2E tests +- **`E2E_TESTING.md`** - Detailed E2E testing documentation + +## Environment Variables + +- `GRANTED_E2E_TESTING=true` - **Required** to enable E2E tests +- `GRANTED_BINARY_PATH` - Path to pre-built binary (optional, builds if not provided) + +## CI Integration + +Tests run automatically in GitHub Actions when: +1. Code is pushed or PR is created +2. The `integration-test` job downloads pre-built binaries +3. Tests execute with `GRANTED_E2E_TESTING=true` + +## Mock Server + +The test suite includes a mock AWS server that simulates: +- SSO GetRoleCredentials API +- SSO ListAccounts API +- SSO ListAccountRoles API +- OIDC CreateToken API + +This allows testing without real AWS credentials or network access. + +## Extending Tests + +To add new test scenarios: +1. Add profiles to the test AWS config +2. Create test functions following existing patterns +3. Use mock server for SSO/OIDC flows +4. Verify expected credential output format + +For detailed documentation, see [E2E_TESTING.md](E2E_TESTING.md). \ No newline at end of file diff --git a/pkg/integration_testing/assume_e2e_test.go b/pkg/integration_testing/assume_e2e_test.go new file mode 100644 index 00000000..165acb6c --- /dev/null +++ b/pkg/integration_testing/assume_e2e_test.go @@ -0,0 +1,245 @@ +package integration_testing + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAssumeCommandE2E tests the full assume command end-to-end +func TestAssumeCommandE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Only run if explicitly enabled via environment variable + if os.Getenv("GRANTED_E2E_TESTING") != "true" { + t.Skip("Skipping E2E test: set GRANTED_E2E_TESTING=true to enable") + } + + // Check if there's a pre-built binary to use (for CI) + grantedBinary := os.Getenv("GRANTED_BINARY_PATH") + + if grantedBinary == "" { + // Build the granted binary which includes assume functionality + projectRoot := filepath.Join("..", "..", "..") + grantedBinary = filepath.Join(t.TempDir(), "dgranted") + + // Build with the dgranted name to trigger assume CLI + cmd := exec.Command("go", "build", "-o", grantedBinary, "./cmd/granted") + cmd.Dir = projectRoot + cmd.Env = append(os.Environ(), "CGO_ENABLED=1") // Ensure CGO is enabled for keychain + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Failed to build granted binary: %v\nOutput: %s", err, output) + } + + // Make binary executable + err = os.Chmod(grantedBinary, 0755) + require.NoError(t, err) + } + + // Start mock AWS server + mockServer := NewAssumeE2EMockServer() + defer mockServer.Close() + + // Setup test environment + tempDir := t.TempDir() + homeDir := filepath.Join(tempDir, "home") + awsDir := filepath.Join(homeDir, ".aws") + grantedDir := filepath.Join(homeDir, ".granted") + + for _, dir := range []string{awsDir, grantedDir} { + err := os.MkdirAll(dir, 0755) + require.NoError(t, err) + } + + // Create AWS config with a simple IAM profile for testing + awsConfig := `[profile test-iam] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +region = us-east-1 +` + awsConfigPath := filepath.Join(awsDir, "config") + err := os.WriteFile(awsConfigPath, []byte(awsConfig), 0644) + require.NoError(t, err) + + // Create granted config + grantedConfig := `DefaultBrowser = "stdout" +Ordering = "Alphabetical" +` + grantedConfigPath := filepath.Join(grantedDir, "config") + err = os.WriteFile(grantedConfigPath, []byte(grantedConfig), 0644) + require.NoError(t, err) + + t.Run("AssumeProfileWithIAM", func(t *testing.T) { + // Set up environment + env := []string{ + fmt.Sprintf("HOME=%s", homeDir), + fmt.Sprintf("AWS_CONFIG_FILE=%s", awsConfigPath), + fmt.Sprintf("GRANTED_STATE_DIR=%s", grantedDir), + "GRANTED_QUIET=true", // Suppress output messages + "FORCE_NO_ALIAS=true", // Skip alias configuration + "FORCE_ASSUME_CLI=true", // Force assume mode + "PATH=" + os.Getenv("PATH"), // Preserve PATH + } + + // Run assume command with IAM profile + cmd := exec.Command(grantedBinary, "test-iam") + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + t.Fatalf("Assume command failed: %v\nStdout: %s\nStderr: %s", err, stdout.String(), stderr.String()) + } + + // Parse output + output := stdout.String() + t.Logf("Assume output: %s", output) + + // The assume command outputs credentials in a specific format + assert.Contains(t, output, "GrantedAssume") + + // Extract credentials from output + parts := strings.Fields(output) + if len(parts) >= 4 { + accessKey := parts[1] + secretKey := parts[2] + + // For IAM profiles, we expect the actual keys to be output + assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", accessKey) + assert.NotEqual(t, "None", secretKey) + + // Session token should be "None" for IAM profiles + sessionToken := parts[3] + assert.Equal(t, "None", sessionToken) + } else { + t.Errorf("Unexpected output format: %s", output) + } + }) +} + +// AssumeE2EMockServer is a specialized mock server for assume command testing +type AssumeE2EMockServer struct { + *http.Server + URL string + accessToken string + accessCount int +} + +func NewAssumeE2EMockServer() *AssumeE2EMockServer { + server := &AssumeE2EMockServer{ + accessToken: "default-test-token", + } + + mux := http.NewServeMux() + server.Server = &http.Server{Handler: mux} + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + server.accessCount++ + + // Log the request for debugging + fmt.Printf("Mock server received: %s %s %s\n", r.Method, r.URL.Path, r.Header.Get("X-Amz-Target")) + + // Handle SSO operations + target := r.Header.Get("X-Amz-Target") + switch target { + case "AWSSSSOPortalService.GetRoleCredentials": + server.handleGetRoleCredentials(w, r) + case "AWSSSSOPortalService.ListAccounts": + server.handleListAccounts(w, r) + case "SSOOIDCService.CreateToken": + server.handleCreateToken(w, r) + default: + // For unexpected requests, return a generic response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Mock response", + }) + } + }) + + // Start server on a random port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(err) + } + + serverURL := fmt.Sprintf("http://%s", listener.Addr().String()) + server.URL = serverURL + + go server.Server.Serve(listener) + + return server +} + +func (s *AssumeE2EMockServer) Close() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s.Server.Shutdown(ctx) +} + +func (s *AssumeE2EMockServer) SetAccessToken(token string) { + s.accessToken = token +} + +func (s *AssumeE2EMockServer) GetAccessCount() int { + return s.accessCount +} + +func (s *AssumeE2EMockServer) handleGetRoleCredentials(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "roleCredentials": map[string]interface{}{ + "accessKeyId": "ASIAMOCKEXAMPLE", + "secretAccessKey": "mock-secret-key", + "sessionToken": "mock-session-token", + "expiration": time.Now().Add(1 * time.Hour).Unix() * 1000, + }, + } + + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + json.NewEncoder(w).Encode(response) +} + +func (s *AssumeE2EMockServer) handleListAccounts(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "accountList": []map[string]interface{}{ + { + "accountId": "123456789012", + "accountName": "Test Account", + "emailAddress": "test@example.com", + }, + }, + } + + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + json.NewEncoder(w).Encode(response) +} + +func (s *AssumeE2EMockServer) handleCreateToken(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "accessToken": s.accessToken, + "tokenType": "Bearer", + "expiresIn": 3600, + "refreshToken": "mock-refresh-token", + } + + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + json.NewEncoder(w).Encode(response) +} \ No newline at end of file diff --git a/pkg/integration_testing/test_e2e.sh b/pkg/integration_testing/test_e2e.sh new file mode 100755 index 00000000..0998f59f --- /dev/null +++ b/pkg/integration_testing/test_e2e.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Test script to run the end-to-end assume command test + +set -e + +echo "Running Assume Command E2E Integration Test" +echo "===========================================" + +# Check if E2E testing is enabled +if [ "$GRANTED_E2E_TESTING" != "true" ]; then + echo "E2E testing is not enabled" + echo "Set GRANTED_E2E_TESTING=true to run these tests" + exit 0 +fi + +# Check if binary path is provided +if [ ! -z "$GRANTED_BINARY_PATH" ]; then + echo "Using pre-built binary: $GRANTED_BINARY_PATH" +else + echo "No binary path provided, test will build its own" +fi + +# Run the specific E2E test +echo "Testing assume command with mock server..." +GRANTED_E2E_TESTING=true go test -v -run TestAssumeCommandE2E ./pkg/integration_testing/... + +echo "" +echo "Test completed successfully!" +echo "" +echo "This test:" +echo "1. Uses pre-built binary (if GRANTED_BINARY_PATH is set) or builds one" +echo "2. Sets up a mock AWS environment" +echo "3. Runs the assume command" +echo "4. Verifies credentials are output correctly" +echo "" +echo "To run with pre-built binary:" +echo " GRANTED_E2E_TESTING=true GRANTED_BINARY_PATH=/path/to/dgranted ./test_e2e.sh" +echo "" +echo "In CI, this runs as part of the main test workflow in .github/workflows/test.yml" \ No newline at end of file From 47847c4d6b4bf9e69534460bf678a8d4a07098f1 Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 16:51:52 +0100 Subject: [PATCH 2/8] rename test workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f0d7cda..b52344df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: [push] jobs: test: - name: Unit Tests + name: Test runs-on: ubuntu-latest steps: From 9accbfe9a4e890f09e178dc631aae8c011f44128 Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 16:52:37 +0100 Subject: [PATCH 3/8] remove testing shell script --- pkg/integration_testing/test_e2e.sh | 39 ----------------------------- 1 file changed, 39 deletions(-) delete mode 100755 pkg/integration_testing/test_e2e.sh diff --git a/pkg/integration_testing/test_e2e.sh b/pkg/integration_testing/test_e2e.sh deleted file mode 100755 index 0998f59f..00000000 --- a/pkg/integration_testing/test_e2e.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -# Test script to run the end-to-end assume command test - -set -e - -echo "Running Assume Command E2E Integration Test" -echo "===========================================" - -# Check if E2E testing is enabled -if [ "$GRANTED_E2E_TESTING" != "true" ]; then - echo "E2E testing is not enabled" - echo "Set GRANTED_E2E_TESTING=true to run these tests" - exit 0 -fi - -# Check if binary path is provided -if [ ! -z "$GRANTED_BINARY_PATH" ]; then - echo "Using pre-built binary: $GRANTED_BINARY_PATH" -else - echo "No binary path provided, test will build its own" -fi - -# Run the specific E2E test -echo "Testing assume command with mock server..." -GRANTED_E2E_TESTING=true go test -v -run TestAssumeCommandE2E ./pkg/integration_testing/... - -echo "" -echo "Test completed successfully!" -echo "" -echo "This test:" -echo "1. Uses pre-built binary (if GRANTED_BINARY_PATH is set) or builds one" -echo "2. Sets up a mock AWS environment" -echo "3. Runs the assume command" -echo "4. Verifies credentials are output correctly" -echo "" -echo "To run with pre-built binary:" -echo " GRANTED_E2E_TESTING=true GRANTED_BINARY_PATH=/path/to/dgranted ./test_e2e.sh" -echo "" -echo "In CI, this runs as part of the main test workflow in .github/workflows/test.yml" \ No newline at end of file From 10d242185413deac2d5f512a073650ead704514b Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 16:58:21 +0100 Subject: [PATCH 4/8] fix test --- pkg/integration_testing/assume_e2e_test.go | 47 ++++++++++++---------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/pkg/integration_testing/assume_e2e_test.go b/pkg/integration_testing/assume_e2e_test.go index 165acb6c..a9c9a7e2 100644 --- a/pkg/integration_testing/assume_e2e_test.go +++ b/pkg/integration_testing/assume_e2e_test.go @@ -31,12 +31,12 @@ func TestAssumeCommandE2E(t *testing.T) { // Check if there's a pre-built binary to use (for CI) grantedBinary := os.Getenv("GRANTED_BINARY_PATH") - + if grantedBinary == "" { // Build the granted binary which includes assume functionality projectRoot := filepath.Join("..", "..", "..") grantedBinary = filepath.Join(t.TempDir(), "dgranted") - + // Build with the dgranted name to trigger assume CLI cmd := exec.Command("go", "build", "-o", grantedBinary, "./cmd/granted") cmd.Dir = projectRoot @@ -76,9 +76,14 @@ region = us-east-1 err := os.WriteFile(awsConfigPath, []byte(awsConfig), 0644) require.NoError(t, err) - // Create granted config - grantedConfig := `DefaultBrowser = "stdout" + // Create granted config with all necessary fields to avoid interactive prompts + // Set CustomBrowserPath to "stdout" to satisfy the UserHasDefaultBrowser check + grantedConfig := `DefaultBrowser = "STDOUT" +CustomBrowserPath = "stdout" Ordering = "Alphabetical" +[Keyring] +Backend = "file" +FileBackend = "" ` grantedConfigPath := filepath.Join(grantedDir, "config") err = os.WriteFile(grantedConfigPath, []byte(grantedConfig), 0644) @@ -90,16 +95,16 @@ Ordering = "Alphabetical" fmt.Sprintf("HOME=%s", homeDir), fmt.Sprintf("AWS_CONFIG_FILE=%s", awsConfigPath), fmt.Sprintf("GRANTED_STATE_DIR=%s", grantedDir), - "GRANTED_QUIET=true", // Suppress output messages - "FORCE_NO_ALIAS=true", // Skip alias configuration - "FORCE_ASSUME_CLI=true", // Force assume mode + "GRANTED_QUIET=true", // Suppress output messages + "FORCE_NO_ALIAS=true", // Skip alias configuration + "FORCE_ASSUME_CLI=true", // Force assume mode "PATH=" + os.Getenv("PATH"), // Preserve PATH } // Run assume command with IAM profile cmd := exec.Command(grantedBinary, "test-iam") cmd.Env = env - + var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -115,17 +120,17 @@ Ordering = "Alphabetical" // The assume command outputs credentials in a specific format assert.Contains(t, output, "GrantedAssume") - + // Extract credentials from output parts := strings.Fields(output) if len(parts) >= 4 { accessKey := parts[1] secretKey := parts[2] - + // For IAM profiles, we expect the actual keys to be output assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", accessKey) assert.NotEqual(t, "None", secretKey) - + // Session token should be "None" for IAM profiles sessionToken := parts[3] assert.Equal(t, "None", sessionToken) @@ -147,16 +152,16 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { server := &AssumeE2EMockServer{ accessToken: "default-test-token", } - + mux := http.NewServeMux() server.Server = &http.Server{Handler: mux} - + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { server.accessCount++ - + // Log the request for debugging fmt.Printf("Mock server received: %s %s %s\n", r.Method, r.URL.Path, r.Header.Get("X-Amz-Target")) - + // Handle SSO operations target := r.Header.Get("X-Amz-Target") switch target { @@ -183,7 +188,7 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { serverURL := fmt.Sprintf("http://%s", listener.Addr().String()) server.URL = serverURL - + go server.Server.Serve(listener) return server @@ -209,10 +214,10 @@ func (s *AssumeE2EMockServer) handleGetRoleCredentials(w http.ResponseWriter, r "accessKeyId": "ASIAMOCKEXAMPLE", "secretAccessKey": "mock-secret-key", "sessionToken": "mock-session-token", - "expiration": time.Now().Add(1 * time.Hour).Unix() * 1000, + "expiration": time.Now().Add(1*time.Hour).Unix() * 1000, }, } - + w.Header().Set("Content-Type", "application/x-amz-json-1.1") json.NewEncoder(w).Encode(response) } @@ -227,7 +232,7 @@ func (s *AssumeE2EMockServer) handleListAccounts(w http.ResponseWriter, r *http. }, }, } - + w.Header().Set("Content-Type", "application/x-amz-json-1.1") json.NewEncoder(w).Encode(response) } @@ -239,7 +244,7 @@ func (s *AssumeE2EMockServer) handleCreateToken(w http.ResponseWriter, r *http.R "expiresIn": 3600, "refreshToken": "mock-refresh-token", } - + w.Header().Set("Content-Type", "application/x-amz-json-1.1") json.NewEncoder(w).Encode(response) -} \ No newline at end of file +} From e50c2e03ceb5a9b720016861c0b3302a54f8cd88 Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 17:02:48 +0100 Subject: [PATCH 5/8] fix test config dir --- pkg/integration_testing/assume_e2e_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/integration_testing/assume_e2e_test.go b/pkg/integration_testing/assume_e2e_test.go index a9c9a7e2..2f9d2364 100644 --- a/pkg/integration_testing/assume_e2e_test.go +++ b/pkg/integration_testing/assume_e2e_test.go @@ -59,7 +59,9 @@ func TestAssumeCommandE2E(t *testing.T) { tempDir := t.TempDir() homeDir := filepath.Join(tempDir, "home") awsDir := filepath.Join(homeDir, ".aws") - grantedDir := filepath.Join(homeDir, ".granted") + // Use XDG_CONFIG_HOME to set custom config directory + xdgConfigHome := filepath.Join(tempDir, "config") + grantedDir := filepath.Join(xdgConfigHome, "granted") for _, dir := range []string{awsDir, grantedDir} { err := os.MkdirAll(dir, 0755) @@ -94,7 +96,7 @@ FileBackend = "" env := []string{ fmt.Sprintf("HOME=%s", homeDir), fmt.Sprintf("AWS_CONFIG_FILE=%s", awsConfigPath), - fmt.Sprintf("GRANTED_STATE_DIR=%s", grantedDir), + fmt.Sprintf("XDG_CONFIG_HOME=%s", xdgConfigHome), "GRANTED_QUIET=true", // Suppress output messages "FORCE_NO_ALIAS=true", // Skip alias configuration "FORCE_ASSUME_CLI=true", // Force assume mode From 78cb6c5911e797f9cd41788753971530670911cb Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 17:15:58 +0100 Subject: [PATCH 6/8] add tests using IAM Identity Center profiles --- pkg/integration_testing/assume_e2e_test.go | 185 +++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/pkg/integration_testing/assume_e2e_test.go b/pkg/integration_testing/assume_e2e_test.go index 2f9d2364..4e65dde3 100644 --- a/pkg/integration_testing/assume_e2e_test.go +++ b/pkg/integration_testing/assume_e2e_test.go @@ -3,6 +3,7 @@ package integration_testing import ( "bytes" "context" + "crypto/sha1" "encoding/json" "fmt" "net" @@ -140,6 +141,174 @@ FileBackend = "" t.Errorf("Unexpected output format: %s", output) } }) + + t.Run("AssumeProfileWithSSO", func(t *testing.T) { + // Create AWS config with SSO profile + ssoConfig := fmt.Sprintf(`[profile test-sso] +sso_account_id = 123456789012 +sso_role_name = TestRole +sso_region = us-east-1 +sso_start_url = %s +region = us-east-1 +`, mockServer.URL) + + // Update AWS config file with SSO profile + err := os.WriteFile(awsConfigPath, []byte(awsConfig+"\n"+ssoConfig), 0644) + require.NoError(t, err) + + // Create SSO cache directory and token + ssoCacheDir := filepath.Join(awsDir, "sso", "cache") + err = os.MkdirAll(ssoCacheDir, 0755) + require.NoError(t, err) + + // Create a cached SSO token + tokenData := map[string]interface{}{ + "accessToken": "cached-test-token", + "expiresAt": time.Now().Add(1 * time.Hour).Format(time.RFC3339), + "region": "us-east-1", + "startUrl": mockServer.URL, + } + tokenBytes, err := json.Marshal(tokenData) + require.NoError(t, err) + + // The cache filename is a SHA1 hash of the session name + // For AWS SSO, the session name is derived from the start URL + h := sha1.New() + h.Write([]byte(mockServer.URL)) + cacheFile := filepath.Join(ssoCacheDir, fmt.Sprintf("%x.json", h.Sum(nil))) + err = os.WriteFile(cacheFile, tokenBytes, 0600) + require.NoError(t, err) + + // Set up environment + env := []string{ + fmt.Sprintf("HOME=%s", homeDir), + fmt.Sprintf("AWS_CONFIG_FILE=%s", awsConfigPath), + fmt.Sprintf("XDG_CONFIG_HOME=%s", xdgConfigHome), + "GRANTED_QUIET=true", // Suppress output messages + "FORCE_NO_ALIAS=true", // Skip alias configuration + "FORCE_ASSUME_CLI=true", // Force assume mode + "PATH=" + os.Getenv("PATH"), // Preserve PATH + } + + // Run assume command with SSO profile + cmd := exec.Command(grantedBinary, "test-sso") + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + t.Fatalf("Assume command failed: %v\nStdout: %s\nStderr: %s", err, stdout.String(), stderr.String()) + } + + // Parse output + output := stdout.String() + t.Logf("Assume output: %s", output) + + // The assume command outputs credentials in a specific format + assert.Contains(t, output, "GrantedAssume") + + // Extract credentials from output + parts := strings.Fields(output) + if len(parts) >= 4 { + accessKey := parts[1] + secretKey := parts[2] + sessionToken := parts[3] + + // For SSO profiles, we expect temporary credentials from the mock server + assert.Equal(t, "ASIAMOCKEXAMPLE", accessKey) + assert.Equal(t, "mock-secret-key", secretKey) + assert.Equal(t, "mock-session-token", sessionToken) + } else { + t.Errorf("Unexpected output format: %s", output) + } + }) + + t.Run("AssumeProfileWithGrantedSSO", func(t *testing.T) { + // Create AWS config with granted_sso_ profile configuration + grantedSSOConfig := fmt.Sprintf(`[profile test-granted-sso] +granted_sso_account_id = 123456789012 +granted_sso_role_name = TestRole +granted_sso_region = us-east-1 +granted_sso_start_url = %s +credential_process = %s credential-process --profile test-granted-sso +region = us-east-1 +`, mockServer.URL, grantedBinary) + + // Update AWS config file with granted SSO profile + err := os.WriteFile(awsConfigPath, []byte(awsConfig+"\n"+grantedSSOConfig), 0644) + require.NoError(t, err) + + // Create SSO cache directory and token for the granted credential process + ssoCacheDir := filepath.Join(awsDir, "sso", "cache") + err = os.MkdirAll(ssoCacheDir, 0755) + require.NoError(t, err) + + // Create a cached SSO token + tokenData := map[string]interface{}{ + "accessToken": "cached-test-token", + "expiresAt": time.Now().Add(1 * time.Hour).Format(time.RFC3339), + "region": "us-east-1", + "startUrl": mockServer.URL, + } + tokenBytes, err := json.Marshal(tokenData) + require.NoError(t, err) + + // The cache filename is a SHA1 hash of the start URL + h := sha1.New() + h.Write([]byte(mockServer.URL)) + cacheFile := filepath.Join(ssoCacheDir, fmt.Sprintf("%x.json", h.Sum(nil))) + err = os.WriteFile(cacheFile, tokenBytes, 0600) + require.NoError(t, err) + + // Set up environment + env := []string{ + fmt.Sprintf("HOME=%s", homeDir), + fmt.Sprintf("AWS_CONFIG_FILE=%s", awsConfigPath), + fmt.Sprintf("XDG_CONFIG_HOME=%s", xdgConfigHome), + "GRANTED_QUIET=true", // Suppress output messages + "FORCE_NO_ALIAS=true", // Skip alias configuration + "FORCE_ASSUME_CLI=true", // Force assume mode + "PATH=" + os.Getenv("PATH"), // Preserve PATH + } + + // Run assume command with granted SSO profile + cmd := exec.Command(grantedBinary, "test-granted-sso") + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + t.Fatalf("Assume command failed: %v\nStdout: %s\nStderr: %s", err, stdout.String(), stderr.String()) + } + + // Parse output + output := stdout.String() + t.Logf("Assume output: %s", output) + + // The assume command outputs credentials in a specific format + assert.Contains(t, output, "GrantedAssume") + + // Extract credentials from output + parts := strings.Fields(output) + if len(parts) >= 4 { + accessKey := parts[1] + secretKey := parts[2] + sessionToken := parts[3] + + // For granted SSO profiles with credential process, we expect temporary credentials + assert.Equal(t, "ASIAMOCKEXAMPLE", accessKey) + assert.Equal(t, "mock-secret-key", secretKey) + assert.Equal(t, "mock-session-token", sessionToken) + } else { + t.Errorf("Unexpected output format: %s", output) + } + }) } // AssumeE2EMockServer is a specialized mock server for assume command testing @@ -171,6 +340,8 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { server.handleGetRoleCredentials(w, r) case "AWSSSSOPortalService.ListAccounts": server.handleListAccounts(w, r) + case "AWSSSSOPortalService.ListAccountRoles": + server.handleListAccountRoles(w, r) case "SSOOIDCService.CreateToken": server.handleCreateToken(w, r) default: @@ -250,3 +421,17 @@ func (s *AssumeE2EMockServer) handleCreateToken(w http.ResponseWriter, r *http.R w.Header().Set("Content-Type", "application/x-amz-json-1.1") json.NewEncoder(w).Encode(response) } + +func (s *AssumeE2EMockServer) handleListAccountRoles(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "roleList": []map[string]interface{}{ + { + "roleName": "TestRole", + "accountId": "123456789012", + }, + }, + } + + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + json.NewEncoder(w).Encode(response) +} From c7fc159b0ad5ca496ccfd11ffac9f068cf7dff31 Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 17:22:11 +0100 Subject: [PATCH 7/8] fix tests --- pkg/integration_testing/assume_e2e_test.go | 35 ++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/pkg/integration_testing/assume_e2e_test.go b/pkg/integration_testing/assume_e2e_test.go index 4e65dde3..b6498d83 100644 --- a/pkg/integration_testing/assume_e2e_test.go +++ b/pkg/integration_testing/assume_e2e_test.go @@ -80,9 +80,10 @@ region = us-east-1 require.NoError(t, err) // Create granted config with all necessary fields to avoid interactive prompts - // Set CustomBrowserPath to "stdout" to satisfy the UserHasDefaultBrowser check + // Set both DefaultBrowser and CustomSSOBrowserPath to avoid all interactive prompts grantedConfig := `DefaultBrowser = "STDOUT" CustomBrowserPath = "stdout" +CustomSSOBrowserPath = "stdout" Ordering = "Alphabetical" [Keyring] Backend = "file" @@ -347,9 +348,11 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { default: // For unexpected requests, return a generic response w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ + if err := json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Mock response", - }) + }); err != nil { + fmt.Printf("Error encoding response: %v\n", err) + } } }) @@ -362,7 +365,11 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { serverURL := fmt.Sprintf("http://%s", listener.Addr().String()) server.URL = serverURL - go server.Server.Serve(listener) + go func() { + if err := server.Server.Serve(listener); err != nil && err != http.ErrServerClosed { + fmt.Printf("Server error: %v\n", err) + } + }() return server } @@ -370,7 +377,9 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { func (s *AssumeE2EMockServer) Close() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - s.Server.Shutdown(ctx) + if err := s.Server.Shutdown(ctx); err != nil { + fmt.Printf("Error shutting down server: %v\n", err) + } } func (s *AssumeE2EMockServer) SetAccessToken(token string) { @@ -392,7 +401,9 @@ func (s *AssumeE2EMockServer) handleGetRoleCredentials(w http.ResponseWriter, r } w.Header().Set("Content-Type", "application/x-amz-json-1.1") - json.NewEncoder(w).Encode(response) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Error encoding response: %v\n", err) + } } func (s *AssumeE2EMockServer) handleListAccounts(w http.ResponseWriter, r *http.Request) { @@ -407,7 +418,9 @@ func (s *AssumeE2EMockServer) handleListAccounts(w http.ResponseWriter, r *http. } w.Header().Set("Content-Type", "application/x-amz-json-1.1") - json.NewEncoder(w).Encode(response) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Error encoding response: %v\n", err) + } } func (s *AssumeE2EMockServer) handleCreateToken(w http.ResponseWriter, r *http.Request) { @@ -419,7 +432,9 @@ func (s *AssumeE2EMockServer) handleCreateToken(w http.ResponseWriter, r *http.R } w.Header().Set("Content-Type", "application/x-amz-json-1.1") - json.NewEncoder(w).Encode(response) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Error encoding response: %v\n", err) + } } func (s *AssumeE2EMockServer) handleListAccountRoles(w http.ResponseWriter, r *http.Request) { @@ -433,5 +448,7 @@ func (s *AssumeE2EMockServer) handleListAccountRoles(w http.ResponseWriter, r *h } w.Header().Set("Content-Type", "application/x-amz-json-1.1") - json.NewEncoder(w).Encode(response) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Error encoding response: %v\n", err) + } } From 4d6af58c6558d797c3e9f457bea4de43874c8d08 Mon Sep 17 00:00:00 2001 From: Chris Norman Date: Sun, 11 May 2025 17:28:31 +0100 Subject: [PATCH 8/8] fix tests --- pkg/integration_testing/assume_e2e_test.go | 35 ++++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/pkg/integration_testing/assume_e2e_test.go b/pkg/integration_testing/assume_e2e_test.go index b6498d83..7e2764e6 100644 --- a/pkg/integration_testing/assume_e2e_test.go +++ b/pkg/integration_testing/assume_e2e_test.go @@ -64,11 +64,16 @@ func TestAssumeCommandE2E(t *testing.T) { xdgConfigHome := filepath.Join(tempDir, "config") grantedDir := filepath.Join(xdgConfigHome, "granted") - for _, dir := range []string{awsDir, grantedDir} { + // Create all necessary directories with proper permissions + for _, dir := range []string{awsDir, grantedDir, xdgConfigHome} { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } + // Ensure the granted directory is writable for config saves + err := os.Chmod(grantedDir, 0755) + require.NoError(t, err) + // Create AWS config with a simple IAM profile for testing awsConfig := `[profile test-iam] aws_access_key_id = AKIAIOSFODNN7EXAMPLE @@ -144,6 +149,30 @@ FileBackend = "" }) t.Run("AssumeProfileWithSSO", func(t *testing.T) { + // Debug: Check if granted config exists and is readable + configContent, err := os.ReadFile(grantedConfigPath) + if err != nil { + t.Logf("Error reading granted config: %v", err) + } else { + t.Logf("Granted config content:\n%s", string(configContent)) + } + + // Debug environment variables + t.Logf("HOME: %s", homeDir) + t.Logf("XDG_CONFIG_HOME: %s", xdgConfigHome) + t.Logf("Config path: %s", grantedConfigPath) + + // List contents of granted directory + files, err := os.ReadDir(grantedDir) + if err != nil { + t.Logf("Error reading granted dir: %v", err) + } else { + t.Logf("Granted dir contents:") + for _, f := range files { + t.Logf(" %s", f.Name()) + } + } + // Create AWS config with SSO profile ssoConfig := fmt.Sprintf(`[profile test-sso] sso_account_id = 123456789012 @@ -366,7 +395,7 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { server.URL = serverURL go func() { - if err := server.Server.Serve(listener); err != nil && err != http.ErrServerClosed { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { fmt.Printf("Server error: %v\n", err) } }() @@ -377,7 +406,7 @@ func NewAssumeE2EMockServer() *AssumeE2EMockServer { func (s *AssumeE2EMockServer) Close() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := s.Server.Shutdown(ctx); err != nil { + if err := s.Shutdown(ctx); err != nil { fmt.Printf("Error shutting down server: %v\n", err) } }