diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 201ec86a..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: @@ -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..7e2764e6 --- /dev/null +++ b/pkg/integration_testing/assume_e2e_test.go @@ -0,0 +1,483 @@ +package integration_testing + +import ( + "bytes" + "context" + "crypto/sha1" + "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") + // Use XDG_CONFIG_HOME to set custom config directory + xdgConfigHome := filepath.Join(tempDir, "config") + grantedDir := filepath.Join(xdgConfigHome, "granted") + + // 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 +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 with all necessary fields to avoid interactive prompts + // Set both DefaultBrowser and CustomSSOBrowserPath to avoid all interactive prompts + grantedConfig := `DefaultBrowser = "STDOUT" +CustomBrowserPath = "stdout" +CustomSSOBrowserPath = "stdout" +Ordering = "Alphabetical" +[Keyring] +Backend = "file" +FileBackend = "" +` + 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("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 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) + } + }) + + 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 +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 +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 "AWSSSSOPortalService.ListAccountRoles": + server.handleListAccountRoles(w, r) + case "SSOOIDCService.CreateToken": + server.handleCreateToken(w, r) + default: + // For unexpected requests, return a generic response + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Mock response", + }); err != nil { + fmt.Printf("Error encoding response: %v\n", err) + } + } + }) + + // 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 func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + fmt.Printf("Server error: %v\n", err) + } + }() + + return server +} + +func (s *AssumeE2EMockServer) Close() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + fmt.Printf("Error shutting down server: %v\n", err) + } +} + +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") + 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) { + 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") + 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) { + 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") + 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) { + response := map[string]interface{}{ + "roleList": []map[string]interface{}{ + { + "roleName": "TestRole", + "accountId": "123456789012", + }, + }, + } + + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Error encoding response: %v\n", err) + } +}