From 86df32b61cb07ce55360cb9889e8d26655bcf54f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 7 May 2026 14:44:33 -0400 Subject: [PATCH] feat(payments): add AgentCore Payments module Add comprehensive payment processing support for AI agents via the x402 protocol. Includes PaymentManager (high-level orchestration), PaymentClient (control plane operations), Strands plugin integration with automatic 402 interception, and multi-blockchain support (Ethereum, Solana). Key components: - PaymentManager: instrument/session lifecycle, process_payment, generate_payment_header - PaymentClient: manager/connector CRUD with rollback-safe orchestration - AgentCorePaymentsPlugin: Strands framework integration with before/after tool hooks - Payment handlers: protocol-agnostic extraction for HTTP, MCP, and generic tool responses - Bearer token auth support for CUSTOM_JWT authorization flows - Multi-vendor support: CoinbaseCDP and StripePrivy credential providers --- .gitignore | 1 + pyproject.toml | 4 +- .../integrations/strands/session_manager.py | 6 + src/bedrock_agentcore/payments/README.md | 731 ++++ src/bedrock_agentcore/payments/__init__.py | 39 + src/bedrock_agentcore/payments/client.py | 1215 ++++++ src/bedrock_agentcore/payments/constants.py | 72 + .../payments/integrations/__init__.py | 5 + .../payments/integrations/config.py | 108 + .../payments/integrations/handlers.py | 638 ++++ .../payments/integrations/strands/README.md | 682 ++++ .../payments/integrations/strands/__init__.py | 6 + .../payments/integrations/strands/plugin.py | 753 ++++ .../payments/integrations/strands/tools.py | 95 + src/bedrock_agentcore/payments/manager.py | 1465 ++++++++ src/bedrock_agentcore/services/identity.py | 116 + tests/bedrock_agentcore/payments/__init__.py | 1 + .../payments/integrations/__init__.py | 1 + .../payments/integrations/strands/__init__.py | 1 + .../integrations/strands/test_config.py | 587 +++ .../integrations/strands/test_plugin.py | 1767 +++++++++ .../integrations/strands/test_tools.py | 1683 +++++++++ .../payments/integrations/test_handlers.py | 1073 ++++++ .../bedrock_agentcore/payments/test_client.py | 1547 ++++++++ .../payments/test_deprecated_naming.py | 59 + .../payments/test_payment_manager.py | 3320 +++++++++++++++++ .../services/test_identity.py | 431 ++- tests_integ/payments/README.md | 254 ++ tests_integ/payments/__init__.py | 1 + tests_integ/payments/integrations/__init__.py | 1 + .../strands/test_payment_tools_integration.py | 474 +++ .../strands/test_plugin_integration.py | 712 ++++ tests_integ/payments/test_payment_client.py | 540 +++ tests_integ/payments/test_payment_manager.py | 1171 ++++++ uv.lock | 23 +- 35 files changed, 19568 insertions(+), 14 deletions(-) create mode 100644 src/bedrock_agentcore/payments/README.md create mode 100644 src/bedrock_agentcore/payments/__init__.py create mode 100644 src/bedrock_agentcore/payments/client.py create mode 100644 src/bedrock_agentcore/payments/constants.py create mode 100644 src/bedrock_agentcore/payments/integrations/__init__.py create mode 100644 src/bedrock_agentcore/payments/integrations/config.py create mode 100644 src/bedrock_agentcore/payments/integrations/handlers.py create mode 100644 src/bedrock_agentcore/payments/integrations/strands/README.md create mode 100644 src/bedrock_agentcore/payments/integrations/strands/__init__.py create mode 100644 src/bedrock_agentcore/payments/integrations/strands/plugin.py create mode 100644 src/bedrock_agentcore/payments/integrations/strands/tools.py create mode 100644 src/bedrock_agentcore/payments/manager.py create mode 100644 tests/bedrock_agentcore/payments/__init__.py create mode 100644 tests/bedrock_agentcore/payments/integrations/__init__.py create mode 100644 tests/bedrock_agentcore/payments/integrations/strands/__init__.py create mode 100644 tests/bedrock_agentcore/payments/integrations/strands/test_config.py create mode 100644 tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py create mode 100644 tests/bedrock_agentcore/payments/integrations/strands/test_tools.py create mode 100644 tests/bedrock_agentcore/payments/integrations/test_handlers.py create mode 100644 tests/bedrock_agentcore/payments/test_client.py create mode 100644 tests/bedrock_agentcore/payments/test_deprecated_naming.py create mode 100644 tests/bedrock_agentcore/payments/test_payment_manager.py create mode 100644 tests_integ/payments/README.md create mode 100644 tests_integ/payments/__init__.py create mode 100644 tests_integ/payments/integrations/__init__.py create mode 100644 tests_integ/payments/integrations/strands/test_payment_tools_integration.py create mode 100644 tests_integ/payments/integrations/strands/test_plugin_integration.py create mode 100644 tests_integ/payments/test_payment_client.py create mode 100644 tests_integ/payments/test_payment_manager.py diff --git a/.gitignore b/.gitignore index b28ca305..01fe8e22 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,4 @@ local_settings.py .dockerignore Dockerfile CLAUDE.md +.omc/ diff --git a/pyproject.toml b/pyproject.toml index 1d478da3..24d3bf66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,7 +152,7 @@ dev = [ "ruff>=0.12.0", "websockets>=14.1", "wheel>=0.45.1", - "strands-agents>=1.18.0", + "strands-agents>=1.20.0", "strands-agents-evals>=0.1.0", "a2a-sdk[http-server]>=0.3", "ag-ui-protocol>=0.1.10", @@ -162,7 +162,7 @@ dev = [ a2a = ["a2a-sdk[http-server]>=0.3"] ag-ui = ["ag-ui-protocol>=0.1.10"] strands-agents = [ - "strands-agents>=1.1.0" + "strands-agents>=1.20.0" ] strands-agents-evals = [ "strands-agents-evals>=0.1.0" diff --git a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py index 622fa736..4a2689b5 100644 --- a/src/bedrock_agentcore/memory/integrations/strands/session_manager.py +++ b/src/bedrock_agentcore/memory/integrations/strands/session_manager.py @@ -158,6 +158,12 @@ def __init__( # Cache for agent created_at timestamps to avoid fetching on every update self._agent_created_at_cache: dict[str, datetime] = {} + # Track last synced internal state for each agent (required by parent RepositorySessionManager) + self._last_synced_internal_state: dict[str, Any] = {} + + # Track if this is a new session (required by parent RepositorySessionManager) + self._is_new_session: bool = True + # Interval-based flushing support self._flush_timer: Optional[threading.Timer] = None self._timer_lock = threading.Lock() diff --git a/src/bedrock_agentcore/payments/README.md b/src/bedrock_agentcore/payments/README.md new file mode 100644 index 00000000..d659e3d3 --- /dev/null +++ b/src/bedrock_agentcore/payments/README.md @@ -0,0 +1,731 @@ +# Bedrock AgentCore payments SDK + +High-level Python SDK for AWS Bedrock AgentCore payments service with support for payment instrument management, +session-based payment limits, and x402 payment processing for AI agent microtransactions. + +## Overview + +The Bedrock AgentCore Payments SDK enables AI agents to process microtransaction payments to access paid APIs, +MCP servers, and premium content. The SDK supports the [x402 Payment Required](https://www.x402.org/) protocol, +allowing agents to automatically handle HTTP 402 responses and complete cryptocurrency transactions on behalf of users. + +### Architecture + +The payments system operates on a hierarchical structure: + +``` +PaymentClient (Control Plane) + └── Payment Manager + └── Payment Connector ──▶ Payment Credential Provider (vendor credentials) + └── Payment Instrument (user's wallet) + └── Payment Session (time-bounded spending context) +``` + +- **Payment Credential Provider** — stores vendor credentials (e.g., Coinbase CDP API keys, Stripe Privy credentials) securely +- **Payment Manager** — top-level resource that owns connectors and orchestrates payment operations +- **Payment Connector** — links a payment manager to a credential provider for a specific payment vendor +- **Payment Instrument** — a user's registered payment method (e.g., embedded crypto wallet) created through a connector +- **Payment Session** — a time-bounded spending context with configurable limits + +### Core Components + +| Component | Layer | Purpose | +|-----------|-------|---------| +| `PaymentClient` | Control plane | Create and manage payment infrastructure (managers, connectors, credential providers) | +| `PaymentManager` | Data plane | Payment operations (instruments, sessions, payment processing, header generation) | +| `AgentCorePaymentsPlugin` | Framework integration | Strands Agents plugin for automatic x402 payment handling ([see Strands README](integrations/strands/README.md)) | + +## Installation + +```bash +pip install bedrock-agentcore +``` + +For Strands Agents integration: + +```bash +pip install 'bedrock-agentcore[strands-agents]' +``` + +Or to develop locally: + +```bash +git clone https://github.com/aws/bedrock-agentcore-sdk-python.git +cd bedrock-agentcore-sdk-python +uv sync +source .venv/bin/activate +``` + +## Prerequisites + +AgentCore Payments connects to external payment providers for wallet operations. You must obtain +credentials from at least one supported provider before creating a Payment Connector. + +**Supported providers:** +- **Coinbase CDP** — API key ID, API key secret, and wallet secret +- **Stripe Privy** — App ID, app secret, and optional authorization key + +## Authentication + +The SDK supports two authentication modes: + +### AWS IAM (Default) + +Uses standard AWS credentials via any of: +- AWS CLI credentials (`aws configure`) +- Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) +- IAM roles (EC2 instance roles, ECS task roles, Lambda execution roles) + +### Custom JWT (Bearer Token) + +For OAuth/CUSTOM_JWT authentication, provide a bearer token or token provider: + +```python +from bedrock_agentcore.payments import PaymentManager + +# Static bearer token (for testing) +manager = PaymentManager( + payment_manager_arn="arn:...", + bearer_token="your-jwt-token", +) + +# Dynamic token provider (recommended for production) +manager = PaymentManager( + payment_manager_arn="arn:...", + token_provider=lambda: get_fresh_token(), +) +``` + +> **Note:** `bearer_token` and `token_provider` are mutually exclusive. When using bearer token auth, +> the service derives `userId` from the JWT `sub` claim, so `user_id` is optional on data plane calls. + +### Region Resolution Order + +1. `region_name` parameter passed to `PaymentManager` or `PaymentClient` +2. Region from `boto3_session` if provided +3. `AWS_REGION` environment variable +4. `boto3.Session().region_name` (checks `AWS_DEFAULT_REGION` and AWS config) +5. Default fallback: `us-west-2` + +## Quick Start + +```python +import os +from bedrock_agentcore.payments import PaymentManager + +manager = PaymentManager( + payment_manager_arn=os.environ["PAYMENT_MANAGER_ARN"], + region_name="us-east-1", +) + +# Create a payment instrument (embedded crypto wallet) +instrument = manager.create_payment_instrument( + payment_connector_id=os.environ["PAYMENT_CONNECTOR_ID"], + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={ + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "linkedAccounts": [ + {"email": {"emailAddress": "user@example.com"}} + ], + } + }, + user_id="user-123", +) + +# Create a payment session with spending limits +session = manager.create_payment_session( + expiry_time_in_minutes=60, + user_id="user-123", + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, +) + +# Check instrument balance +balance = manager.get_payment_instrument_balance( + payment_connector_id=os.environ["PAYMENT_CONNECTOR_ID"], + payment_instrument_id=instrument["paymentInstrumentId"], + chain="BASE_SEPOLIA", + token="USDC", + user_id="user-123", +) +print(f"Balance: {balance}") +``` + +## Usage + +### Creating Payment Manager and Connector + +> **Note:** Payment resource creation is typically done once, separately from your agent application. +> In production, create these resources through the AWS Console or a separate setup script, then use +> the `paymentManagerArn` and `paymentConnectorId` in your agent application. + +```python +import os +from bedrock_agentcore.payments.client import PaymentClient + +payment_client = PaymentClient(region_name="us-east-1") + +response = payment_client.create_payment_manager_with_connector( + payment_manager_name="AgentCorePaymentManager", + payment_manager_description="Payment Manager for Agent Core", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config={ + "name": "agent-core-connector", + "description": "Payment Connector for Agent Core", + "payment_credential_provider_config": { + "name": "agent-core-provider", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": os.environ["COINBASE_API_KEY_ID"], + "api_key_secret": os.environ["COINBASE_API_KEY_SECRET"], + "wallet_secret": os.environ["COINBASE_WALLET_SECRET"], + }, + }, + }, + wait_for_ready=True, + max_wait=300, + poll_interval=5, +) + +# Extract details from response +payment_manager_arn = response["paymentManager"]["paymentManagerArn"] +payment_connector_id = response["paymentConnector"]["paymentConnectorId"] +credential_provider_arn = response["credentialProvider"]["credentialProviderArn"] +print(f"Payment Manager ARN: {payment_manager_arn}") +print(f"Payment Connector ID: {payment_connector_id}") +print(f"Credential Provider ARN: {credential_provider_arn}") +``` + +The `wait_for_ready=True` parameter causes the method to poll until all resources reach READY status. +If any step fails, previously created resources are automatically rolled back. + +For Stripe Privy, use `"StripePrivy"` as the `credential_provider_vendor` with the appropriate credentials: + +```python +"credentials": { + "app_id": os.environ["STRIPE_PRIVY_APP_ID"], + "app_secret": os.environ["STRIPE_PRIVY_APP_SECRET"], + "authorization_key": os.environ.get("STRIPE_PRIVY_AUTH_KEY", ""), # optional +} +``` + +--- + +### Creating a Payment Instrument + +Create a payment instrument for a user. Below is an example creating an Ethereum-compatible embedded crypto wallet: + +```python +from bedrock_agentcore.payments import PaymentManager + +manager = PaymentManager( + payment_manager_arn=payment_manager_arn, + region_name="us-east-1", +) + +instrument = manager.create_payment_instrument( + payment_connector_id=payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={ + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "linkedAccounts": [ + {"email": {"emailAddress": "user@example.com"}} + ], + } + }, + user_id="test-user-123", +) + +payment_instrument_id = instrument["paymentInstrumentId"] +``` + +For Solana-compatible chains, use `"SOLANA"` for the network input: + +```python +instrument = manager.create_payment_instrument( + payment_connector_id=payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={ + "embeddedCryptoWallet": { + "network": "SOLANA", + "linkedAccounts": [ + {"email": {"emailAddress": "user@example.com"}} + ], + } + }, + user_id="test-user-123", +) +``` + +> **Important:** Once created, the instrument must be funded and permission granted for signing +> before the agent can use it. These are end-user actions that should be completed before using +> the payment instrument in your agent. +> +> - **Coinbase**: You'll receive a `redirectUrl` in the response pointing to the Coinbase-hosted +> WalletHub. Redirect your user there to grant signing permission and transfer funds. +> - **Stripe**: Developers use a provided URL template to host a frontend page where end users +> can take the same actions. + +--- + +### Querying Instrument Balance + +Check the token balance for a payment instrument on a specific chain: + +```python +balance = manager.get_payment_instrument_balance( + payment_connector_id=payment_connector_id, + payment_instrument_id=payment_instrument_id, + chain="BASE_SEPOLIA", + token="USDC", + user_id="test-user-123", +) +print(f"Balance: {balance}") +``` + +Supported chains include `BASE_SEPOLIA`, `BASE`, `SOLANA_DEVNET`, `SOLANA_MAINNET`, etc. + +--- + +### Creating a Payment Session + +Create a payment session before processing payments: + +```python +session = manager.create_payment_session( + expiry_time_in_minutes=60, + user_id="test-user-123", + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, +) + +payment_session_id = session["paymentSessionId"] +``` + +Check session status and remaining payment limits: + +```python +session_details = manager.get_payment_session( + payment_session_id=payment_session_id, + user_id="test-user-123", +) +print(f"Available: {session_details.get('availableLimits', {}).get('availableSpendAmount')}") +``` + +List all sessions for a user: + +```python +sessions = manager.list_payment_sessions(user_id="test-user-123") +``` + +--- + +### Processing Payments + +Process a payment transaction directly: + +```python +payment = manager.process_payment( + payment_session_id=payment_session_id, + payment_instrument_id=payment_instrument_id, + payment_type="CRYPTO_X402", + payment_input={ + "cryptoX402": { + "version": "1", + "payload": { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "5000", + "resource": "https://example.com/premium-api", + "description": "Premium API access", + "mimeType": "application/json", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD9", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF71", + }, + } + }, + user_id="test-user-123", +) +``` + +--- + +### Payment Header Generation + +Generate x402 payment headers for HTTP 402 Payment Required responses. This is the core method +used by the Strands plugin under the hood: + +```python +header = manager.generate_payment_header( + payment_instrument_id=payment_instrument_id, + payment_session_id=payment_session_id, + payment_required_request={ + "statusCode": 402, + "headers": {"content-type": "application/json"}, + "body": { + "x402Version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "5000", + "resource": "https://example.com/api", + "payTo": "0x...", + "asset": "0x...", + } + ], + }, + }, + user_id="test-user-123", + network_preferences=["base-sepolia", "eip155:8453", "solana-mainnet"], +) +# Returns: {"X-PAYMENT": "base64..."} (v1) or {"PAYMENT-SIGNATURE": "base64..."} (v2) +``` + +**Network selection process:** +1. Filter accepts to those matching the instrument's blockchain type (Ethereum or Solana) +2. Use provided `network_preferences` or fall back to the default `NETWORK_PREFERENCES` +3. Pick the first network from preferences that matches a filtered accept +4. If no match found, return the first filtered accept + +--- + +### Deleting Resources + +#### Data Plane (PaymentManager) + +Delete a payment session (hard delete — permanently removes the record): + +```python +result = manager.delete_payment_session( + payment_session_id="payment-session-abc123", + user_id="user-123", +) +# result: {"status": "DELETED"} +``` + +Delete a payment instrument (soft delete — marks as DELETED, preserved for audit): + +```python +result = manager.delete_payment_instrument( + payment_instrument_id="payment-instrument-xyz789", + payment_connector_id="connector-456", + user_id="user-123", +) +# result: {"status": "DELETED"} +``` + +> **Note:** Deleting a non-existent or already-deleted resource raises `PaymentSessionNotFound` +> or `PaymentInstrumentNotFound`. + +#### Control Plane (PaymentClient) + +```python +from bedrock_agentcore.payments.client import PaymentClient + +client = PaymentClient(region_name="us-east-1") + +# Delete connector first, then manager +client.delete_payment_connector( + payment_manager_id="pm-123", + payment_connector_id="connector-456", +) + +client.delete_payment_manager(payment_manager_id="pm-123") +``` + +> **Important:** Delete resources in the correct order to avoid dependency errors: +> 1. Delete payment instruments first +> 2. Delete payment sessions +> 3. Delete payment connectors +> 4. Delete the payment manager last + +--- + +### Using CUSTOM_JWT (Bearer Token) Authentication + +When your payment manager uses `CUSTOM_JWT` authorizer type, configure the `PaymentManager` with a +bearer token or token provider. The service derives `userId` from the JWT `sub` claim, so `user_id` +is optional on all data plane calls. + +#### Setting Up a CUSTOM_JWT Payment Manager + +```python +from bedrock_agentcore.payments.client import PaymentClient + +client = PaymentClient(region_name="us-east-1") + +manager_response = client.create_payment_manager( + name="JWTPaymentManager", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + authorizer_type="CUSTOM_JWT", + authorizer_configuration={ + "customJWTConfiguration": { + "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE", + "audiences": ["your-client-id"], + } + }, + wait_for_ready=True, +) +``` + +#### Using PaymentManager with a Token Provider + +```python +import requests +from bedrock_agentcore.payments import PaymentManager + +def get_fresh_token() -> str: + """Fetch a fresh JWT from your identity provider.""" + resp = requests.post( + "https://your-domain.auth.us-east-1.amazoncognito.com/oauth2/token", + data={ + "grant_type": "client_credentials", + "client_id": "your-client-id", + "client_secret": "your-client-secret", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + return resp.json()["access_token"] + +manager = PaymentManager( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-jwt", + region_name="us-east-1", + token_provider=get_fresh_token, # Called before each request +) + +# user_id is not required — the service extracts it from the JWT 'sub' claim +instrument = manager.create_payment_instrument( + payment_connector_id="connector-456", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={ + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "linkedAccounts": [ + {"email": {"emailAddress": "user@example.com"}} + ], + } + }, +) +``` + +> **Note:** `bearer_token` and `token_provider` are mutually exclusive. Use `token_provider` in +> production for automatic token refresh. Use `bearer_token` for quick testing with a known token. + +--- + +### Control Plane Operations + +For individual resource management (alternative to `create_payment_manager_with_connector`): + +```python +from bedrock_agentcore.payments.client import PaymentClient + +client = PaymentClient(region_name="us-east-1") + +# Create a payment manager +manager_response = client.create_payment_manager( + name="MyPaymentManager", + role_arn="arn:aws:iam::123456789012:role/PaymentRole", + authorizer_type="AWS_IAM", + wait_for_ready=True, +) + +# Create a payment connector +connector_response = client.create_payment_connector( + payment_manager_id=manager_response["paymentManagerId"], + name="MyCoinbaseConnector", + connector_type="CoinbaseCDP", + credential_provider_configurations=[ + {"coinbaseCDP": {"credentialProviderArn": "arn:..."}} + ], + wait_for_ready=True, +) + +# List payment managers +managers = client.list_payment_managers(max_results=10) + +# List connectors for a manager +connectors = client.list_payment_connectors( + payment_manager_id=manager_response["paymentManagerId"], +) + +# Update a payment manager +client.update_payment_manager( + payment_manager_id=manager_response["paymentManagerId"], + description="Updated description", +) +``` + +--- + +## Error Handling + +### Common Exceptions + +```python +from bedrock_agentcore.payments import ( + PaymentError, + PaymentInstrumentNotFound, + PaymentSessionNotFound, + InvalidPaymentInstrument, + InsufficientBudget, + PaymentSessionExpired, + PaymentInstrumentConfigurationRequired, + PaymentSessionConfigurationRequired, +) + +try: + payment = manager.process_payment( + payment_session_id="session-456", + payment_instrument_id="instrument-789", + payment_type="CRYPTO_X402", + payment_input={...}, + user_id="user-123", + ) +except PaymentInstrumentNotFound: + print("Payment instrument not found. Create one first.") +except PaymentSessionNotFound: + print("Payment session not found or expired.") +except PaymentSessionExpired: + print("Payment session has expired. Create a new session.") +except InsufficientBudget: + print("Payment amount exceeds remaining session budget.") +except InvalidPaymentInstrument: + print("Payment instrument is invalid or inactive.") +except PaymentError as e: + print(f"Payment operation failed: {e}") +``` + +### Best Practices for Error Handling + +1. **Handle specific exceptions first** — catch `PaymentInstrumentNotFound`, `InsufficientBudget`, etc. before the generic `PaymentError` +2. **Handle rate limiting gracefully** — catch `ClientError` with `ThrottlingException` code and retry with backoff +3. **Log errors for debugging** — use structured logging with `exc_info=True` for full tracebacks + +--- + +## Best Practices + +### Infrastructure Setup + +- Use `create_payment_manager_with_connector()` for one-step setup with automatic rollback +- Use `PaymentClient` only for control plane operations (creating/managing managers and connectors) +- Use `PaymentManager` for all data plane operations (instruments, sessions, payments) + +### Instrument Management + +- Use `EMBEDDED_CRYPTO_WALLET` as the instrument type +- Ensure instruments are funded and signing permissions are granted before use +- Use `"ETHEREUM"` or `"SOLANA"` for the network field + +### Session Management + +- Set appropriate `expiry_time_in_minutes` values (15–480 minutes) +- Configure spending limits to control maximum transaction amounts +- Monitor remaining payment limits via `get_payment_session` before processing payments + +### Network Preferences + +- Provide `network_preferences` to control blockchain network selection order +- Default preferences prioritize Solana (fast, low cost) then Ethereum networks +- Ensure your payment instrument's network matches at least one accept in the x402 payload + +### Security + +- Use IAM roles instead of hardcoded credentials in production +- Use `token_provider` (callable) over static `bearer_token` for automatic token refresh +- Never log or expose bearer tokens or API key secrets +- Use `client_token` for idempotent payment operations + +### Thread Safety + +- `PaymentManager` is **not** thread-safe — create separate instances for concurrent operations +- Reuse instances within a single thread for connection pooling benefits + +--- + +## API Reference + +### PaymentManager Methods + +| Method | Description | +|--------|-------------| +| `create_payment_instrument()` | Create a payment instrument (embedded crypto wallet) | +| `get_payment_instrument()` | Retrieve payment instrument details | +| `get_payment_instrument_balance()` | Query token balance for an instrument on a specific chain | +| `list_payment_instruments()` | List payment instruments for a user | +| `delete_payment_instrument()` | Delete a payment instrument (soft delete) | +| `create_payment_session()` | Create a time-bounded payment session with spending limits | +| `get_payment_session()` | Retrieve payment session details | +| `list_payment_sessions()` | List payment sessions for a user | +| `delete_payment_session()` | Delete a payment session (hard delete) | +| `process_payment()` | Process a payment transaction | +| `generate_payment_header()` | Generate x402 payment headers for 402 responses | + +### PaymentManager Constructor Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `payment_manager_arn` | `str` | Yes | ARN of the payment manager instance | +| `region_name` | `Optional[str]` | No | AWS region for the client | +| `boto3_session` | `Optional[boto3.Session]` | No | Custom boto3 session | +| `boto_client_config` | `Optional[BotocoreConfig]` | No | Custom botocore client config | +| `agent_name` | `Optional[str]` | No | Agent name propagated via HTTP header | +| `bearer_token` | `Optional[str]` | No | Static JWT for CUSTOM_JWT auth | +| `token_provider` | `Optional[Callable[[], str]]` | No | Callable returning fresh JWT | + +### PaymentClient Methods + +| Method | Description | +|--------|-------------| +| `create_payment_manager()` | Create a payment manager resource | +| `get_payment_manager()` | Retrieve payment manager details | +| `list_payment_managers()` | List payment managers | +| `update_payment_manager()` | Update a payment manager | +| `delete_payment_manager()` | Delete a payment manager | +| `create_payment_connector()` | Create a payment connector | +| `get_payment_connector()` | Retrieve payment connector details | +| `list_payment_connectors()` | List payment connectors for a manager | +| `update_payment_connector()` | Update a payment connector | +| `delete_payment_connector()` | Delete a payment connector | +| `create_payment_manager_with_connector()` | One-step setup with automatic rollback | + +### Exception Classes + +| Exception | Description | +|-----------|-------------| +| `PaymentError` | Base exception for all payment operations | +| `PaymentInstrumentNotFound` | Payment instrument does not exist | +| `PaymentSessionNotFound` | Payment session does not exist | +| `InvalidPaymentInstrument` | Payment instrument is invalid or inactive | +| `InsufficientBudget` | Payment amount exceeds remaining payment limits | +| `PaymentSessionExpired` | Payment session has expired | +| `PaymentInstrumentConfigurationRequired` | Payment instrument ID not configured | +| `PaymentSessionConfigurationRequired` | Payment session ID not configured | + +### Constants + +| Constant | Description | +|----------|-------------| +| `PaymentManagerStatus` | Payment manager resource statuses (CREATING, READY, etc.) | +| `PaymentConnectorStatus` | Payment connector statuses | +| `PaymentConnectorType` | Supported connector types (CoinbaseCDP, StripePrivy) | +| `PaymentsAuthorizerType` | Authorizer types (AWS_IAM, CUSTOM_JWT) | +| `NETWORK_PREFERENCES` | Default blockchain network preference order | +| `DEFAULT_MAX_RESULTS` | Default pagination limit (100) | + +--- + +## Strands Agents Integration + +For automatic x402 payment handling in Strands Agents, see the dedicated +[Strands AgentCore Payments Plugin README](integrations/strands/README.md). + +The plugin provides: +- Automatic interception and processing of HTTP 402 responses +- Built-in payment query tools for agents +- Interrupt-based error handling for payment failures +- Configurable auto-payment and tool allowlists diff --git a/src/bedrock_agentcore/payments/__init__.py b/src/bedrock_agentcore/payments/__init__.py new file mode 100644 index 00000000..ee47454b --- /dev/null +++ b/src/bedrock_agentcore/payments/__init__.py @@ -0,0 +1,39 @@ +"""Bedrock AgentCore Payment SDK.""" + +from .client import PaymentClient +from .constants import ( + DEFAULT_MAX_RESULTS, + PaymentConnectorStatus, + PaymentConnectorType, + PaymentManagerStatus, + PaymentsAuthorizerType, +) +from .manager import ( + InsufficientBudget, + InvalidPaymentInstrument, + PaymentError, + PaymentInstrumentConfigurationRequired, + PaymentInstrumentNotFound, + PaymentManager, + PaymentSessionConfigurationRequired, + PaymentSessionExpired, + PaymentSessionNotFound, +) + +__all__ = [ + "PaymentClient", + "PaymentError", + "PaymentInstrumentConfigurationRequired", + "PaymentSessionConfigurationRequired", + "PaymentInstrumentNotFound", + "PaymentSessionNotFound", + "InvalidPaymentInstrument", + "InsufficientBudget", + "PaymentSessionExpired", + "PaymentManager", + "PaymentManagerStatus", + "PaymentConnectorStatus", + "PaymentConnectorType", + "PaymentsAuthorizerType", + "DEFAULT_MAX_RESULTS", +] diff --git a/src/bedrock_agentcore/payments/client.py b/src/bedrock_agentcore/payments/client.py new file mode 100644 index 00000000..bc14791f --- /dev/null +++ b/src/bedrock_agentcore/payments/client.py @@ -0,0 +1,1215 @@ +"""AgentCore Payments SDK - PaymentClient. + +This module provides a low-level SDK for integrating payment management into Bedrock AgentCore. +It enables direct communication with payment control plane APIs for managing payment managers +and payment connectors. + +The PaymentsClient provides both direct boto3 method forwarding and access to control plane operations. +""" + +import logging +import time +import uuid +from typing import Any, Dict, List, Optional, TypedDict, Union + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +from bedrock_agentcore._utils.endpoints import get_control_plane_endpoint +from bedrock_agentcore._utils.user_agent import build_user_agent_suffix +from bedrock_agentcore.services.identity import IdentityClient + +logger = logging.getLogger(__name__) + + +class CoinbaseCdpConfigurationInput(TypedDict, total=False): + """Configuration for Coinbase CDP credential provider. + + Attributes: + api_key_id: The API key ID from Coinbase Developer Platform + api_key_secret: The API key secret from Coinbase Developer Platform + wallet_secret: The wallet secret from Coinbase Developer Platform + """ + + api_key_id: str + api_key_secret: str + wallet_secret: str + + +class CoinbaseCdpCredentials(TypedDict): + """Coinbase CDP specific credentials. + + Attributes: + api_key_id: The API key ID from Coinbase CDP + api_key_secret: The API key secret from Coinbase CDP + wallet_secret: The wallet secret from Coinbase CDP + """ + + api_key_id: str + api_key_secret: str + wallet_secret: str + + +class StripePrivyConfigurationInput(TypedDict, total=False): + """Configuration for Stripe + Privy credential provider. + + Attributes: + app_id: The Privy application ID + app_secret: The Privy application secret + authorization_private_key: The private key used for authorization signing + authorization_id: The authorization identifier + """ + + app_id: str + app_secret: str + authorization_private_key: str + authorization_id: str + + +class StripePrivyCredentials(TypedDict): + """Stripe + Privy specific credentials. + + Attributes: + app_id: The Privy application ID + app_secret: The Privy application secret + authorization_private_key: The private key used for authorization signing + authorization_id: The authorization identifier + """ + + app_id: str + app_secret: str + authorization_private_key: str + authorization_id: str + + +# Union type for vendor-specific credentials +CredentialProviderCredentials = Union[CoinbaseCdpCredentials, StripePrivyCredentials] + + +class ConnectorCredentialProviderConfig(TypedDict, total=False): + """Configuration for credential provider used by payment connector. + + Attributes: + name: Unique name for the credential provider + credential_provider_vendor: The vendor type (CoinbaseCDP or StripePrivy) + credentials: Vendor-specific credentials (CoinbaseCdpCredentials or StripePrivyCredentials) + """ + + name: str + credential_provider_vendor: str + credentials: CredentialProviderCredentials + + +class PaymentConnectorConfig(TypedDict, total=False): + """Configuration for payment connector with credential provider. + + Attributes: + name: Unique name for the payment connector + description: Optional description for the payment connector + payment_credential_provider_config: Credential provider configuration containing: + - name: Unique name for the credential provider + - credential_provider_vendor: Vendor type (CoinbaseCDP or StripePrivy) + - credentials: Vendor-specific credentials (CoinbaseCdpCredentials or StripePrivyCredentials) + """ + + name: str + description: str + payment_credential_provider_config: ConnectorCredentialProviderConfig + + +class PaymentClient: + """Low-level control plane client for payment operations. + + Provides direct boto3 method forwarding for control plane operations. + """ + + # Allowed control plane methods (forwarded to bedrock-agentcore-control client) + _ALLOWED_PAYMENTS_CP_METHODS = { + "create_payment_manager", + "get_payment_manager", + "list_payment_managers", + "update_payment_manager", + "delete_payment_manager", + "create_payment_connector", + "get_payment_connector", + "list_payment_connectors", + "update_payment_connector", + "delete_payment_connector", + } + + @staticmethod + def _is_not_blank(value: Optional[str]) -> bool: + """Check if a parameter value is not blank (not None and not empty string). + + Args: + value: The parameter value to validate + + Returns: + True if the value is not blank, False otherwise + """ + return value is not None and value != "" + + @staticmethod + def _safe_error_message(e: Exception) -> str: + """Extract a safe error message that won't leak credentials. + + For ClientError, returns the error code and sanitized message. + For other exceptions, returns only the exception type to avoid + leaking credential data that may appear in str(e). + + Args: + e: The exception to extract a safe message from + + Returns: + A sanitized error string safe for logging + """ + if isinstance(e, ClientError): + error_code = e.response.get("Error", {}).get("Code", "Unknown") + error_message = e.response.get("Error", {}).get("Message", "Unknown error") + return f"{error_code}: {error_message}" + if isinstance(e, ValueError): + return f"ValueError: {str(e)}" + return f"{type(e).__name__}: (details redacted for security)" + + @staticmethod + def _build_provider_config_input( + payment_credential_provider_config: Dict[str, Any], + ) -> Dict[str, Any]: + """Build provider configuration input based on vendor type. + + Args: + payment_credential_provider_config: Credential provider configuration containing: + - credential_provider_vendor: The vendor type (e.g., CoinbaseCDP, StripePrivy) + - credentials: Vendor-specific credentials + + Returns: + Dictionary with the appropriate configuration structure for the vendor + + Raises: + ValueError: If required credential fields are missing or None + + Example: + For CoinbaseCDP vendor: + { + "coinbaseCdpConfiguration": { + "apiKeyId": "...", + "apiKeySecret": "...", + "walletSecret": "..." + } + } + + For StripePrivy vendor: + { + "stripePrivyConfiguration": { + "appId": "...", + "appSecret": "...", + "authorizationPrivateKey": "...", + "authorizationId": "..." + } + } + """ + vendor = payment_credential_provider_config.get("credential_provider_vendor") + if not vendor: + raise ValueError("credential_provider_vendor is required") + + credentials: CredentialProviderCredentials = payment_credential_provider_config.get("credentials", {}) # type: ignore + + if vendor == "CoinbaseCDP": + required_fields = ["api_key_id", "api_key_secret", "wallet_secret"] + missing = [f for f in required_fields if not credentials.get(f)] + if missing: + raise ValueError(f"Missing required CoinbaseCDP credential fields: {', '.join(missing)}") + return { + "coinbaseCdpConfiguration": { + "apiKeyId": credentials["api_key_id"], + "apiKeySecret": credentials["api_key_secret"], + "walletSecret": credentials["wallet_secret"], + } + } + elif vendor == "StripePrivy": + required_fields = ["app_id", "app_secret", "authorization_private_key", "authorization_id"] + missing = [f for f in required_fields if not credentials.get(f)] + if missing: + raise ValueError(f"Missing required StripePrivy credential fields: {', '.join(missing)}") + return { + "stripePrivyConfiguration": { + "appId": credentials["app_id"], + "appSecret": credentials["app_secret"], + "authorizationPrivateKey": credentials["authorization_private_key"], + "authorizationId": credentials["authorization_id"], + } + } + else: + raise ValueError( + f"Unsupported credential_provider_vendor: '{vendor}'. Supported vendors are: CoinbaseCDP, StripePrivy" + ) + + def __init__( + self, + region_name: Optional[str] = None, + integration_source: Optional[str] = None, + ) -> None: + """Initialize the Payments control plane client. + + Args: + region_name: AWS region name. Defaults to boto3 session region or us-west-2 + integration_source: Optional identifier for tracking integration source in telemetry + + """ + self.region_name = region_name or boto3.Session().region_name or "us-west-2" + self.integration_source = integration_source + + # Build config with user-agent for telemetry + user_agent_extra = build_user_agent_suffix(integration_source) + client_config = Config(user_agent_extra=user_agent_extra) + + # Control plane operations are available through bedrock-agentcore-control service + self.payments_cp_client = boto3.client( + "bedrock-agentcore-control", + region_name=self.region_name, + endpoint_url=get_control_plane_endpoint(self.region_name), + config=client_config, + ) + + # Initialize identity client for credential provider operations + self.identity_client = IdentityClient(region=self.region_name) + + logger.info( + "Initialized PaymentClient for control plane: %s", + self.payments_cp_client.meta.region_name, + ) + + def __getattr__(self, name: str): + """Dynamically forward method calls to the control plane boto3 client. + + This method enables access to all boto3 client methods without explicitly + defining them. Methods are looked up in the following order: + 1. payments_cp_client (bedrock-agentcore-control) - for control plane operations + + Args: + name: The method name being accessed + + Returns: + A callable method from the control plane boto3 client + + Raises: + AttributeError: If the method doesn't exist on the control plane client + + Example: + # Access any boto3 method directly + client = PaymentClient() + + # These calls are forwarded to the control plane boto3 client + response = client.create_payment_manager(...) + response = client.get_payment_connector(...) + """ + if name in self._ALLOWED_PAYMENTS_CP_METHODS and hasattr(self.payments_cp_client, name): + method = getattr(self.payments_cp_client, name) + logger.debug("Forwarding method '%s' to payments_cp_client", name) + return method + + # Method not found on control plane client + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'. " + f"Method not found on payments_cp_client. " + f"Available methods can be found in the boto3 documentation for " + f"'bedrock-agentcore-control' service." + ) + + def _wait_for_status( + self, + get_method, + resource_id: str, + target_status: str, + max_wait: int = 300, + poll_interval: int = 10, + **get_kwargs, + ) -> Dict[str, Any]: + """Wait for a resource to reach a target status. + + Args: + get_method: The get method to call (e.g., get_payment_manager) + resource_id: ID of the resource to check + target_status: Status to wait for + max_wait: Maximum seconds to wait + poll_interval: Seconds between checks + **get_kwargs: Additional kwargs for the get method + + Returns: + The resource details when target status is reached + + Raises: + TimeoutError: If max_wait is exceeded + ClientError: If the resource reaches a failed status + """ + start_time = time.time() + while True: + elapsed = time.time() - start_time + if elapsed > max_wait: + raise TimeoutError(f"Timeout waiting for resource {resource_id} to reach {target_status} status") + + try: + # Call get_method with resource_id as first positional arg and any additional kwargs + if get_kwargs: + response = get_method(resource_id, **get_kwargs) + else: + response = get_method(resource_id) + status = response.get("status") + + if status == target_status: + logger.info("Resource %s reached %s status", resource_id, target_status) + return response + + if status in ["CREATE_FAILED", "UPDATE_FAILED", "DELETE_FAILED"]: + raise ClientError( + {"Error": {"Code": "ResourceFailed", "Message": f"Resource reached {status} status"}}, + "GetResource", + ) + + logger.debug("Resource %s status: %s (elapsed: %.1fs)", resource_id, status, elapsed) + time.sleep(poll_interval) + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code == "ResourceNotFoundException": + logger.debug("Resource %s not found yet (elapsed: %.1fs)", resource_id, elapsed) + time.sleep(poll_interval) + else: + raise + + def create_payment_manager( + self, + name: str, + role_arn: str, + authorizer_type: str = "AWS_IAM", + description: Optional[str] = None, + authorizer_configuration: Optional[Dict[str, Any]] = None, + client_token: Optional[str] = None, + wait_for_ready: bool = False, + max_wait: int = 300, + poll_interval: int = 10, + ) -> Dict[str, Any]: + """Create a payment manager resource. + + Args: + name: Name of the payment manager + role_arn: IAM role ARN for payment manager authorization + authorizer_type: Authorization type (default: AWS_IAM) + description: Optional description + authorizer_configuration: Optional authorizer configuration + client_token: Optional idempotency token. If not provided, a UUID will be generated. + wait_for_ready: Whether to wait for manager to reach READY status + max_wait: Maximum seconds to wait if wait_for_ready is True + poll_interval: Seconds between checks if wait_for_ready is True + + Returns: + Dictionary with paymentManagerArn, paymentManagerId, and status + + Raises: + ClientError: If creation fails + TimeoutError: If wait_for_ready is True and max_wait is exceeded + """ + if client_token is None: + client_token = str(uuid.uuid4()) + + try: + logger.info("Creating payment manager: %s with role %s", name, role_arn) + params = { + "name": name, + "roleArn": role_arn, + "authorizerType": authorizer_type, + "clientToken": client_token, + } + + if self._is_not_blank(description): + params["description"] = description + + if authorizer_configuration: + params["authorizerConfiguration"] = authorizer_configuration + + response = self.payments_cp_client.create_payment_manager(**params) + + manager_arn = response.get("paymentManagerArn") + manager_id = response.get("paymentManagerId") + status = response.get("status") + + logger.info("Payment manager created: %s (status: %s)", manager_arn, status) + + if wait_for_ready: + logger.info("Waiting for payment manager %s to reach READY status", manager_id) + response = self._wait_for_status( + self.get_payment_manager, + manager_id, + "READY", + max_wait=max_wait, + poll_interval=poll_interval, + ) + status = response.get("status") + + return { + "paymentManagerArn": manager_arn, + "paymentManagerId": manager_id, + "status": status, + } + + except ClientError as e: + logger.error("Failed to create payment manager: %s", e) + raise + + def get_payment_manager(self, payment_manager_id: str) -> Dict[str, Any]: + """Retrieve payment manager details. + + Args: + payment_manager_id: ID of the payment manager + + Returns: + Dictionary with payment manager configuration + + Raises: + ClientError: If retrieval fails + """ + try: + logger.info("Retrieving payment manager: %s", payment_manager_id) + response = self.payments_cp_client.get_payment_manager(paymentManagerId=payment_manager_id) + + return { + "paymentManagerId": response.get("paymentManagerId"), + "paymentManagerArn": response.get("paymentManagerArn"), + "name": response.get("name"), + "description": response.get("description"), + "status": response.get("status"), + "createdAt": response.get("createdAt"), + "updatedAt": response.get("updatedAt"), + } + + except ClientError as e: + logger.error("Failed to get payment manager: %s", e) + raise + + def list_payment_managers(self, max_results: int = 100, next_token: Optional[str] = None) -> Dict[str, Any]: + """List all payment managers with pagination support. + + Args: + max_results: Maximum number of results to return (default: 100) + next_token: Token for pagination to retrieve the next set of results + + Returns: + Dictionary containing: + - paymentManagers: List of payment manager configurations + - nextToken: Token for retrieving the next page (if more results exist) + + Raises: + ClientError: If listing fails + """ + try: + logger.info("Listing payment managers with max_results=%s, next_token=%s", max_results, next_token) + params = {"maxResults": max_results} + if self._is_not_blank(next_token): + params["nextToken"] = next_token + + response = self.payments_cp_client.list_payment_managers(**params) + + managers = [] + for manager in response.get("paymentManagers", []): + managers.append( + { + "paymentManagerId": manager.get("paymentManagerId"), + "paymentManagerArn": manager.get("paymentManagerArn"), + "name": manager.get("name"), + "description": manager.get("description"), + "status": manager.get("status"), + "createdAt": manager.get("createdAt"), + "updatedAt": manager.get("updatedAt"), + } + ) + + logger.info("Retrieved %s payment managers", len(managers)) + return { + "paymentManagers": managers, + "nextToken": response.get("nextToken"), + } + + except ClientError as e: + logger.error("Failed to list payment managers: %s", e) + raise + + def update_payment_manager( + self, + payment_manager_id: str, + description: Optional[str] = None, + authorizer_type: Optional[str] = None, + authorizer_configuration: Optional[Dict[str, Any]] = None, + role_arn: Optional[str] = None, + client_token: Optional[str] = None, + wait_for_ready: bool = False, + max_wait: int = 300, + poll_interval: int = 10, + ) -> Dict[str, Any]: + """Update a payment manager. + + Args: + payment_manager_id: ID of the payment manager to update + description: Optional new description + authorizer_type: Optional authorizer type (CUSTOM_JWT or AWS_IAM) + authorizer_configuration: Optional authorizer configuration + role_arn: Optional IAM role ARN for the payment manager + client_token: Optional idempotency token. If not provided, a UUID will be generated. + wait_for_ready: Whether to wait for manager to reach READY status + max_wait: Maximum seconds to wait if wait_for_ready is True + poll_interval: Seconds between checks if wait_for_ready is True + + Returns: + Dictionary with updated manager details + + Raises: + ClientError: If update fails + TimeoutError: If wait_for_ready is True and max_wait is exceeded + """ + if client_token is None: + client_token = str(uuid.uuid4()) + + try: + logger.info("Updating payment manager: %s", payment_manager_id) + params = { + "paymentManagerId": payment_manager_id, + "clientToken": client_token, + } + + if self._is_not_blank(description): + params["description"] = description + + if authorizer_type: + params["authorizerType"] = authorizer_type + + if authorizer_configuration: + params["authorizerConfiguration"] = authorizer_configuration + + if self._is_not_blank(role_arn): + params["roleArn"] = role_arn + + response = self.payments_cp_client.update_payment_manager(**params) + + status = response.get("status") + + if wait_for_ready: + logger.info("Waiting for payment manager %s to reach READY status", payment_manager_id) + response = self._wait_for_status( + self.get_payment_manager, + payment_manager_id, + "READY", + max_wait=max_wait, + poll_interval=poll_interval, + ) + status = response.get("status") + + result = { + "paymentManagerId": response.get("paymentManagerId"), + "paymentManagerArn": response.get("paymentManagerArn"), + "name": response.get("name"), + "description": response.get("description"), + "status": status, + "updatedAt": response.get("updatedAt"), + } + + return result + + except ClientError as e: + logger.error("Failed to update payment manager: %s", e) + raise + + def delete_payment_manager( + self, + payment_manager_id: str, + ) -> Dict[str, Any]: + """Delete a payment manager. + + Args: + payment_manager_id: ID of the payment manager to delete + + Returns: + Dictionary with deletion status + + Raises: + ClientError: If deletion fails + """ + try: + logger.info("Deleting payment manager: %s", payment_manager_id) + response = self.payments_cp_client.delete_payment_manager( + paymentManagerId=payment_manager_id, + ) + + logger.info("Initiated deletion of payment manager: %s", payment_manager_id) + + return { + "paymentManagerId": payment_manager_id, + "status": response.get("status", "DELETED"), + } + + except ClientError as e: + logger.error("Failed to delete payment manager: %s", e) + raise + + def create_payment_connector( + self, + payment_manager_id: str, + name: str, + connector_type: str, + credential_provider_configurations: List[Dict[str, Any]], + description: Optional[str] = None, + client_token: Optional[str] = None, + wait_for_ready: bool = False, + max_wait: int = 300, + poll_interval: int = 10, + ) -> Dict[str, Any]: + """Create a payment connector for a provider. + + Args: + payment_manager_id: ID of the payment manager + name: Name of the connector + connector_type: Connector type (e.g., CoinbaseCDP) + credential_provider_configurations: List of credential provider configurations. + Each config should be a dict with provider name as key and credential config as value. + Example: [{"coinbaseCDP": {"credentialProviderArn": "arn:..."}}] + description: Optional description + client_token: Optional idempotency token. If not provided, a UUID will be generated. + wait_for_ready: Whether to wait for connector to reach READY status + max_wait: Maximum seconds to wait if wait_for_ready is True + poll_interval: Seconds between checks if wait_for_ready is True + + Returns: + Dictionary with paymentConnectorId and status + + Raises: + ClientError: If creation fails + TimeoutError: If wait_for_ready is True and max_wait is exceeded + """ + if client_token is None: + client_token = str(uuid.uuid4()) + + try: + logger.info( + "Creating payment connector: %s for manager %s", + name, + payment_manager_id, + ) + + params = { + "paymentManagerId": payment_manager_id, + "name": name, + "type": connector_type, + "credentialProviderConfigurations": credential_provider_configurations, + "clientToken": client_token, + } + + if self._is_not_blank(description): + params["description"] = description + + response = self.payments_cp_client.create_payment_connector(**params) + + payment_connector_id = response.get("paymentConnectorId") + status = response.get("status") + + logger.info("Payment connector created: %s (status: %s)", payment_connector_id, status) + + if wait_for_ready: + logger.info("Waiting for payment connector %s to reach READY status", payment_connector_id) + + # Create a wrapper function that calls get_payment_connector with the correct arguments + def get_connector_status(conn_id): + return self.get_payment_connector(payment_manager_id, conn_id) + + response = self._wait_for_status( + get_connector_status, + payment_connector_id, + "READY", + max_wait=max_wait, + poll_interval=poll_interval, + ) + status = response.get("status") + + return { + "paymentConnectorId": payment_connector_id, + "status": status, + } + + except ClientError as e: + logger.error("Failed to create payment connector: %s", e) + raise + + def get_payment_connector(self, payment_manager_id: str, payment_connector_id: str) -> Dict[str, Any]: + """Retrieve payment connector details. + + Args: + payment_manager_id: ID of the payment manager + payment_connector_id: ID of the connector + + Returns: + Dictionary with payment connector configuration + + Raises: + ClientError: If retrieval fails + """ + try: + logger.info("Retrieving payment connector: %s for manager %s", payment_connector_id, payment_manager_id) + response = self.payments_cp_client.get_payment_connector( + paymentManagerId=payment_manager_id, paymentConnectorId=payment_connector_id + ) + + return { + "paymentConnectorId": response.get("paymentConnectorId"), + "paymentManagerId": response.get("paymentManagerId"), + "name": response.get("name"), + "description": response.get("description"), + "providerType": response.get("type"), + "status": response.get("status"), + "createdAt": response.get("createdAt"), + "updatedAt": response.get("lastUpdatedAt"), + } + + except ClientError as e: + logger.error("Failed to get payment connector: %s", e) + raise + + def list_payment_connectors( + self, payment_manager_id: str, max_results: int = 100, next_token: Optional[str] = None + ) -> Dict[str, Any]: + """List connectors for a payment manager with pagination support. + + Args: + payment_manager_id: ID of the payment manager + max_results: Maximum number of results to return (default: 100) + next_token: Token for pagination to retrieve the next set of results + + Returns: + Dictionary containing: + - paymentConnectors: List of payment connector configurations + - nextToken: Token for retrieving the next page (if more results exist) + + Raises: + ClientError: If listing fails + """ + try: + logger.info( + "Listing payment connectors for manager %s with max_results=%s, next_token=%s", + payment_manager_id, + max_results, + next_token, + ) + params = {"paymentManagerId": payment_manager_id, "maxResults": max_results} + if self._is_not_blank(next_token): + params["nextToken"] = next_token + + response = self.payments_cp_client.list_payment_connectors(**params) + + connectors = [] + for connector in response.get("paymentConnectors", []): + connectors.append( + { + "paymentConnectorId": connector.get("paymentConnectorId"), + "paymentManagerId": connector.get("paymentManagerId"), + "name": connector.get("name"), + "description": connector.get("description"), + "providerType": connector.get("type"), + "status": connector.get("status"), + "createdAt": connector.get("createdAt"), + "updatedAt": connector.get("lastUpdatedAt"), + } + ) + + logger.info("Retrieved %s payment connectors", len(connectors)) + return { + "paymentConnectors": connectors, + "nextToken": response.get("nextToken"), + } + + except ClientError as e: + logger.error("Failed to list payment connectors: %s", e) + raise + + def delete_payment_connector( + self, + payment_manager_id: str, + payment_connector_id: str, + ) -> Dict[str, Any]: + """Delete a payment connector. + + Args: + payment_manager_id: ID of the payment manager + payment_connector_id: ID of the connector to delete + + Returns: + Dictionary with deletion status + + Raises: + ClientError: If deletion fails + """ + try: + logger.info("Deleting payment connector: %s for manager %s", payment_connector_id, payment_manager_id) + response = self.payments_cp_client.delete_payment_connector( + paymentManagerId=payment_manager_id, + paymentConnectorId=payment_connector_id, + ) + + logger.info("Initiated deletion of payment connector: %s", payment_connector_id) + + return { + "paymentConnectorId": payment_connector_id, + "status": response.get("status", "DELETED"), + } + + except ClientError as e: + logger.error("Failed to delete payment connector: %s", e) + raise + + def update_payment_connector( + self, + payment_manager_id: str, + payment_connector_id: str, + description: Optional[str] = None, + connector_type: Optional[str] = None, + credential_provider_configurations: Optional[List[Dict[str, Any]]] = None, + client_token: Optional[str] = None, + wait_for_ready: bool = False, + max_wait: int = 300, + poll_interval: int = 10, + ) -> Dict[str, Any]: + """Update a payment connector. + + Args: + payment_manager_id: ID of the payment manager + payment_connector_id: ID of the connector to update + description: Optional new description + connector_type: Optional connector type (e.g., CoinbaseCDP) + credential_provider_configurations: Optional list of credential provider configurations. + Each config should be a dict with provider name as key and credential config as value. + Example: [{"coinbaseCDP": {"credentialProviderArn": "arn:..."}}] + client_token: Optional idempotency token. If not provided, a UUID will be generated. + wait_for_ready: Whether to wait for connector to reach READY status + max_wait: Maximum seconds to wait if wait_for_ready is True + poll_interval: Seconds between checks if wait_for_ready is True + + Returns: + Dictionary with updated connector details + + Raises: + ClientError: If update fails + TimeoutError: If wait_for_ready is True and max_wait is exceeded + """ + if client_token is None: + client_token = str(uuid.uuid4()) + + try: + logger.info("Updating payment connector: %s for manager %s", payment_connector_id, payment_manager_id) + params = { + "paymentManagerId": payment_manager_id, + "paymentConnectorId": payment_connector_id, + "clientToken": client_token, + } + + if self._is_not_blank(description): + params["description"] = description + + if connector_type: + params["type"] = connector_type + + if credential_provider_configurations: + params["credentialProviderConfigurations"] = credential_provider_configurations + + response = self.payments_cp_client.update_payment_connector(**params) + + status = response.get("status") + + if wait_for_ready: + logger.info("Waiting for payment connector %s to reach READY status", payment_connector_id) + + def get_connector_status(conn_id): + return self.get_payment_connector(payment_manager_id, conn_id) + + response = self._wait_for_status( + get_connector_status, + payment_connector_id, + "READY", + max_wait=max_wait, + poll_interval=poll_interval, + ) + + status = response.get("status") + + result = { + "paymentConnectorId": response.get("paymentConnectorId"), + "paymentManagerId": response.get("paymentManagerId"), + "name": response.get("name"), + "description": response.get("description"), + "providerType": response.get("type"), + "status": status, + "updatedAt": response.get("lastUpdatedAt"), + } + + return result + + except ClientError as e: + logger.error("Failed to update payment connector: %s", e) + raise + + def create_payment_manager_with_connector( + self, + payment_manager_name: str, + payment_manager_description: Optional[str], + authorizer_type: str, + role_arn: str, + payment_connector_config: PaymentConnectorConfig, + wait_for_ready: bool = False, + max_wait: int = 300, + poll_interval: int = 10, + ) -> Dict[str, Any]: + """Create a payment manager with connector and credential provider in one operation. + + This method orchestrates the creation of three interdependent resources: + 1. Payment Credential Provider (via IdentityClient) - stores vendor credentials + 2. Payment Manager (via PaymentClient) - manages payment operations + 3. Payment Connector (via PaymentClient) - connects to payment provider + + Client tokens are generated internally for each resource creation call to ensure idempotency. + If any step fails, the method automatically rolls back previously created resources. + + Args: + payment_manager_name: Name of the payment manager + payment_manager_description: Optional description for payment manager + authorizer_type: Authorization type (default: AWS_IAM) + role_arn: IAM role ARN for payment manager authorization + payment_connector_config: Configuration for payment connector including: + - name: Unique name for the payment connector + - description: Optional description for the payment connector + - payment_credential_provider_config: Credential provider configuration with: + - name: Unique name for the credential provider + - credential_provider_vendor: Vendor type (e.g., CoinbaseCDP, StripePrivy) + - credentials: Vendor-specific credentials + (CoinbaseCdpCredentials or StripePrivyCredentials) + wait_for_ready: Whether to wait for resources to reach READY status + max_wait: Maximum seconds to wait if wait_for_ready is True + poll_interval: Seconds between checks if wait_for_ready is True + + Returns: + Dictionary containing consolidated response with: + - paymentManager: Payment manager details (ARN, ID, status) + - paymentConnector: Payment connector details (ID, status) + - credentialProvider: Credential provider details (ARN, name) + + Raises: + ValueError: If required parameters are missing or invalid + ClientError: If any API call fails (with automatic rollback) + + Example: + ```python + from bedrock_agentcore.payments.client import PaymentClient + + payment_client = PaymentClient(region_name="us-east-1") + + response = payment_client.create_payment_manager_with_connector( + payment_manager_name="CDPPaymentManager", + payment_manager_description="Coinbase Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config={ + "name": "coinbase-connector", + "description": "Coinbase CDP Connector", + "payment_credential_provider_config": { + "name": "coinbase-provider-name", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "your-api-key-id", + "api_key_secret": "your-api-key-secret", + "wallet_secret": "your-wallet-secret", + }, + }, + }, + wait_for_ready=True, + ) + + manager_arn = response["paymentManager"]["paymentManagerArn"] + payment_connector_id = response["paymentConnector"]["paymentConnectorId"] + provider_arn = response["credentialProvider"]["credentialProviderArn"] + ``` + """ + mgr_client_token = str(uuid.uuid4()) + connector_client_token = str(uuid.uuid4()) + + # Extract credential provider config + payment_credential_provider_config = payment_connector_config.get("payment_credential_provider_config", {}) + + # Track created resources for rollback + created_resources = { + "credential_provider_name": None, + "payment_manager_id": None, + "payment_connector_id": None, + } + + try: + # Step 1: Create Payment Credential Provider via IdentityClient + logger.info("Step 1: Creating payment credential provider: %s", payment_credential_provider_config["name"]) + + # Build provider configuration based on vendor type + provider_config_input = self._build_provider_config_input(payment_credential_provider_config) + + credential_provider_response = self.identity_client.create_payment_credential_provider( + name=payment_credential_provider_config["name"], + credential_provider_vendor=payment_credential_provider_config["credential_provider_vendor"], + provider_configuration_input=provider_config_input, + ) + created_resources["credential_provider_name"] = payment_credential_provider_config["name"] + credential_provider_arn = credential_provider_response.get("credentialProviderArn") + logger.info("Successfully created credential provider: %s", credential_provider_arn) + + # Step 2: Create Payment Manager + logger.info("Step 2: Creating payment manager: %s", payment_manager_name) + + manager_response = self.create_payment_manager( + name=payment_manager_name, + role_arn=role_arn, + authorizer_type=authorizer_type, + description=payment_manager_description, + client_token=mgr_client_token, + wait_for_ready=wait_for_ready, + max_wait=max_wait, + poll_interval=poll_interval, + ) + + payment_manager_id = manager_response.get("paymentManagerId") + payment_manager_arn = manager_response.get("paymentManagerArn") + created_resources["payment_manager_id"] = payment_manager_id + + logger.info("Successfully created payment manager: %s", payment_manager_arn) + + # Step 3: Create Payment Connector + logger.info("Step 3: Creating payment connector: %s", payment_connector_config["name"]) + + # Build credential provider configurations in the expected format + vendor = payment_credential_provider_config["credential_provider_vendor"] + if vendor == "CoinbaseCDP": + credential_provider_configs = [{"coinbaseCDP": {"credentialProviderArn": credential_provider_arn}}] + elif vendor == "StripePrivy": + credential_provider_configs = [{"stripePrivy": {"credentialProviderArn": credential_provider_arn}}] + else: + raise ValueError( + f"Unsupported credential_provider_vendor: '{vendor}'. " + f"Supported vendors are: CoinbaseCDP, StripePrivy" + ) + + connector_response = self.create_payment_connector( + payment_manager_id=payment_manager_id, + name=payment_connector_config["name"], + connector_type=payment_credential_provider_config["credential_provider_vendor"], + credential_provider_configurations=credential_provider_configs, + description=payment_connector_config.get("description"), + client_token=connector_client_token, + wait_for_ready=wait_for_ready, + max_wait=max_wait, + poll_interval=poll_interval, + ) + + payment_connector_id = connector_response.get("paymentConnectorId") + created_resources["payment_connector_id"] = payment_connector_id + + logger.info("Successfully created payment connector: %s", payment_connector_id) + + # Return consolidated response + logger.info("Successfully completed payment manager with connector creation") + return { + "paymentManager": { + "paymentManagerArn": payment_manager_arn, + "paymentManagerId": payment_manager_id, + "name": payment_manager_name, + "description": payment_manager_description, + "status": manager_response.get("status"), + }, + "paymentConnector": { + "paymentConnectorId": payment_connector_id, + "paymentManagerId": payment_manager_id, + "name": payment_connector_config["name"], + "description": payment_connector_config.get("description"), + "providerType": payment_credential_provider_config["credential_provider_vendor"], + "status": connector_response.get("status"), + }, + "credentialProvider": { + "credentialProviderArn": credential_provider_arn, + "name": payment_credential_provider_config["name"], + "credentialProviderVendor": payment_credential_provider_config["credential_provider_vendor"], + }, + } + + except Exception as e: + safe_error = self._safe_error_message(e) + logger.error("Error during payment manager with connector creation: %s", safe_error) + logger.info("Initiating rollback of created resources...") + + # Rollback: Delete created resources in reverse order + rollback_errors = [] + + # Rollback Payment Connector + if created_resources["payment_connector_id"]: + try: + logger.info("Rolling back payment connector: %s", created_resources["payment_connector_id"]) + self.delete_payment_connector( + payment_manager_id=created_resources["payment_manager_id"], + payment_connector_id=created_resources["payment_connector_id"], + ) + logger.info("Successfully rolled back payment connector") + except Exception as rollback_error: + error_msg = f"Failed to rollback connector: {self._safe_error_message(rollback_error)}" + logger.error(error_msg) + rollback_errors.append(error_msg) + + # Rollback Payment Manager + if created_resources["payment_manager_id"]: + try: + logger.info("Rolling back payment manager: %s", created_resources["payment_manager_id"]) + self.delete_payment_manager(payment_manager_id=created_resources["payment_manager_id"]) + logger.info("Successfully rolled back payment manager") + except Exception as rollback_error: + error_msg = f"Failed to rollback manager: {self._safe_error_message(rollback_error)}" + logger.error(error_msg) + rollback_errors.append(error_msg) + + # Rollback Credential Provider + if created_resources["credential_provider_name"]: + try: + logger.info("Rolling back credential provider: %s", created_resources["credential_provider_name"]) + self.identity_client.delete_payment_credential_provider( + name=created_resources["credential_provider_name"] + ) + logger.info("Successfully rolled back credential provider") + except Exception as rollback_error: + error_msg = f"Failed to rollback credential provider: {self._safe_error_message(rollback_error)}" + logger.error(error_msg) + rollback_errors.append(error_msg) + + # Raise error with rollback information + if rollback_errors: + rollback_summary = "\n".join(rollback_errors) + error_message = ( + f"Failed to create payment manager with connector. " + f"Original error: {safe_error}\n" + f"Rollback errors:\n{rollback_summary}" + ) + logger.error(error_message) + raise ClientError( + { + "Error": { + "Code": "PaymentManagerCreationFailed", + "Message": error_message, + } + }, + "CreatePaymentManagerWithConnector", + ) from e + else: + logger.info("Rollback completed successfully") + raise ClientError( + { + "Error": { + "Code": "PaymentManagerCreationFailed", + "Message": f"Failed to create payment manager with connector: {safe_error}", + } + }, + "CreatePaymentManagerWithConnector", + ) from e diff --git a/src/bedrock_agentcore/payments/constants.py b/src/bedrock_agentcore/payments/constants.py new file mode 100644 index 00000000..7d8b9c99 --- /dev/null +++ b/src/bedrock_agentcore/payments/constants.py @@ -0,0 +1,72 @@ +"""Constants for Bedrock AgentCore Payment SDK.""" + +from enum import Enum + + +class PaymentManagerStatus(Enum): + """Payment manager resource statuses.""" + + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + READY = "READY" + CREATE_FAILED = "CREATE_FAILED" + UPDATE_FAILED = "UPDATE_FAILED" + DELETE_FAILED = "DELETE_FAILED" + + +class PaymentConnectorStatus(Enum): + """Payment connector statuses.""" + + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + READY = "READY" + CREATE_FAILED = "CREATE_FAILED" + UPDATE_FAILED = "UPDATE_FAILED" + DELETE_FAILED = "DELETE_FAILED" + + +class PaymentConnectorType(Enum): + """Supported payment connector types.""" + + COINBASE_CDP = "CoinbaseCDP" + STRIPE_PRIVY = "StripePrivy" + + +class PaymentsAuthorizerType(Enum): + """Payment manager authorizer types.""" + + CUSTOM_JWT = "CUSTOM_JWT" + AWS_IAM = "AWS_IAM" + + +# Default constants +DEFAULT_MAX_RESULTS = 100 + +# Define network preference order (most preferred first) +NETWORK_PREFERENCES = [ + # Solan first as it is fast and low cost + "solana-mainnet", # Solana Mainnet (simplified identifier) + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", # Mainnet genesis hash (32 chars, CAIP-2) + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", # Mainnet full genesis hash (44 chars) + # Ethereum network + "eip155:8453", # Base mainnet (low fees) + "eip155:1", # Ethereum mainnet + "base", + "eip155:42161", # Arbitrum One + "eip155:10", # Optimism + "ethereum", + # SOLANA test network + "solana-devnet", # Solana Devnet (simplified identifier) + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", # Devnet genesis hash (32 chars, CAIP-2) + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", # Devnet full genesis hash (44 chars) + "solana-testnet", # Solana Testnet (simplified identifier) + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", # Testnet genesis hash (32 chars, CAIP-2) + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY", # Testnet full genesis hash (44 chars) + # Ethereum test + "sepolia", + "base-sepolia", + "eip155:84532", # Base Sepolia (testnet) + "eip155:11155111", # Ethereum Sepolia (Test) +] diff --git a/src/bedrock_agentcore/payments/integrations/__init__.py b/src/bedrock_agentcore/payments/integrations/__init__.py new file mode 100644 index 00000000..aa107aee --- /dev/null +++ b/src/bedrock_agentcore/payments/integrations/__init__.py @@ -0,0 +1,5 @@ +"""Payment plugin integrations for AgentCorePaymentsPlugin.""" + +from .strands import AgentCorePaymentsPlugin + +__all__ = ["AgentCorePaymentsPlugin"] diff --git a/src/bedrock_agentcore/payments/integrations/config.py b/src/bedrock_agentcore/payments/integrations/config.py new file mode 100644 index 00000000..86e05898 --- /dev/null +++ b/src/bedrock_agentcore/payments/integrations/config.py @@ -0,0 +1,108 @@ +"""Configuration for AgentCorePaymentsPlugin.""" + +from dataclasses import dataclass +from typing import Callable, List, Optional + + +@dataclass +class AgentCorePaymentsPluginConfig: + """Configuration for AgentCorePaymentsPlugin. + + Attributes: + payment_manager_arn: ARN of the payment manager service + region: AWS region for the payment manager + user_id: User ID for payment processing. Required for SigV4 auth. + Optional for bearer token auth (JWT identifies the user). + When set with bearer auth, propagated via X-Amzn-Bedrock-AgentCore-Payments-User-Id header. + payment_instrument_id: Optional payment instrument ID for the user. + Can be set later via update_payment_instrument_id(). + payment_session_id: Optional payment session ID for the transaction. + Can be set later via update_payment_session_id(). + network_preferences_config: Optional list of network CAIP2 identifiers + in order of preference. If not provided, defaults to the system default. + auto_payment: Whether to automatically process 402 payment requirements. + Defaults to True to maintain existing behavior. + max_interrupt_retries: Maximum number of interrupt retries per tool use. + Defaults to 5. Set to 0 to disable interrupt retries entirely (no interrupts will be raised). + agent_name: Optional agent name to propagate via the + X-Amzn-Bedrock-AgentCore-Payments-Agent-Name HTTP header on every + AgentCore payments data-plane API call. When set, the header is automatically injected + by PaymentManager and propagated for Payments. + bearer_token: Optional static JWT bearer token for OAuth/CUSTOM_JWT authentication. + When set, PaymentManager uses Bearer token auth instead of SigV4. + Mutually exclusive with token_provider. + token_provider: Optional callable that returns a fresh JWT bearer token string. + Called before each request to support token refresh. + Mutually exclusive with bearer_token. + payment_tool_allowlist: Optional list of tool names that are eligible for + automatic X402 payment processing. When None (default), all tools are + eligible (preserving existing behavior). When set, only tool calls whose + name appears in this list will trigger payment processing; all others are + skipped. + """ + + payment_manager_arn: str + user_id: Optional[str] = None + payment_instrument_id: Optional[str] = None + payment_session_id: Optional[str] = None + payment_connector_id: Optional[str] = None + region: Optional[str] = None + network_preferences_config: Optional[list[str]] = None + auto_payment: bool = True + max_interrupt_retries: int = 5 + agent_name: Optional[str] = None + bearer_token: Optional[str] = None + token_provider: Optional[Callable[[], str]] = None + payment_tool_allowlist: Optional[List[str]] = None + + def __post_init__(self) -> None: + """Validate configuration after initialization.""" + if not self.payment_manager_arn: + raise ValueError("payment_manager_arn is required") + + if not self.payment_manager_arn.startswith("arn:"): + raise ValueError(f"Invalid ARN format: {self.payment_manager_arn}") + + if self.bearer_token is not None and not isinstance(self.bearer_token, str): + raise ValueError(f"bearer_token must be a string, got {type(self.bearer_token).__name__}") + + if self.token_provider is not None and not callable(self.token_provider): + raise ValueError(f"token_provider must be callable, got {type(self.token_provider).__name__}") + + if self.user_id is not None and self.user_id and not self.user_id.strip(): + raise ValueError("user_id cannot be whitespace-only") + + if not self.user_id and self.bearer_token is None and self.token_provider is None: + raise ValueError("user_id is required for SigV4 auth (when bearer_token/token_provider not set)") + + if not isinstance(self.auto_payment, bool): + raise ValueError(f"auto_payment must be a boolean, got {type(self.auto_payment).__name__}") + + if self.bearer_token is not None and self.token_provider is not None: + raise ValueError("bearer_token and token_provider are mutually exclusive. Provide only one.") + + if self.payment_tool_allowlist is not None: + if not isinstance(self.payment_tool_allowlist, list): + raise ValueError("payment_tool_allowlist must be a list of tool name strings") + if not all(isinstance(t, str) for t in self.payment_tool_allowlist): + raise ValueError("All entries in payment_tool_allowlist must be strings") + + def update_payment_session_id(self, payment_session_id: str) -> None: + """Update the payment session ID. + + Args: + payment_session_id: New payment session ID for the transaction. + """ + if not payment_session_id: + raise ValueError("payment_session_id cannot be empty") + self.payment_session_id = payment_session_id + + def update_payment_instrument_id(self, payment_instrument_id: str) -> None: + """Update the payment instrument ID. + + Args: + payment_instrument_id: New payment instrument ID for the user. + """ + if not payment_instrument_id: + raise ValueError("payment_instrument_id cannot be empty") + self.payment_instrument_id = payment_instrument_id diff --git a/src/bedrock_agentcore/payments/integrations/handlers.py b/src/bedrock_agentcore/payments/integrations/handlers.py new file mode 100644 index 00000000..cdbb9f2e --- /dev/null +++ b/src/bedrock_agentcore/payments/integrations/handlers.py @@ -0,0 +1,638 @@ +"""Tool-specific handlers for X.402 payment processing. + +This module provides handlers for extracting payment information from different tool responses. +Each handler is responsible for parsing tool-specific response formats and extracting +HTTP status codes and X.402 payment requirements. +""" + +import ast +import json +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +class PaymentResponseHandler(ABC): + """Abstract base class for tool-specific payment response handlers.""" + + @abstractmethod + def extract_status_code(self, result: Any) -> Optional[int]: + """Extract HTTP status code from tool result. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Status code if found, None otherwise + """ + pass + + @abstractmethod + def extract_headers(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract HTTP headers from tool result. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Headers dictionary if found, None otherwise + """ + pass + + @abstractmethod + def extract_body(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract HTTP response body from tool result. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Body dictionary if found, None otherwise + """ + pass + + def validate_tool_input(self, tool_input: Any) -> bool: + """Validate that tool input is suitable for applying payment headers. + + Args: + tool_input: The tool input to validate + + Returns: + True if tool input is valid, False otherwise + """ + if not isinstance(tool_input, dict): + logger.warning("Tool input is not a dict, cannot add payment header") + return False + return True + + @abstractmethod + def apply_payment_header(self, tool_input: Dict[str, Any], payment_header: Dict[str, str]) -> bool: + """Apply payment header to tool input. + + Args: + tool_input: The tool input dictionary to modify + payment_header: The payment header to add (e.g., {"X-PAYMENT": "base64..."}) + + Returns: + True if header was successfully applied, False otherwise + """ + pass + + +class GenericPaymentHandler(PaymentResponseHandler): + """Generic handler for extracting payment information from tool responses. + + This handler extracts payment information from tool responses following the + 402 PaymentRequired Standard Response Structure Specification v1.0. + + Tools MUST return responses with the PAYMENT_REQUIRED marker containing: + { + "statusCode": 402, + "headers": dict, + "body": dict + } + + This handler supports: + - Standard PAYMENT_REQUIRED: marker format (spec-compliant) + - Direct dictionary responses with statusCode, headers, body + - Content arrays with text blocks (Anthropic format) + - Fallback extraction for backward compatibility + """ + + PAYMENT_REQUIRED_MARKER = "PAYMENT_REQUIRED: " + + @staticmethod + def _extract_content_array(result: Any) -> Optional[list]: + """Extract content array from result in various formats. + + Handles: + 1. A list of content blocks + 2. A dict with 'content' key + 3. An object with 'content' attribute + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Content array if found, None otherwise + """ + if isinstance(result, list): + return result + elif isinstance(result, dict): + return result.get("content") + elif hasattr(result, "content"): + return getattr(result, "content", None) + return None + + @staticmethod + def _extract_text_from_block(content_block: Any) -> Optional[str]: + """Extract text from a content block. + + Handles: + 1. Objects with 'text' attribute + 2. Dicts with 'text' key + + Args: + content_block: A single content block + + Returns: + Text string if found, None otherwise + """ + if hasattr(content_block, "text"): + return getattr(content_block, "text", None) + elif isinstance(content_block, dict) and "text" in content_block: + return content_block.get("text") + return None + + @staticmethod + def _parse_json_or_dict(value_str: str) -> Optional[Dict[str, Any]]: + """Parse a string as JSON. + + Args: + value_str: String to parse + + Returns: + Parsed dictionary if successful, None otherwise + """ + try: + result = json.loads(value_str) + if isinstance(result, dict): + return result + return None + except (json.JSONDecodeError, TypeError): + return None + + def _extract_payment_required_structure(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract payment_required structure from result. + + Follows the 402 PaymentRequired Standard Response Structure Specification v1.0. + Looks for the PAYMENT_REQUIRED: marker in content blocks. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Parsed payment_required dict if found, None otherwise + """ + try: + # Try to find PAYMENT_REQUIRED marker in content blocks + content = self._extract_content_array(result) + if content: + for content_block in content: + text_data = self._extract_text_from_block(content_block) + if isinstance(text_data, str) and text_data.startswith(self.PAYMENT_REQUIRED_MARKER): + # Extract JSON after marker + payment_json = text_data[len(self.PAYMENT_REQUIRED_MARKER) :] + parsed = self._parse_json_or_dict(payment_json) + if parsed and isinstance(parsed, dict): + logger.debug("Extracted payment_required structure from PAYMENT_REQUIRED marker") + return parsed + + return None + except Exception as e: + logger.debug("Error extracting payment_required structure: %s", str(e)) + return None + + def extract_status_code(self, result: Any) -> Optional[int]: + """Extract HTTP status code from tool result. + + Follows the 402 PaymentRequired Standard Response Structure Specification v1.0. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Status code if found, None otherwise + """ + try: + # Extract from payment_required structure (spec-compliant) + payment_required = self._extract_payment_required_structure(result) + if payment_required: + status_code = payment_required.get("statusCode") + if isinstance(status_code, int): + return status_code + + return None + except Exception as e: + logger.error("Error extracting status code from result: %s", str(e)) + return None + + def extract_headers(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract HTTP headers from tool result. + + Follows the 402 PaymentRequired Standard Response Structure Specification v1.0. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Headers dictionary if found, None otherwise + """ + try: + # Extract from payment_required structure (spec-compliant) + payment_required = self._extract_payment_required_structure(result) + if payment_required: + headers = payment_required.get("headers") + if isinstance(headers, dict): + return headers + + return None + except Exception as e: + logger.error("Error extracting headers from result: %s", str(e)) + return None + + def extract_body(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract HTTP response body from tool result. + + Follows the 402 PaymentRequired Standard Response Structure Specification v1.0. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Body dictionary if found, None otherwise + """ + try: + # Extract from payment_required structure + payment_required = self._extract_payment_required_structure(result) + if payment_required: + body = payment_required.get("body") + if isinstance(body, dict): + return body + + return None + except Exception as e: + logger.debug("Error extracting body from result: %s", str(e)) + return None + + def apply_payment_header(self, tool_input: Dict[str, Any], payment_header: Dict[str, str]) -> bool: + """Apply payment header to tool input. + + Adds the payment header to the headers dictionary in the tool input. + + Args: + tool_input: The tool input dictionary to modify + payment_header: The payment header to add (e.g., {"X-PAYMENT": "base64..."}) + + Returns: + True if header was successfully applied, False otherwise + """ + try: + # Ensure headers dict exists + if "headers" not in tool_input: + tool_input["headers"] = {} + + # Add payment header to the headers dict + if isinstance(tool_input["headers"], dict): + tool_input["headers"].update(payment_header) + logger.info("Added payment header to tool input headers: %s", list(payment_header.keys())) + return True + else: + logger.warning("Tool input headers is not a dict, cannot add payment header") + return False + except Exception as e: + logger.error("Error applying payment header to tool input: %s", str(e)) + return False + + +class MCPRequestPaymentHandler(PaymentResponseHandler): + """Handler for MCP Gateway proxy_tool_call responses. + + This handler extracts payment information from MCP Gateway responses where + x402 payment data is returned in the structuredContent field, and applies + payment headers inside parameters.headers for MCP-shaped tool inputs. + """ + + def validate_tool_input(self, tool_input: Any) -> bool: + """Validate that tool input has MCP Gateway shape suitable for payment headers. + + Args: + tool_input: The tool input to validate + + Returns: + True if tool input is valid MCP Gateway shape, False otherwise + """ + if not super().validate_tool_input(tool_input): + return False + if "toolName" not in tool_input or "parameters" not in tool_input: + logger.warning("Tool input does not have MCP Gateway shape (toolName + parameters)") + return False + if not isinstance(tool_input["parameters"], dict): + logger.warning("Tool input parameters is not a dict, cannot add payment header") + return False + return True + + @staticmethod + def _is_x402_payment_data(data: Any) -> bool: + """Check if a dictionary contains x402 payment required data. + + Args: + data: Dictionary to check + + Returns: + True if it contains x402Version and accepts fields + """ + return isinstance(data, dict) and "x402Version" in data and "accepts" in data + + def extract_status_code(self, result: Any) -> Optional[int]: + """Extract status code from MCP Gateway tool result. + + MCP Gateway returns HTTP 200 with x402 payment data embedded in + structuredContent, so there is no explicit 402 status code. We infer + 402 from the presence of x402Version + accepts fields. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + 402 if x402 payment data found, None otherwise + """ + try: + if isinstance(result, dict) and self._is_x402_payment_data(result.get("structuredContent")): + return 402 + return None + except Exception as e: + logger.error("Error extracting status code from MCP result: %s", str(e)) + return None + + def extract_headers(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract headers from MCP Gateway tool result. + + Returns content-type header when structuredContent contains x402 data. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Headers dict if x402 data found, None otherwise + """ + try: + if isinstance(result, dict) and self._is_x402_payment_data(result.get("structuredContent")): + return {"content-type": "application/json"} + return None + except Exception as e: + logger.error("Error extracting headers from MCP result: %s", str(e)) + return None + + def extract_body(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract body from MCP Gateway tool result. + + Returns the structuredContent dict directly when it contains x402 data. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + structuredContent dict if x402 data found, None otherwise + """ + try: + if isinstance(result, dict): + sc = result.get("structuredContent") + if self._is_x402_payment_data(sc): + return sc + return None + except Exception as e: + logger.debug("Error extracting body from MCP result: %s", str(e)) + return None + + def apply_payment_header(self, tool_input: Dict[str, Any], payment_header: Dict[str, str]) -> bool: + """Apply payment header to MCP Gateway tool input. + + Places headers inside parameters.headers and sets method to POST. + + Args: + tool_input: The tool input dictionary to modify + payment_header: The payment header to add + + Returns: + True if header was successfully applied, False otherwise + """ + try: + params = tool_input["parameters"] + if "headers" not in params: + params["headers"] = {} + if isinstance(params["headers"], dict): + params["headers"].update(payment_header) + logger.info("Added payment header to parameters.headers: %s", list(payment_header.keys())) + return True + + logger.warning("parameters.headers is not a dict, cannot add payment header") + return False + except Exception as e: + logger.error("Error applying payment header to MCP tool input: %s", str(e)) + return False + + +class HttpRequestPaymentHandler(GenericPaymentHandler): + """Handler for http_request tool responses. + + See: https://github.com/strands-agents/tools/blob/main/src/strands_tools/http_request.py + This handler supports both x402Version 1 and x402Version 2. + + This handler extends GenericPaymentHandler with http_request-specific optimizations, + adding support for legacy "Status Code:", "Headers:", "Body:" text block format. + + For x402 v2, the payment requirement is conveyed via a ``Payment-Required`` HTTP + response header whose value is a base64-encoded JSON payload. The http_request tool + includes ``Payment-Required`` in its important-headers filter, so the header appears + inside the ``Headers: {...}`` text block. This handler parses that text block + (which uses Python dict repr with single quotes) via ``ast.literal_eval`` as a + fallback when ``json.loads`` fails, ensuring the ``Payment-Required`` header value + is available for downstream extraction by ``PaymentManager._extract_x402_payload``. + """ + + @staticmethod + def _parse_headers_string(headers_str: str) -> Optional[Dict[str, Any]]: + """Parse a headers string that may be JSON or Python dict repr. + + The http_request tool formats headers with ``f"Headers: {headers_text}"`` + where *headers_text* is a Python dict. ``str(dict)`` produces single-quoted + keys/values which are not valid JSON but can be parsed by + ``ast.literal_eval``. + + Args: + headers_str: The string after ``Headers:`` prefix. + + Returns: + Parsed dictionary if successful, None otherwise. + """ + # Try JSON first (double-quoted keys) + try: + result = json.loads(headers_str) + if isinstance(result, dict): + return result + except (json.JSONDecodeError, TypeError): + pass + + # Fallback: Python dict repr (single-quoted keys from str(dict)) + try: + result = ast.literal_eval(headers_str) + if isinstance(result, dict): + return result + except (ValueError, SyntaxError): + pass + + return None + + def extract_status_code(self, result: Any) -> Optional[int]: + """Extract HTTP status code from http_request tool result. + + First tries spec-compliant format via parent class, then falls back to legacy format. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Status code if found, None otherwise + """ + try: + # Try spec-compliant format first (via parent class) + status_code = super().extract_status_code(result) + if status_code is not None: + return status_code + + # Fallback to legacy format + content = self._extract_content_array(result) + if not content: + return None + + for content_block in content: + text_data = self._extract_text_from_block(content_block) + if isinstance(text_data, str) and text_data.startswith("Status Code:"): + try: + status_code_str = text_data.replace("Status Code:", "").strip().split()[0] + return int(status_code_str) + except (ValueError, IndexError): + logger.error("Failed to parse status code: %s", status_code_str) + continue + + return None + except Exception as e: + logger.error("Error extracting status code from result: %s", str(e)) + return None + + def extract_headers(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract HTTP headers from http_request tool result. + + First tries spec-compliant format via parent class, then falls back to legacy format. + The legacy format supports both JSON and Python dict repr (single-quoted keys) + to handle the http_request tool's ``f"Headers: {headers_text}"`` output. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Headers dictionary if found, None otherwise + """ + try: + # Try spec-compliant format first (via parent class) + headers = super().extract_headers(result) + if headers is not None: + return headers + + # Fallback to legacy format + content = self._extract_content_array(result) + if not content: + return None + + for content_block in content: + text_data = self._extract_text_from_block(content_block) + if not isinstance(text_data, str): + continue + + # Check for "Headers: {...}" format + if text_data.startswith("Headers:"): + headers_str = text_data.replace("Headers:", "", 1).strip() + parsed = self._parse_headers_string(headers_str) + if parsed: + return parsed + logger.error("Failed to parse headers string: %s", headers_str) + continue + + return None + except Exception as e: + logger.error("Error extracting headers from result: %s", str(e)) + return None + + def extract_body(self, result: Any) -> Optional[Dict[str, Any]]: + """Extract HTTP response body from http_request tool result. + + First tries spec-compliant format via parent class, then falls back to legacy format. + + Args: + result: The tool result from AfterToolCallEvent + + Returns: + Body dictionary if found, None otherwise + """ + try: + # Try spec-compliant format first (via parent class) + body = super().extract_body(result) + if body is not None: + return body + + # Fallback to legacy format + content = self._extract_content_array(result) + if not content: + return None + + for content_block in content: + text_data = self._extract_text_from_block(content_block) + if not isinstance(text_data, str): + continue + + # Check for "Body: {...}" format + if text_data.startswith("Body:"): + body_str = text_data.replace("Body:", "", 1).strip() + try: + return json.loads(body_str) + except json.JSONDecodeError as e: + logger.debug("Failed to parse body as JSON: %s", str(e)) + continue + return None + except Exception as e: + logger.debug("Error extracting body from result: %s", str(e)) + return None + + +# Registry of tool handlers (name-based) +PAYMENT_HANDLERS: Dict[str, PaymentResponseHandler] = { + "http_request": HttpRequestPaymentHandler(), +} + +# Singleton handler instances +_GENERIC_HANDLER = GenericPaymentHandler() +_MCP_HANDLER = MCPRequestPaymentHandler() + + +def get_payment_handler(tool_name: str, tool_input: Dict[str, Any]) -> PaymentResponseHandler: + """Get the payment handler for a specific tool. + + This function implements a handler resolution strategy: + 1. First, try to get a tool-specific handler from the name-based registry + 2. Then, detect MCP Gateway shape from tool input (toolName + parameters keys) + 3. If not found, return the generic handler as a fallback + + Args: + tool_name: Name of the tool + tool_input: The tool input dictionary + + Returns: + PaymentResponseHandler (tool-specific, MCP, or generic fallback) + """ + # First, try to get tool-specific handler by name + handler = PAYMENT_HANDLERS.get(tool_name) + if handler: + logger.debug("Using tool-specific handler for tool: %s", tool_name) + return handler + + # Detect MCP Gateway shape from tool input + if isinstance(tool_input, dict) and "toolName" in tool_input and "parameters" in tool_input: + logger.debug("Using MCP handler for tool: %s (detected toolName+parameters shape)", tool_name) + return _MCP_HANDLER + + # Fall back to generic handler + logger.debug("Using generic handler for tool: %s", tool_name) + return _GENERIC_HANDLER diff --git a/src/bedrock_agentcore/payments/integrations/strands/README.md b/src/bedrock_agentcore/payments/integrations/strands/README.md new file mode 100644 index 00000000..ebabbaa3 --- /dev/null +++ b/src/bedrock_agentcore/payments/integrations/strands/README.md @@ -0,0 +1,682 @@ +# Strands AgentCore Payments Plugin + +The AgentCore Payments Plugin leverages Amazon Bedrock AgentCore Payments to provide automated payment processing +capabilities for Strands Agents. It supports the [x402 Payment Required](https://www.x402.org/) protocol, enabling +agents to automatically handle HTTP 402 responses by processing microtransaction payments to access paid APIs, +MCP servers, and premium content. + +## Overview + +- **Automatic x402 Payment Handling** — intercepts HTTP 402 responses from tools, processes payment requirements, and retries requests with payment headers +- **Payment Query Tools** — built-in tools for agents to query payment instruments and sessions at runtime +- **Multi-Protocol Support** — handles x402 v1 and v2 payment protocols +- **Multi-Handler Architecture** — supports generic tools, `http_request` tools, and MCP Gateway proxy tools +- **Interrupt-Based Error Handling** — raises Strands SDK interrupts on payment failures so the agent (or application) can respond dynamically +- **Configurable Auto-Payment** — enable or disable automatic payment processing per plugin instance + +## How It Works + +### x402 Payment Flow + +``` +┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ +│ Agent │────▶│ Tool │────▶│ Paid API │────▶│ 402 Response │ +│ │ └──────────┘ └──────────────┘ └────────────────┘ +│ │ │ +│ │ ┌──────────┐ ┌──────────────┐ │ +│ │◀────│ Tool │◀────│ Plugin │◀────────────┘ +│ (result)│ │ (retry) │ │ processes │ +└─────────┘ └──────────┘ │ payment │ + └──────────────┘ +``` + +1. Agent calls a tool (e.g., `http_request`) that hits a paid API +2. The API returns HTTP 402 with x402 payment requirements +3. The plugin's `after_tool_call` hook intercepts the 402 response +4. The plugin extracts payment requirements using the appropriate handler +5. The plugin calls `PaymentManager.generate_payment_header()` to process the payment +6. The payment header is applied to the tool input +7. The tool is automatically retried with the payment credentials +8. The API returns a successful response + +## Installation + +```bash +pip install 'bedrock-agentcore[strands-agents]' +``` + +Or to develop locally: + +```bash +git clone https://github.com/aws/bedrock-agentcore-sdk-python.git +cd bedrock-agentcore-sdk-python +uv sync +source .venv/bin/activate +``` + +## Quick Start + +Once your payment resources are ready, wire up the plugin: + +```python +import os +from strands import Agent +from strands_tools import http_request +from bedrock_agentcore.payments.integrations.strands import ( + AgentCorePaymentsPlugin, + AgentCorePaymentsPluginConfig, +) + +plugin = AgentCorePaymentsPlugin(config=AgentCorePaymentsPluginConfig( + payment_manager_arn=os.environ["PAYMENT_MANAGER_ARN"], + user_id="test-user-123", + payment_instrument_id=os.environ["PAYMENT_INSTRUMENT_ID"], + payment_session_id=os.environ["PAYMENT_SESSION_ID"], + region="us-east-1", +)) + +agent = Agent( + system_prompt="You are a helpful assistant that can access paid APIs.", + tools=[http_request], + plugins=[plugin], +) + +# 402 responses are automatically handled +agent("Fetch a joke from https://premium-api.example.com/joke") +``` + +The plugin intercepts x402 payment requests automatically, processes the payment, and retries the +request with payment proof for the agent. + +--- + +## Built-in Agent Tools + +The plugin registers three tools that agents can use to query payment information at runtime: + +| Tool | Description | +|------|-------------| +| `get_payment_instrument` | Retrieve details about a specific payment instrument | +| `list_payment_instruments` | List all payment instruments for a user | +| `get_payment_session` | Retrieve details about a payment session (budget, status, expiry) | + +All three tools accept an optional `user_id` parameter. If not provided, the tool falls back to the +`user_id` configured in the plugin config. + +These tools enable agents to make informed decisions about payment methods and payment limits during +conversations. + +--- + +## Prerequisites + +Before using the plugin, you need: + +1. **A Payment Manager** — created via the `PaymentClient` control plane API or AWS Console +2. **A Payment Connector** — linked to a credential provider for a supported payment vendor +3. **A Payment Instrument** — a user's registered payment instrument (funded and signing-enabled) +4. **A Payment Session** — a time-bounded session with optional payment limits + +For more details on the Payments SDK, see the [Payments SDK README](../../README.md). + +## Setup + +### Creating Payment Manager and Connector + +> **One-time setup:** The payment resource creation shown below is typically done once, separately +> from your agent application. In production, you would create the payment resources through the +> AWS Console or a separate setup script using the AgentCore SDK, then use `PaymentManagerArn` and +> `PaymentConnectorId` in your agent application. + +```python +import os +from bedrock_agentcore.payments.client import PaymentClient + +# This is typically done once, separately from your agent application +payment_client = PaymentClient(region_name="us-east-1") + +response = payment_client.create_payment_manager_with_connector( + payment_manager_name="AgentCorePaymentManager", + payment_manager_description="Payment Manager for Agent Core", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config={ + "name": "agent-core-connector", + "description": "Payment Connector for Agent Core", + "payment_credential_provider_config": { + "name": "agent-core-provider", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "", + "api_key_secret": "", + "wallet_secret": "", + }, + }, + }, + wait_for_ready=True, + max_wait=300, + poll_interval=5, +) + +# Export for reuse in your agent application +payment_manager_arn = response["paymentManager"]["paymentManagerArn"] +payment_connector_id = response["paymentConnector"]["paymentConnectorId"] +os.environ["PAYMENT_MANAGER_ARN"] = payment_manager_arn +os.environ["PAYMENT_CONNECTOR_ID"] = payment_connector_id +print(f"Payment Manager ARN: {payment_manager_arn}") +print(f"Payment Connector ID: {payment_connector_id}") +``` + +The `wait_for_ready=True` parameter causes the method to poll until all resources reach READY status. +If any step fails, previously created resources are automatically rolled back. + +### Creating a Payment Instrument + +Create a payment instrument for a given user to process payments. Below is an example creating an +Ethereum chain-compatible embedded crypto wallet: + +```python +instrument = manager.create_payment_instrument( + user_id="test-user-123", + payment_connector_id=os.environ["PAYMENT_CONNECTOR_ID"], + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={ + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "linkedAccounts": [ + {"email": {"emailAddress": "email@example.com"}} + ], + } + }, +) + +payment_instrument_id = instrument["paymentInstrumentId"] +os.environ["PAYMENT_INSTRUMENT_ID"] = payment_instrument_id +print(f"Payment Instrument ID: {payment_instrument_id}") +``` + +For Solana-compatible chains, use `"SOLANA"` for the network input. Once created, the instrument +must be funded and permission granted for signing before the agent can use it. These are end-user +actions that should be completed before using the payment instrument in your agent. + +If you are using Coinbase as wallet provider, you'll receive a `redirectUrl` in the payment +instrument response, pointing to the Coinbase-hosted WalletHub. Redirect your user there to grant +signing permission and transfer funds. + +For Stripe, developers use a provided URL template to host a frontend page where end users can take +the same actions. + +### Creating a Payment Session + +You also need a payment session before processing payments: + +```python +session = manager.create_payment_session( + user_id="test-user-123", + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, + expiry_time_in_minutes=60, +) + +payment_session_id = session["paymentSessionId"] +os.environ["PAYMENT_SESSION_ID"] = payment_session_id +print(f"Payment Session ID: {payment_session_id}") +``` + +--- + +## Advanced Usage + +### Dynamic Instrument/Session Selection + +Dynamic Instrument/Session selection is useful when the payment instrument and the payment session aren’t known upfront and need to be resolved dynamically during execution. You can initialize the plugin without a payment instrument or payment session, then set them later based on runtime logic or agent interrupts: + +```python +config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-abc123", + user_id="user-123", + region="us-east-1", + # payment_instrument_id and payment_session_id omitted +) + +plugin = AgentCorePaymentsPlugin(config=config) +agent = Agent( + system_prompt="You are a helpful assistant.", + tools=[http_request], + plugins=[plugin], +) + +# Later, update configuration dynamically +config.update_payment_instrument_id("payment-instrument-xyz789") +config.update_payment_session_id("payment-session-def456") +``` + +When the plugin encounters a 402 response without these values configured, it raises a +`PaymentInstrumentConfigurationRequired` or `PaymentSessionConfigurationRequired` interrupt +that your application can handle. + +--- + +### Handling Payment Interrupts + +When payment processing fails, the plugin stores the failure and raises an interrupt. Your application +should handle these interrupts to provide autonomous functionality: + +```python +result = agent("Access the premium endpoint at https://api.example.com/premium") + +while result.stop_reason == "interrupt": + responses = [] + for interrupt in result.interrupts: + reason = interrupt.reason + match reason.get("exceptionType"): + case "PaymentInstrumentConfigurationRequired": + plugin.config.update_payment_instrument_id("payment-instrument-new123") + msg = "Payment instrument configured. Please retry." + case "PaymentSessionConfigurationRequired": + plugin.config.update_payment_session_id("payment-session-new456") + msg = "Payment session configured. Please retry." + case _: + msg = f"Payment failed: {reason.get('exceptionMessage')}" + + responses.append({"interruptResponse": {"interruptId": interrupt.id, "response": msg}}) + + result = agent(responses) +``` + +--- + +### Disabling Auto-Payment + +If you want the plugin to only provide payment query tools without automatic 402 handling: + +```python +config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-abc123", + user_id="user-123", + region="us-east-1", + auto_payment=False, # Disable automatic 402 processing +) +``` + +--- + +### Network Preferences + +You can specify preferred blockchain networks for payment processing: + +```python +config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-abc123", + user_id="user-123", + payment_instrument_id="payment-instrument-xyz789", + payment_session_id="payment-session-def456", + region="us-east-1", + network_preferences_config=["eip155:8453", "base-sepolia", "solana-mainnet"], +) +``` + +If not specified, the system uses a default preference order prioritizing Solana mainnet and Base +(Ethereum L2) for low transaction fees. + +--- + +### Payment Tool Allowlist + +You can restrict which tools are eligible for automatic x402 payment processing using the +`payment_tool_allowlist` parameter. When set, only tool calls whose name appears in this list +will trigger payment processing; all others are skipped: + +```python +config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-abc123", + user_id="user-123", + payment_instrument_id="payment-instrument-xyz789", + payment_session_id="payment-session-def456", + region="us-east-1", + payment_tool_allowlist=["http_request", "mcp_proxy_tool_call"], +) +``` + +When `payment_tool_allowlist` is `None` (default), all tools are eligible for payment processing. + +--- + +### Using CUSTOM_JWT (Bearer Token) Authentication + +When your payment manager uses `CUSTOM_JWT` authorizer type, configure the plugin with a bearer +token or token provider instead of SigV4 credentials. The service derives the `userId` from the +JWT `sub` claim, so `user_id` is optional. + +#### Static Bearer Token + +```python +from bedrock_agentcore.payments.integrations.strands import ( + AgentCorePaymentsPlugin, + AgentCorePaymentsPluginConfig, +) + +config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-jwt", + bearer_token="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + # user_id is optional with bearer auth — derived from JWT 'sub' claim + payment_instrument_id="payment-instrument-xyz789", + payment_session_id="payment-session-def456", + region="us-east-1", +) + +plugin = AgentCorePaymentsPlugin(config=config) +agent = Agent( + system_prompt="You are a helpful assistant that can access paid APIs.", + tools=[http_request], + plugins=[plugin], +) +``` + +#### Dynamic Token Provider (Recommended for Production) + +Use a callable token provider for automatic token refresh before each request: + +```python +import requests + +def get_fresh_token() -> str: + """Fetch a fresh JWT from your identity provider.""" + resp = requests.post( + "https://your-domain.auth.us-east-1.amazoncognito.com/oauth2/token", + data={ + "grant_type": "client_credentials", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + return resp.json()["access_token"] + +config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-jwt", + token_provider=get_fresh_token, # Called before each request + payment_instrument_id="payment-instrument-xyz789", + payment_session_id="payment-session-def456", + region="us-east-1", +) + +plugin = AgentCorePaymentsPlugin(config=config) +agent = Agent( + system_prompt="You are a helpful assistant that can access paid APIs.", + tools=[http_request], + plugins=[plugin], +) + +# 402 responses are handled automatically using JWT auth +agent("Fetch data from https://premium-api.example.com/data") +``` + +> **Note:** `bearer_token` and `token_provider` are mutually exclusive. Use `token_provider` in +> production for automatic token refresh. Use `bearer_token` for quick testing with a known token. + +--- + +## Configuration Reference + +### AgentCorePaymentsPluginConfig Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `payment_manager_arn` | `str` | Yes | — | ARN of the Bedrock AgentCore Payment Manager resource | +| `user_id` | `Optional[str]` | Conditional | `None` | Unique identifier for the user. Required for SigV4 auth; optional with bearer token auth (derived from JWT `sub` claim) | +| `payment_instrument_id` | `Optional[str]` | No | `None` | Payment instrument ID. Can be set later via `update_payment_instrument_id()` | +| `payment_session_id` | `Optional[str]` | No | `None` | Payment session ID. Can be set later via `update_payment_session_id()` | +| `region` | `Optional[str]` | No | `None` | AWS region for the payment manager | +| `network_preferences_config` | `Optional[list[str]]` | No | `None` | List of network CAIP-2 identifiers in order of preference | +| `auto_payment` | `bool` | No | `True` | Whether to automatically process 402 payment requirements | +| `max_interrupt_retries` | `int` | No | `5` | Maximum interrupt retries per tool use. Set to 0 to disable interrupts | +| `agent_name` | `Optional[str]` | No | `None` | Agent name propagated via HTTP header on API calls | +| `bearer_token` | `Optional[str]` | No | `None` | Static JWT bearer token for CUSTOM_JWT auth. Mutually exclusive with `token_provider` | +| `token_provider` | `Optional[Callable[[], str]]` | No | `None` | Callable returning a fresh JWT token string. Mutually exclusive with `bearer_token` | +| `payment_tool_allowlist` | `Optional[List[str]]` | No | `None` | List of tool names eligible for automatic payment processing. When `None`, all tools are eligible | + +--- + +## End-to-End Examples + +### Calling Coinbase Bazaar Tools via MCP Client + +This example shows automatic 402 payment handling with Strands and a direct MCP connection to Coinbase Bazaar. + +**Environment Variables:** + +```bash +PAYMENT_MANAGER_ARN=arn:aws:bedrock-agentcore:::payment-manager/ +USER_ID= +PAYMENT_INSTRUMENT_ID= +PAYMENT_SESSION_ID= +AWS_REGION=us-west-2 +MODEL_ID=us.anthropic.claude-sonnet-4-20250514-v1:0 +``` + +**Agent Code:** + +```python +import os +from dotenv import load_dotenv +load_dotenv() + +from datetime import timedelta +from mcp.client.streamable_http import streamablehttp_client +from strands import Agent +from strands.models import BedrockModel +from strands.tools.mcp.mcp_client import MCPClient +from bedrock_agentcore.payments.integrations.strands import ( + AgentCorePaymentsPlugin, + AgentCorePaymentsPluginConfig, +) + +MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-sonnet-4-20250514-v1:0") +PAYMENT_MANAGER_ARN = os.environ["PAYMENT_MANAGER_ARN"] +USER_ID = os.environ["USER_ID"] +PAYMENT_INSTRUMENT_ID = os.environ["PAYMENT_INSTRUMENT_ID"] +PAYMENT_SESSION_ID = os.environ["PAYMENT_SESSION_ID"] +REGION = os.environ.get("AWS_REGION", "us-west-2") + +COINBASE_BAZAAR_URL = "https://api.cdp.coinbase.com/platform/v2/x402/discovery/mcp" + +def main(): + # 1. Connect to Coinbase Bazaar MCP server + mcp_client = MCPClient(lambda: streamablehttp_client( + COINBASE_BAZAAR_URL, + timeout=timedelta(seconds=120), + )) + + # 2. Configure payment plugin + payment_plugin = AgentCorePaymentsPlugin(config=AgentCorePaymentsPluginConfig( + payment_manager_arn=PAYMENT_MANAGER_ARN, + user_id=USER_ID, + payment_instrument_id=PAYMENT_INSTRUMENT_ID, + payment_session_id=PAYMENT_SESSION_ID, + region=REGION, + )) + + # 3. Create agent — plugin handles 402 payments automatically + with mcp_client: + agent = Agent( + model=BedrockModel(model_id=MODEL_ID, streaming=True), + tools=mcp_client.list_tools_sync(), + plugins=[payment_plugin], + ) + result = agent("Get me the latest crypto news") + print(result.message) + +if __name__ == "__main__": + main() +``` + +### Calling Coinbase Bazaar Tools via AgentCore Gateway + +This example demonstrates how to leverage AgentCore Gateway to interact with Coinbase Bazaar MCP tools. + +**Prerequisite:** Add "Coinbase x402 Bazaar" as a target in your Gateway. + +**Environment Variables:** + +```bash +GATEWAY_URL=https://.gateway.bedrock-agentcore..amazonaws.com/mcp +CLIENT_ID= +CLIENT_SECRET= +TOKEN_URL=https://.auth..amazoncognito.com/oauth2/token +PAYMENT_MANAGER_ARN=arn:aws:bedrock-agentcore:::payment-manager/ +USER_ID= +PAYMENT_INSTRUMENT_ID= +PAYMENT_SESSION_ID= +AWS_REGION=us-west-2 +MODEL_ID=us.anthropic.claude-sonnet-4-20250514-v1:0 +``` + +**Agent Code:** + +```python +import os +from dotenv import load_dotenv +load_dotenv() + +from datetime import timedelta +import requests as http_requests +from mcp.client.streamable_http import streamablehttp_client +from strands import Agent +from strands.models import BedrockModel +from strands.tools.mcp.mcp_client import MCPClient +from bedrock_agentcore.payments.integrations.strands import ( + AgentCorePaymentsPlugin, + AgentCorePaymentsPluginConfig, +) + +GATEWAY_URL = os.environ["GATEWAY_URL"] +CLIENT_ID = os.environ["CLIENT_ID"] +CLIENT_SECRET = os.environ["CLIENT_SECRET"] +TOKEN_URL = os.environ["TOKEN_URL"] +MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-sonnet-4-20250514-v1:0") +PAYMENT_MANAGER_ARN = os.environ["PAYMENT_MANAGER_ARN"] +USER_ID = os.environ["USER_ID"] +PAYMENT_INSTRUMENT_ID = os.environ["PAYMENT_INSTRUMENT_ID"] +PAYMENT_SESSION_ID = os.environ["PAYMENT_SESSION_ID"] +REGION = os.environ.get("AWS_REGION", "us-west-2") + +def get_oauth_token(): + resp = http_requests.post(TOKEN_URL, data={ + "grant_type": "client_credentials", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, headers={"Content-Type": "application/x-www-form-urlencoded"}) + resp.raise_for_status() + return resp.json()["access_token"] + +def main(): + token = get_oauth_token() + + # 1. Connect to Gateway MCP server + mcp_client = MCPClient(lambda: streamablehttp_client( + GATEWAY_URL, + headers={"Authorization": f"Bearer {token}"}, + timeout=timedelta(seconds=120), + )) + + # 2. Configure payment plugin + payment_plugin = AgentCorePaymentsPlugin(config=AgentCorePaymentsPluginConfig( + payment_manager_arn=PAYMENT_MANAGER_ARN, + user_id=USER_ID, + payment_instrument_id=PAYMENT_INSTRUMENT_ID, + payment_session_id=PAYMENT_SESSION_ID, + region=REGION, + )) + + # 3. Create agent — plugin handles 402 payments automatically + with mcp_client: + agent = Agent( + model=BedrockModel(model_id=MODEL_ID, streaming=True), + tools=mcp_client.list_tools_sync(), + plugins=[payment_plugin], + ) + result = agent("Get me the latest crypto news") + print(result.message) + +if __name__ == "__main__": + main() +``` + +The developer writes no payment logic. The plugin intercepts 402 responses, generates payment proofs +via AgentCore, and retries the tool call automatically. + +--- + +## Supported Response Formats + +The plugin supports multiple tool response formats: + +- **Spec-compliant marker format**: Tools return `PAYMENT_REQUIRED: {json}` in content blocks +- **Legacy http_request format**: `Status Code:`, `Headers:`, `Body:` text blocks +- **MCP Gateway format**: `structuredContent` with `x402Version` and `accepts` fields + +### Payment Handler Resolution + +The plugin selects the appropriate handler based on tool characteristics: + +| Strategy | Condition | Handler | +|----------|-----------|---------| +| Name-based registry | `http_request` tool | `HttpRequestPaymentHandler` | +| Shape detection | Tools with `toolName` + `parameters` input | `MCPRequestPaymentHandler` | +| Generic fallback | All other tools | `GenericPaymentHandler` | + +--- + +## Important Notes + +### Payment Session Limits + +Payment sessions have configurable spending limits and expiry times (15–480 minutes). Monitor session +budgets using the `get_payment_session` tool to avoid `InsufficientBudget` errors. + +### Retry Limits and Post-Payment Failure Detection + +The plugin enforces a maximum of 3 payment retry attempts per tool use and a configurable maximum of +5 interrupt retries. These limits are checked independently — interrupt retry limits do not gate +402 payment processing. + +Additionally, the plugin detects **post-payment failures**: if a 402 response is received *after* +a payment retry was already attempted (e.g., due to insufficient balance or invalid signature), +the plugin propagates the failure as an interrupt instead of retrying again. This prevents infinite +loops where the plugin keeps signing and retrying against a server that rejects the payment for +non-retryable reasons. + +### Custom Tools + +To make your custom tools compatible with automatic payment processing, return responses using the +spec-compliant `PAYMENT_REQUIRED:` marker format: + +```python +import json + +payment_required = { + "statusCode": 402, + "headers": response_headers, + "body": response_body, +} + +return { + "status": "error", + "content": [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_required)}"}], +} +``` + +### Thread Safety + +`AgentCorePaymentsPlugin` is not thread-safe. Create separate plugin instances for concurrent agents. + +### x402 Protocol Support + +- **v1**: Payment header returned as `X-PAYMENT` (base64-encoded JSON) +- **v2**: Payment header returned as `PAYMENT-SIGNATURE` (base64-encoded JSON with resource and extension fields) + +### Supported Blockchain Networks + +- **Ethereum**: Base, Ethereum mainnet, Arbitrum, Optimism, Sepolia testnets +- **Solana**: Mainnet, Devnet, Testnet (identified by CAIP-2 genesis hashes or simplified names) diff --git a/src/bedrock_agentcore/payments/integrations/strands/__init__.py b/src/bedrock_agentcore/payments/integrations/strands/__init__.py new file mode 100644 index 00000000..e37c0c62 --- /dev/null +++ b/src/bedrock_agentcore/payments/integrations/strands/__init__.py @@ -0,0 +1,6 @@ +"""Strands Agents framework integration for AgentCorePaymentsPlugin.""" + +from ..config import AgentCorePaymentsPluginConfig +from .plugin import AgentCorePaymentsPlugin + +__all__ = ["AgentCorePaymentsPlugin", "AgentCorePaymentsPluginConfig"] diff --git a/src/bedrock_agentcore/payments/integrations/strands/plugin.py b/src/bedrock_agentcore/payments/integrations/strands/plugin.py new file mode 100644 index 00000000..edc8372d --- /dev/null +++ b/src/bedrock_agentcore/payments/integrations/strands/plugin.py @@ -0,0 +1,753 @@ +"""AgentCorePaymentsPlugin for Strands Agents framework.""" + +import logging +import uuid +from typing import Any, Dict, Optional + +from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent +from strands.plugins import Plugin, hook +from strands.tools import tool + +from bedrock_agentcore.payments.manager import ( + PaymentError, + PaymentInstrumentConfigurationRequired, + PaymentManager, + PaymentSessionConfigurationRequired, +) + +from ..config import AgentCorePaymentsPluginConfig +from ..handlers import get_payment_handler +from .tools import validate_required_params + +logger = logging.getLogger(__name__) + + +class AgentCorePaymentsPlugin(Plugin): + """Plugin for handling X402 payment requirements and providing payment tools in Strands Agents. + + This plugin provides three tools for querying payment information: + - getPaymentInstrument: Retrieve details about a specific payment instrument + - listPaymentInstruments: List all payment instruments for a user + - getPaymentSession: Retrieve details about a specific payment session + + The plugin also intercepts tool calls and responses to handle HTTP 402 Payment Required + responses by processing X402 payment requirements and retrying requests with + appropriate payment credentials. Payment processing is controlled by the auto_payment + configuration flag (default: True). + + Attributes: + name: Plugin identifier ("agent-core-payments-plugin") + MAX_PAYMENT_RETRIES: Maximum number of payment retry attempts per tool use (3) + """ + + name = "agent-core-payments-plugin" + MAX_PAYMENT_RETRIES = 3 # Maximum number of payment retry attempts per tool use + + def __init__(self, config: AgentCorePaymentsPluginConfig): + """Initialize the payment plugin. + + Args: + config: Configuration for the payment plugin + + Raises: + ValueError: If config is invalid + """ + super().__init__() + self.config = config + self.payment_manager: Optional[PaymentManager] = None + logger.info("Initialized AgentCorePaymentsPlugin") + + def init_agent(self, agent) -> None: + """Initialize plugin with agent. + + This method initializes the PaymentManager with the configured ARN and region. + + Args: + agent: The Strands Agent instance + + Raises: + RuntimeError: If PaymentManager initialization fails + """ + logger.info( + "Initializing AgentCorePaymentsPlugin with agent - ARN: %s, Region: %s", + self.config.payment_manager_arn, + self.config.region or "default", + ) + + try: + # Initialize PaymentManager + self.payment_manager = PaymentManager( + payment_manager_arn=self.config.payment_manager_arn, + region_name=self.config.region, + agent_name=self.config.agent_name, + bearer_token=self.config.bearer_token, + token_provider=self.config.token_provider, + ) + logger.info("PaymentManager initialized successfully") + except Exception as e: + logger.error("Failed to initialize PaymentManager: %s", str(e)) + raise RuntimeError(f"Failed to initialize PaymentManager: {str(e)}") from e + + @hook + def before_tool_call(self, event: BeforeToolCallEvent) -> None: + """Handle before tool call event. + + This checks for any stored payment failures from the previous tool call + and raises an interrupt to notify the agent. + + Args: + event: The before tool call event + """ + logger.debug("BeforeToolCallEvent: tool=%s", event.tool_use.get("name", "unknown")) + + # Check for any stored payment failures from previous tool calls + for key, value in list(event.invocation_state.items()): + if key.startswith("payment_failure_"): + # Found a payment failure from a previous tool call + failure_info = value + tool_use_id = failure_info.get("toolUseId", "unknown") + + # Check interrupt retry limit using agent.state + if self._check_interrupt_retry_limit(event.agent, tool_use_id): + logger.warning( + "Interrupt retry limit (%d) reached for tool %s, skipping interrupt", + self.config.max_interrupt_retries, + tool_use_id, + ) + del event.invocation_state[key] + return + + self._increment_interrupt_retry_count(event.agent, tool_use_id) + + interrupt_name = f"payment-failure-{tool_use_id}" + str(uuid.uuid4()) + interrupt_reason = failure_info + + logger.info( + "Raising payment failure interrupt from stored state: %s and interrupt_reason: %s", + interrupt_name, + interrupt_reason, + ) + event.interrupt(interrupt_name, reason=interrupt_reason) + + # Remove the stored failure after raising interrupt + del event.invocation_state[key] + return + + @hook + def after_tool_call(self, event: AfterToolCallEvent) -> None: + """Handle after tool call event. + + This is where we intercept 402 responses and process payment requirements. + Payment processing is controlled by the auto_payment configuration flag. + + Args: + event: The after tool call event + """ + logger.debug("AfterToolCallEvent: tool=%s", event.tool_use.get("name", "unknown")) + + # Check if auto_payment is disabled + if not self.config.auto_payment: + logger.debug( + "auto_payment is disabled, skipping X.402 payment processing for tool: %s", + event.tool_use.get("name", "unknown"), + ) + return + + # Check if tool is in the payment allowlist + if self.config.payment_tool_allowlist is not None: + tool_name = event.tool_use.get("name", "unknown") + if tool_name not in self.config.payment_tool_allowlist: + logger.debug( + "Tool '%s' is not in payment_tool_allowlist, skipping payment processing", + tool_name, + ) + return + + # Check if payment retry limit has been reached + if self._check_payment_retry_limit(event): + logger.warning("Payment processing retry limit has been reached. Processing skipped.") + return + + # Check if response is a 402 Payment Required + if not hasattr(event, "result") or event.result is None: + return + + logger.debug("event.result: %s", event.result) + + # Note: get_payment_handler always returns a handler (tool-specific or generic fallback). + # The generic handler will attempt to extract payment information from any tool result + # that contains a PAYMENT_REQUIRED marker or HTTP-like response structure. + # When payment_tool_allowlist is set, only allowlisted tools reach this point. + tool_name = event.tool_use.get("name", "unknown") + tool_input = event.tool_use.get("input", {}) + handler = get_payment_handler(tool_name, tool_input) + + try: + # Extract status code from the result using the handler + status_code = handler.extract_status_code(event.result) + + if status_code != 402: + logger.debug("Response status code is %s, not 402, no payment processing needed.", status_code) + return + + logger.info("Detected 402 Payment Required response from tool: %s", event.tool_use.get("name", "unknown")) + + # Increment retry count in invocation state + self._increment_payment_retry_count(event) + + # Build payment_required_request dict using handler methods + headers = handler.extract_headers(event.result) + body = handler.extract_body(event.result) + payment_required_request = { + "statusCode": status_code, + "headers": headers or {}, + "body": body or {}, + } + + # If we already retried with payment credentials and still got a 402, + # this is a post-payment failure (e.g., insufficient balance, invalid signature). + # Propagate as an interrupt instead of retrying again to avoid infinite loops. + if self._is_post_payment_failure(event, body): + logger.warning( + "Received 402 after payment retry for tool %s — treating as payment failure", + event.tool_use.get("name", "unknown"), + ) + error_msg = self._extract_payment_error_message(body) + self._store_payment_failure_state(event, PaymentError(f"Payment failed after retry: {error_msg}")) + return + + # Validate tool input before processing payment + tool_input = event.tool_use.get("input", {}) + if not handler.validate_tool_input(tool_input): + logger.error("Tool input validation failed, cannot apply payment header") + self._store_payment_failure_state(event, Exception("Tool input validation failed")) + return + + # Process payment through PaymentManager.generate_payment_header + payment_header_dict = self._process_payment_required_request(payment_required_request) + + # Apply payment header to tool input using the handler + if not handler.apply_payment_header(tool_input, payment_header_dict): + logger.error("Failed to apply payment header to tool input") + self._store_payment_failure_state(event, Exception("Failed to apply payment header")) + return + + # Set retry flag to re-execute the tool with payment credentials. + # Do NOT reset the payment retry counter here — it must persist across + # retries so that _is_post_payment_failure and _check_payment_retry_limit + # can detect repeated 402s and break the loop. + event.retry = True + self._reset_interrupt_retry_count(event) + logger.info("Set retry flag to re-execute tool with payment credentials") + + except (PaymentInstrumentConfigurationRequired, PaymentSessionConfigurationRequired) as e: + logger.error("Payment configuration error (not retryable): %s", str(e)) + self._store_payment_failure_state(event, e) + return + except PaymentError as e: + logger.error("Payment processing failed: %s", str(e)) + self._store_payment_failure_state(event, e) + return + except Exception as e: + logger.error("Unexpected error during payment processing: %s", str(e)) + self._store_payment_failure_state(event, e) + return + + def _check_payment_retry_limit(self, event: AfterToolCallEvent) -> bool: + """Check if the payment retry limit has been reached for this tool use. + + Only checks the payment-specific retry counter (invocation_state). + Interrupt retry limits are checked separately in before_tool_call and + do not gate 402 payment processing. + + Args: + event: The after tool call event + + Returns: + True if the payment retry limit has been reached, False otherwise + """ + tool_use_id = event.tool_use.get("toolUseId", "unknown") + payment_retry_key = f"payment_retry_count_{tool_use_id}" + retry_count = event.invocation_state.get(payment_retry_key, 0) + + if retry_count >= self.MAX_PAYMENT_RETRIES: + logger.warning( + "Tool use %s has reached maximum payment retry attempts (%d), not retrying", + tool_use_id, + self.MAX_PAYMENT_RETRIES, + ) + return True + + return False + + def _increment_payment_retry_count(self, event: AfterToolCallEvent) -> None: + """Increment the payment retry count for this tool use. + + Args: + event: The after tool call event + """ + tool_use_id = event.tool_use.get("toolUseId", "unknown") + payment_retry_key = f"payment_retry_count_{tool_use_id}" + retry_count = event.invocation_state.get(payment_retry_key, 0) + + event.invocation_state[payment_retry_key] = retry_count + 1 + logger.info( + "Payment retry attempt %d/%d for tool use %s", retry_count + 1, self.MAX_PAYMENT_RETRIES, tool_use_id + ) + + def _is_post_payment_failure(self, event: AfterToolCallEvent, body: Optional[Dict[str, Any]]) -> bool: + """Check if this 402 response is a failure after we already retried with payment credentials. + + A post-payment failure occurs when: + 1. We already sent a payment header (retry count > 0 before this increment), AND + 2. The 402 response body contains an error that is NOT the initial "payment required" + (e.g., "invalid_exact_evm_insufficient_balance", "payment_rejected", etc.) + + This prevents infinite loops where the plugin keeps signing and retrying + against a server that keeps rejecting the payment for non-retryable reasons. + + Args: + event: The after tool call event + body: The extracted response body (may be None) + + Returns: + True if this is a post-payment failure that should be propagated as an interrupt + """ + tool_use_id = event.tool_use.get("toolUseId", "unknown") + payment_retry_key = f"payment_retry_count_{tool_use_id}" + # retry count was already incremented before this check, so > 1 means + # we already attempted at least one payment retry + retry_count = event.invocation_state.get(payment_retry_key, 0) + + if retry_count <= 1: + return False + + # If the body contains an error field that is NOT the initial "payment required", + # this is a post-payment failure + if body and isinstance(body, dict): + error = body.get("error", "") + if isinstance(error, str) and error.lower() not in ("", "payment required"): + logger.info( + "Post-payment failure detected for tool %s: error=%s (retry_count=%d)", + tool_use_id, + error, + retry_count, + ) + return True + + return False + + @staticmethod + def _extract_payment_error_message(body: Optional[Dict[str, Any]]) -> str: + """Extract a human-readable error message from a 402 response body. + + Args: + body: The extracted response body (may be None) + + Returns: + Error message string, or "unknown error" if not extractable + """ + if body and isinstance(body, dict): + error = body.get("error") + if isinstance(error, str) and error: + return error + return "unknown error" + + def _store_payment_failure_state(self, event: AfterToolCallEvent, exception: Exception) -> None: + """Store payment failure information in invocation state for agent to handle. + + Args: + event: The after tool call event + exception: The exception that caused the payment failure + """ + import time + + tool_use_id = event.tool_use.get("toolUseId", "unknown") + tool_name = event.tool_use.get("name", "unknown") + + # Store payment failure state in invocation_state + payment_failure_key = f"payment_failure_{tool_use_id}" + event.invocation_state[payment_failure_key] = { + "tool": tool_name, + "toolUseId": tool_use_id, + "exceptionType": type(exception).__name__, + "exceptionMessage": str(exception), + "retryAttempt": event.invocation_state.get(f"payment_retry_count_{tool_use_id}", 0), + "maxRetries": self.MAX_PAYMENT_RETRIES, + "timestamp": time.time(), + } + + logger.info("Stored payment failure state for tool use %s: %s", tool_use_id, type(exception).__name__) + + def _check_interrupt_retry_limit(self, agent, tool_use_id: str) -> bool: + """Check if interrupt retry limit has been reached for a tool use. + + Uses agent.state to persist the count across interrupt cycles. + + Args: + agent: The Strands Agent instance. + tool_use_id: The tool use ID to check. + + Returns: + True if the limit has been reached, False otherwise. + """ + if not agent or self.config.max_interrupt_retries <= 0: + return True + + state_key = f"payment_interrupt_retry_{tool_use_id}" + current_count = agent.state.get(state_key) or 0 + return current_count >= self.config.max_interrupt_retries + + def _increment_interrupt_retry_count(self, agent, tool_use_id: str) -> None: + """Increment the interrupt retry count for a tool use in agent.state. + + Args: + agent: The Strands Agent instance. + tool_use_id: The tool use ID to increment the count for. + """ + if not agent: + return + + state_key = f"payment_interrupt_retry_{tool_use_id}" + current_count = agent.state.get(state_key) or 0 + agent.state.set(state_key, current_count + 1) + + def _reset_interrupt_retry_count(self, event: AfterToolCallEvent) -> None: + """Reset the interrupt retry count after successful payment processing. + + Args: + event: The after tool call event. + """ + agent = event.agent + if not agent: + return + + tool_use_id = event.tool_use.get("toolUseId", "unknown") + state_key = f"payment_interrupt_retry_{tool_use_id}" + agent.state.delete(state_key) + + def _process_payment_required_request(self, payment_required_request: Dict[str, Any]) -> Dict[str, str]: + """Process 402 payment required request and generate payment header. + + Calls PaymentManager.generate_payment_header with the 402 payment required request + and returns the payment header dictionary. + + Args: + payment_required_request: Dictionary containing 402 payment requirements with statusCode, headers, and body + + Returns: + Dictionary with payment header name and value (e.g., {"X-PAYMENT": "base64..."}) + + Raises: + PaymentError: If payment processing fails + """ + if not self.payment_manager: + raise PaymentError("PaymentManager not initialized") + + logger.debug("Processing 402 payment required request") + + if self.config.payment_instrument_id is None: + raise PaymentInstrumentConfigurationRequired( + "payment_instrument_id is required for x402 payments.\n" + "Setup steps:\n" + "1. Create instrument: PaymentManager.create_payment_instrument(connector_id, type, details, user_id)\n" + "2. Fund wallet: https://faucet.circle.com/ (Base Sepolia, USDC, paste wallet address)\n" + "3. Grant signing: visit the redirectUrl from step 1\n" + "4. Pass instrument_id in invoke payload or plugin config" + ) + + if self.config.payment_session_id is None: + raise PaymentSessionConfigurationRequired( + "payment_session_id is required for x402 payments.\n" + "Create a session: PaymentManager.create_payment_session(expiry_time_in_minutes, user_id, limits)\n" + "Then pass session_id in invoke payload or plugin config.\n" + "Tip: use 'agentcore invoke --payment-session-id ' or '--auto-session' from the CLI." + ) + + # Generate payment header using PaymentManager + payment_header_dict = self.payment_manager.generate_payment_header( + user_id=self.config.user_id, + payment_instrument_id=self.config.payment_instrument_id, + payment_session_id=self.config.payment_session_id, + payment_required_request=payment_required_request, + network_preferences=self.config.network_preferences_config, + client_token=str(uuid.uuid4()), + payment_connector_id=self.config.payment_connector_id, + ) + + logger.debug("Generated payment header: %s", list(payment_header_dict.keys())) + return payment_header_dict + + @tool + def get_payment_instrument( + self, + payment_instrument_id: Optional[str] = None, + user_id: Optional[str] = None, + payment_connector_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Retrieve details about a specific payment instrument. + + This tool allows agents to query payment instrument information at runtime, + enabling dynamic payment workflows and decision-making based on instrument + properties. + + Args: + payment_instrument_id: Payment instrument identifier (optional, falls back to plugin config) + user_id: User identifier (optional, falls back to plugin config) + payment_connector_id: Payment connector identifier (optional) + + Returns: + Dictionary containing payment instrument details with the following structure: + { + "paymentInstrumentId": str, + "paymentInstrumentType": str, + "paymentInstrumentDetails": dict, + "status": str, + ...other fields from PaymentManager response + } + """ + logger.info( + "Executing getPaymentInstrument tool for user %s, instrument %s", + user_id, + payment_instrument_id, + ) + + try: + # Ensure PaymentManager is initialized + if not self.payment_manager: + raise PaymentError("PaymentManager not initialized") + + resolved_instrument_id = ( + payment_instrument_id.strip() if payment_instrument_id else None + ) or self.config.payment_instrument_id + if not resolved_instrument_id: + raise PaymentError( + "payment_instrument_id is not set. Provide it as a parameter or configure it in the plugin." + ) + + resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id + + # Call PaymentManager to get instrument details + instrument_details = self.payment_manager.get_payment_instrument( + user_id=resolved_user_id, + payment_instrument_id=resolved_instrument_id, + payment_connector_id=payment_connector_id, + ) + + logger.info("Successfully retrieved payment instrument %s", resolved_instrument_id) + return instrument_details + + except Exception as e: + logger.error( + "Error executing getPaymentInstrument tool: %s - %s", + type(e).__name__, + str(e), + ) + raise + + @tool + def list_payment_instruments( + self, + user_id: Optional[str] = None, + payment_connector_id: Optional[str] = None, + max_results: int = 100, + next_token: Optional[str] = None, + ) -> Dict[str, Any]: + """List all payment instruments for a user. + + This tool allows agents to query and iterate through payment instruments, + enabling dynamic selection and management of payment methods. + + Args: + user_id: User identifier (optional, falls back to plugin config) + payment_connector_id: Filter by payment connector identifier (optional) + max_results: Maximum number of results to return (default 100) + next_token: Pagination token for retrieving next page (optional) + + Returns: + Dictionary containing list of instruments and optional pagination token: + { + "paymentInstruments": [ + { + "paymentInstrumentId": str, + "paymentInstrumentType": str, + ...other instrument fields + }, + ... + ], + "nextToken": str (optional, present if more results exist) + } + """ + logger.info( + "Executing listPaymentInstruments tool for user %s (max_results=%d)", + user_id, + max_results, + ) + + try: + # Validate parameters + validation_error = validate_required_params( + {}, + required=[], + optional=["user_id", "payment_connector_id", "max_results", "next_token"], + ) + if validation_error: + logger.warning("Parameter validation failed for listPaymentInstruments: %s", validation_error) + raise ValueError(validation_error["message"]) + + # Ensure PaymentManager is initialized + if not self.payment_manager: + raise PaymentError("PaymentManager not initialized") + + resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id + + # Call PaymentManager to list instruments + instruments_list = self.payment_manager.list_payment_instruments( + user_id=resolved_user_id, + payment_connector_id=payment_connector_id, + max_results=max_results, + next_token=next_token, + ) + + logger.info( + "Successfully retrieved %d payment instruments for user %s", + len(instruments_list.get("paymentInstruments", [])), + user_id, + ) + return instruments_list + + except Exception as e: + logger.error( + "Error executing listPaymentInstruments tool: %s - %s", + type(e).__name__, + str(e), + ) + raise + + @tool + def get_payment_instrument_balance( + self, + payment_instrument_id: str, + chain: str = "BASE_SEPOLIA", + token: str = "USDC", + payment_connector_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Get the token balance for a payment instrument on a specific blockchain. + + Args: + payment_instrument_id: Payment instrument identifier + chain: Blockchain chain to query (e.g., BASE_SEPOLIA, SOLANA_DEVNET) + token: Token to query balance for (e.g., USDC) + payment_connector_id: Payment connector identifier (optional, falls back to plugin config) + user_id: User identifier (optional, falls back to plugin config) + + Returns: + Dictionary containing balance information: + { + "paymentInstrumentId": str, + "tokenBalance": { + "amount": str, + "chain": str, + "decimals": int, + "network": str, + "token": str + } + } + """ + resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id + resolved_connector_id = payment_connector_id or self.config.payment_connector_id + + logger.info("Executing getPaymentInstrumentBalance for instrument %s on %s", payment_instrument_id, chain) + + try: + if not self.payment_manager: + raise PaymentError("PaymentManager not initialized") + + result = self.payment_manager.get_payment_instrument_balance( + payment_connector_id=resolved_connector_id, + payment_instrument_id=payment_instrument_id, + chain=chain, + token=token, + user_id=resolved_user_id, + ) + return result + + except Exception as e: + logger.error("Error executing getPaymentInstrumentBalance: %s", str(e)) + raise + + @tool + def get_payment_session( + self, payment_session_id: Optional[str] = None, user_id: Optional[str] = None + ) -> Dict[str, Any]: + """Retrieve details about a specific payment session. + + This tool allows agents to query payment session information at runtime, + enabling dynamic tracking of payment budgets and session status. + + Args: + payment_session_id: Payment session identifier (optional, falls back to plugin config) + user_id: User identifier (optional, falls back to plugin config) + + Returns: + Dictionary containing payment session details with the following structure: + { + "paymentSessionId": str, + "paymentManagerArn": str, + "userId": str, + "availableLimits": { + "availableSpendAmount": { + "value": str, + "currency": str + }, + "updatedAt": str + }, + "limits": { + "maxSpendAmount": { + "value": str, + "currency": str + } + }, + "expiryTimeInMinutes": int, + "createdAt": str, + "updatedAt": str + } + """ + logger.info( + "Executing getPaymentSession tool for user %s, session %s", + user_id, + payment_session_id, + ) + + try: + # Ensure PaymentManager is initialized + if not self.payment_manager: + raise PaymentError("PaymentManager not initialized") + + resolved_session_id = ( + payment_session_id.strip() if payment_session_id else None + ) or self.config.payment_session_id + if not resolved_session_id: + raise PaymentError( + "payment_session_id is not set. Provide it as a parameter or configure it in the plugin." + ) + + resolved_user_id = (user_id.strip() if user_id else None) or self.config.user_id + + # Call PaymentManager to get session details + session_details = self.payment_manager.get_payment_session( + user_id=resolved_user_id, + payment_session_id=resolved_session_id, + ) + + logger.info("Successfully retrieved payment session %s", resolved_session_id) + return session_details + + except Exception as e: + logger.error( + "Error executing getPaymentSession tool: %s - %s", + type(e).__name__, + str(e), + ) + raise diff --git a/src/bedrock_agentcore/payments/integrations/strands/tools.py b/src/bedrock_agentcore/payments/integrations/strands/tools.py new file mode 100644 index 00000000..c8d3c0d5 --- /dev/null +++ b/src/bedrock_agentcore/payments/integrations/strands/tools.py @@ -0,0 +1,95 @@ +"""Shared utilities for payment instrument tools.""" + +import json +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def validate_required_params( + params: dict[str, Any], + required: list[str], + optional: Optional[list[str]] = None, +) -> Optional[dict[str, str]]: + """Validate required and optional parameters. + + Args: + params: Dictionary of parameters to validate + required: List of required parameter names + optional: List of optional parameter names (if provided, will be validated) + + Returns: + Error dict if validation fails, None if valid + """ + # Check required parameters + for param in required: + if param not in params: + error_dict = { + "error": "ValidationError", + "message": f"Missing required parameter: {param}", + } + logger.warning("Validation error: %s", error_dict["message"]) + return error_dict + + if isinstance(params[param], str) and not params[param].strip(): + error_dict = { + "error": "ValidationError", + "message": f"Parameter cannot be empty: {param}", + } + logger.warning("Validation error: %s", error_dict["message"]) + return error_dict + + # Check optional parameters if provided + if optional: + for param in optional: + if param in params and isinstance(params[param], str): + if not params[param].strip(): + error_dict = { + "error": "ValidationError", + "message": f"Parameter cannot be empty: {param}", + } + logger.warning("Validation error: %s", error_dict["message"]) + return error_dict + + return None + + +def format_error_response(tool_use_id: str, exception: Exception) -> dict[str, Any]: + """Format exception as error response. + + Args: + tool_use_id: Tool use ID from Strands + exception: Exception to format + + Returns: + ToolResult dict with error status + """ + error_dict = { + "error": exception.__class__.__name__, + "message": str(exception), + } + logger.error("Tool error: %s - %s", error_dict["error"], error_dict["message"]) + return { + "toolUseId": tool_use_id, + "status": "error", + "content": [{"text": json.dumps(error_dict)}], + } + + +def format_success_response(tool_use_id: str, data: dict[str, Any]) -> dict[str, Any]: + """Format data as success response. + + Args: + tool_use_id: Tool use ID from Strands + data: Data to return in response + + Returns: + ToolResult dict with success status + """ + logger.info("Tool execution successful for tool_use_id: %s", tool_use_id) + return { + "toolUseId": tool_use_id, + "status": "success", + "content": [{"text": json.dumps(data)}], + } diff --git a/src/bedrock_agentcore/payments/manager.py b/src/bedrock_agentcore/payments/manager.py new file mode 100644 index 00000000..0b968b55 --- /dev/null +++ b/src/bedrock_agentcore/payments/manager.py @@ -0,0 +1,1465 @@ +"""PaymentManager class for managing payment operations.""" + +import base64 +import binascii +import json +import logging +import os +import sys +import uuid +from typing import Any, Callable, Dict, Optional + +import boto3 +from botocore.config import Config as BotocoreConfig +from botocore.exceptions import ClientError + +from bedrock_agentcore._utils.endpoints import get_data_plane_endpoint +from bedrock_agentcore._utils.user_agent import build_user_agent_suffix + +logger = logging.getLogger(__name__) + + +class PaymentError(Exception): + """Base exception for payment operations.""" + + pass + + +class PaymentInstrumentNotFound(PaymentError): + """Raised when a payment instrument is not found.""" + + pass + + +class PaymentSessionNotFound(PaymentError): + """Raised when a payment session is not found.""" + + pass + + +class InvalidPaymentInstrument(PaymentError): + """Raised when a payment instrument is invalid or inactive.""" + + pass + + +class InsufficientBudget(PaymentError): + """Raised when payment amount exceeds remaining budget.""" + + pass + + +class PaymentSessionExpired(PaymentError): + """Raised when attempting to use an expired payment session.""" + + +class PaymentInstrumentConfigurationRequired(PaymentError): + """Raised when payment_instrument_id is not set on the plugin config.""" + + +class PaymentSessionConfigurationRequired(PaymentError): + """Raised when payment_session_id is not set on the plugin config.""" + + pass + + +class PaymentManager: + """Manages payment operations through a simplified interface. + + The PaymentManager provides a high-level wrapper around AgentCorePayment operations, simplifying + payment operations by managing the paymentManagerArn internally. It provides a clean interface + for payment instrument creation, payment session management, and payment processing. + + Key Capabilities: + - **Payment Instrument Management**: Create and manage payment instruments without + repeatedly passing the manager ARN + - **Payment Session Management**: Create payment sessions with automatic ARN injection + - **Payment Processing**: Process payments with automatic payment instrument validation + - **Method Forwarding**: Access PaymentClient methods directly when needed + + Usage Patterns: + 1. **Create Payment Instrument**: Store a payment method for a user + 2. **Create Payment Session**: Establish a time-bounded payment context + 3. **Process Payment**: Execute a payment with automatic validation + + Example: + ```python + # Initialize manager + manager = PaymentManager( + payment_manager_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123", + region_name="us-east-1" + ) + + # Create a payment instrument + instrument_response = manager.create_payment_instrument( + payment_connector_id="connector-456", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM", + "linkedAccounts": [{"email": {"emailAddress": "user@example.com"}}]}}, + user_id="user-123", + ) + + # Create a payment session + session_response = manager.create_payment_session( + expiry_time_in_minutes=60, + user_id="user-123", + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, + ) + + # Process a payment + payment_response = manager.process_payment( + payment_session_id=session_response["paymentSessionId"], + payment_instrument_id=instrument_response["paymentInstrumentId"], + payment_type="CRYPTO_X402", + payment_input={"cryptoX402": { + "version": "1", + "payload": { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "5000", + "resource": "https://premiousEndpoint", + "description": "Premium AI joke generation", + "mimeType": "application/json", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD9", + "maxTimeoutSeconds": 300, + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF71", + "outputSchema": {"input": {"type": "http", "method": "GET", "discoverable": True}}, + "extra": {"name": "USDC", "version": "2"}, + }, + }}, + user_id="user-123", + ) + ``` + + Thread Safety: + This class is not thread-safe. Create separate instances for concurrent operations. + + AWS Permissions Required: + - bedrock-agentcore:CreatePaymentInstrument + - bedrock-agentcore:GetPaymentInstrument + - bedrock-agentcore:CreatePaymentSession + - bedrock-agentcore:ProcessPayment + """ + + # Allowed data plane methods (forwarded to bedrock-agentcore client) + _ALLOWED_PAYMENTS_DP_METHODS = { + "create_payment_instrument", + "get_payment_instrument", + "get_payment_instrument_balance", + "list_payment_instruments", + "delete_payment_instrument", + "create_payment_session", + "get_payment_session", + "list_payment_sessions", + "delete_payment_session", + "process_payment", + } + + def __init__( + self, + payment_manager_arn: str, + region_name: Optional[str] = None, + boto3_session: Optional[boto3.Session] = None, + boto_client_config: Optional[BotocoreConfig] = None, + agent_name: Optional[str] = None, + bearer_token: Optional[str] = None, + token_provider: Optional[Callable[[], str]] = None, + ): + """Initialize a PaymentManager instance. + + Args: + payment_manager_arn: The ARN of the payment manager instance. Must be a non-empty string. + region_name: AWS region for the bedrock-agentcore client. If not provided, + will use the region from boto3_session or default session. + boto3_session: Optional boto3 Session to use. If provided and region_name + parameter is also specified, validation will ensure they match. + boto_client_config: Optional boto3 client configuration. If provided, will be + merged with default configuration including user agent. + agent_name: Optional agent name to propagate via the + X-Amzn-Bedrock-AgentCore-Payments-Agent-Name HTTP header on every + data-plane API call. + bearer_token: Optional static JWT bearer token for OAuth/CUSTOM_JWT authentication. + When set, requests use Bearer token auth instead of SigV4. + Mutually exclusive with token_provider. + token_provider: Optional callable that returns a fresh JWT bearer token string. + Called before each request to support token refresh. + Mutually exclusive with bearer_token. + + Raises: + ValueError: If payment_manager_arn is invalid, region_name conflicts with boto3_session region, + configuration parameters are inconsistent, or both bearer_token and token_provider + are provided. + """ + if not payment_manager_arn or not isinstance(payment_manager_arn, str): + raise ValueError( + f"payment_manager_arn is required and must be a non-empty string. Received: {payment_manager_arn!r}" + ) + + if bearer_token is not None and token_provider is not None: + raise ValueError("bearer_token and token_provider are mutually exclusive. Provide only one.") + + if bearer_token is not None: + if not isinstance(bearer_token, str) or not bearer_token.strip(): + raise ValueError("bearer_token must be a non-empty string.") + if any(c in bearer_token for c in ("\r", "\n", "\x00")): + raise ValueError("bearer_token must not contain newlines or null bytes.") + + if token_provider is not None and not callable(token_provider): + raise ValueError("token_provider must be callable.") + + # Store payment manager ARN + self._payment_manager_arn: str = payment_manager_arn + self._agent_name: Optional[str] = agent_name + self._bearer_token: Optional[str] = bearer_token + self._token_provider: Optional[Callable[[], str]] = token_provider + + # Setup session and validate region consistency + self.region_name = self._validate_and_resolve_region(region_name, boto3_session) + session = boto3_session if boto3_session else boto3.Session() + + # Configure and create boto3 client + client_config = self._build_client_config(boto_client_config) + self._payment_client = session.client( + "bedrock-agentcore", + region_name=self.region_name, + config=client_config, + endpoint_url=get_data_plane_endpoint(self.region_name), + ) + + # Register event handler to inject agent name header on every data-plane call + if self._agent_name: + self._payment_client.meta.events.register( + "before-sign.bedrock-agentcore.*", + self._add_agent_name_header, + ) + + # Configure bearer token auth if provided (overrides default SigV4) + # Uses before-send (after signing) so the Bearer header replaces the SigV4 Authorization header + if bearer_token is not None or token_provider is not None: + self._payment_client.meta.events.register( + "before-send.bedrock-agentcore.*", + self._inject_bearer_token, + ) + + logger.debug( + "PaymentManager initialized with ARN: %s in region: %s (agent_name: %s, auth: %s)", + self._payment_manager_arn, + self.region_name, + self._agent_name or "not set", + "bearer" if self._is_bearer_auth else "sigv4", + ) + + def _inject_bearer_token(self, request, **kwargs) -> None: + """Inject Bearer token into the request, replacing SigV4 authorization. + + For token_provider, calls the provider to get a fresh token. + For static bearer_token, uses the stored value. + Note: userId is NOT injected as a header — for CUSTOM_JWT auth, the service + derives userId from the JWT 'sub' claim. + """ + if self._token_provider: + try: + token = self._token_provider() + except Exception as e: + raise PaymentError(f"Token provider failed: {e}") from e + if not token or not isinstance(token, str) or not token.strip(): + raise PaymentError("Token provider returned an empty or invalid token.") + if any(c in token for c in ("\r", "\n", "\x00")): + raise PaymentError("Token provider returned a token containing newlines or null bytes.") + else: + token = self._bearer_token + + request.headers["Authorization"] = f"Bearer {token}" + + def _add_agent_name_header(self, request, **kwargs): + """Inject the agent name HTTP header into every outgoing data-plane request. + + This is registered as a boto3 event handler on ``before-sign`` so the + header is present before the request is signed. + + Args: + request: The ``AWSPreparedRequest`` about to be sent. + **kwargs: Additional event keyword arguments (ignored). + """ + request.headers["X-Amzn-Bedrock-AgentCore-Payments-Agent-Name"] = self._agent_name + + @property + def _is_bearer_auth(self) -> bool: + """Check if bearer token auth is configured.""" + return self._bearer_token is not None or self._token_provider is not None + + def _validate_and_resolve_region(self, region_name: Optional[str], session: Optional[boto3.Session]) -> str: + """Validate region consistency and resolve the final region to use. + + Args: + region_name: Explicitly provided region name + session: Optional Boto3 session instance + + Returns: + The resolved region name to use + + Raises: + ValueError: If region_name conflicts with session region + """ + session_region = session.region_name if session else None + + # Validate region consistency if both are provided + if region_name and session and session_region and (region_name != session_region): + raise ValueError( + f"Region mismatch: provided region_name '{region_name}' does not match " + f"boto3_session region '{session_region}'. Please ensure both " + f"parameters specify the same region or omit the region_name parameter " + f"to use the session's region." + ) + + return ( + region_name or session_region or os.environ.get("AWS_REGION") or boto3.Session().region_name or "us-west-2" + ) + + def _build_client_config(self, boto_client_config: Optional[BotocoreConfig]) -> BotocoreConfig: + """Build the final boto3 client configuration with SDK user agent. + + Args: + boto_client_config: Optional user-provided client configuration + + Returns: + Final client configuration with SDK user agent + """ + user_agent_extra = build_user_agent_suffix() + + if boto_client_config: + existing_user_agent = getattr(boto_client_config, "user_agent_extra", None) + if existing_user_agent: + new_user_agent = f"{existing_user_agent} {user_agent_extra}" + else: + new_user_agent = user_agent_extra + return boto_client_config.merge(BotocoreConfig(user_agent_extra=new_user_agent)) + else: + return BotocoreConfig(user_agent_extra=user_agent_extra) + + def create_payment_instrument( + self, + payment_connector_id: str, + payment_instrument_type: str, + payment_instrument_details: Dict[str, Any], + user_id: Optional[str] = None, + client_token: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a payment instrument for a user. + + Creates a new payment instrument (e.g., crypto wallet) associated with a user. + The paymentManagerArn is automatically injected from the manager's configuration. + + Args: + payment_connector_id: ID of the payment connector to use + payment_instrument_type: Type of payment instrument (e.g., EMBEDDED_CRYPTO_WALLET) + payment_instrument_details: Details of the payment instrument (e.g., embeddedCryptoWallet) + user_id: Unique identifier for the user (optional, omitted for bearer auth) + client_token: Optional idempotency token + + Returns: + Dictionary containing paymentInstrumentId and other instrument details + + Raises: + PaymentError: If validation fails or API call fails + + Example: + ```python + response = manager.create_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}} + ) + instrument_id = response["paymentInstrumentId"] + ``` + """ + user_id = user_id.strip() if user_id else None + + if client_token is None: + client_token = str(uuid.uuid4()) + + try: + logger.info("Creating payment instrument for user %s", user_id) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "paymentConnectorId": payment_connector_id, + "paymentInstrumentType": payment_instrument_type, + "paymentInstrumentDetails": payment_instrument_details, + "clientToken": client_token, + } + + result = self._payment_client.create_payment_instrument(**params) + logger.info("Successfully created instrument for user %s", user_id) + # Unwrap the nested paymentInstrument response + return result.get("paymentInstrument", result) + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ValidationException": + logger.error("Validation error creating payment instrument: %s", message) + raise PaymentError(f"Validation error: {message}") from e + + logger.error("Failed to create payment instrument: %s", str(e)) + raise PaymentError(f"Failed to create payment instrument: {str(e)}") from e + + def get_payment_instrument( + self, + payment_instrument_id: str, + user_id: Optional[str] = None, + payment_connector_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Retrieve payment instrument details. + + Args: + payment_instrument_id: Unique identifier for the instrument + user_id: Unique identifier for the user (optional, omitted for bearer auth) + payment_connector_id: ID of the payment connector (optional) + + Returns: + Dictionary containing instrument details + + Raises: + PaymentInstrumentNotFound: If instrument not found + PaymentError: If API call fails + """ + user_id = user_id.strip() if user_id else None + + try: + logger.info("Retrieving payment instrument %s for user %s", payment_instrument_id, user_id) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "paymentInstrumentId": payment_instrument_id, + } + if payment_connector_id is not None: + params["paymentConnectorId"] = payment_connector_id + + result = self._payment_client.get_payment_instrument(**params) + logger.info("Successfully retrieved instrument %s", payment_instrument_id) + # Unwrap the nested paymentInstrument response + return result.get("paymentInstrument", result) + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ResourceNotFoundException" or "not found" in message.lower(): + logger.error("Instrument not found: %s", payment_instrument_id) + raise PaymentInstrumentNotFound(f"Instrument not found: {payment_instrument_id}") from e + + logger.error("Failed to get payment instrument: %s", str(e)) + raise PaymentError(f"Failed to get payment instrument: {str(e)}") from e + + def list_payment_instruments( + self, + user_id: Optional[str] = None, + payment_connector_id: Optional[str] = None, + max_results: int = 100, + next_token: Optional[str] = None, + ) -> Dict[str, Any]: + """List payment instruments for a user. + + Args: + user_id: Unique identifier for the user (optional, omitted for bearer auth) + payment_connector_id: Optional ID of the payment connector to filter by + max_results: Maximum number of results to return (default 100) + next_token: Token for pagination + + Returns: + Dictionary containing list of instruments and next_token if more results exist + + Raises: + PaymentError: If API call fails + """ + user_id = user_id.strip() if user_id else None + + try: + logger.info("Listing payment instruments for user %s", user_id) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "maxResults": max_results, + } + + if payment_connector_id: + params["paymentConnectorId"] = payment_connector_id + + if next_token: + params["nextToken"] = next_token + + result = self._payment_client.list_payment_instruments(**params) + # Unwrap the nested paymentInstruments response + instruments = result.get("paymentInstruments", result.get("instruments", [])) + logger.info("Retrieved %d instruments for user %s", len(instruments), user_id) + response = {"paymentInstruments": instruments} + if "nextToken" in result: + response["nextToken"] = result["nextToken"] + return response + + except ClientError as e: + logger.error("Failed to list payment instruments: %s", str(e)) + raise PaymentError(f"Failed to list payment instruments: {str(e)}") from e + + def get_payment_instrument_balance( + self, + payment_connector_id: str, + payment_instrument_id: str, + chain: str, + token: str, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Get the token balance for a payment instrument on a specific chain. + + Args: + payment_connector_id: ID of the payment connector + payment_instrument_id: Unique identifier for the instrument + chain: Blockchain chain to query (e.g., "BASE_SEPOLIA", "SOLANA_DEVNET") + token: Token to query balance for (e.g., "USDC") + user_id: Unique identifier for the user (optional, omitted for bearer auth) + + Returns: + Dictionary containing paymentInstrumentId and tokenBalance + + Raises: + PaymentInstrumentNotFound: If instrument not found + PaymentError: If API call fails + """ + user_id = user_id.strip() if user_id else None + + try: + logger.info( + "Getting balance for instrument %s on chain %s", + payment_instrument_id, + chain, + ) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "paymentConnectorId": payment_connector_id, + "paymentInstrumentId": payment_instrument_id, + "chain": chain, + "token": token, + } + + result = self._payment_client.get_payment_instrument_balance(**params) + logger.info("Successfully retrieved balance for instrument %s", payment_instrument_id) + return result + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ResourceNotFoundException" or "not found" in message.lower(): + raise PaymentInstrumentNotFound(f"Instrument not found: {payment_instrument_id}") from e + + logger.error("Failed to get instrument balance: %s", str(e)) + raise PaymentError(f"Failed to get instrument balance: {str(e)}") from e + + def create_payment_session( + self, + expiry_time_in_minutes: int, + user_id: Optional[str] = None, + limits: Optional[dict] = None, + client_token: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a payment session with spending limits. + + Args: + expiry_time_in_minutes: Session expiry time in minutes (15-480) + user_id: Unique identifier for the user (optional, omitted for bearer auth) + limits: Optional spending limits dict with maxSpendAmount structure + client_token: Optional idempotency token + + Returns: + Dictionary containing paymentSessionId and other session details + + Raises: + PaymentError: If validation fails or API call fails + """ + user_id = user_id.strip() if user_id else None + + if client_token is None: + client_token = str(uuid.uuid4()) + + try: + logger.info("Creating payment session for user %s with session limits %s", user_id, limits) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "expiryTimeInMinutes": expiry_time_in_minutes, + "clientToken": client_token, + } + + if limits is not None: + params["limits"] = limits + + result = self._payment_client.create_payment_session(**params) + logger.info("Successfully created session for user %s", user_id) + # Unwrap the nested paymentSession response + return result.get("paymentSession", result) + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ValidationException": + if "expiry" in message.lower() or "duration" in message.lower(): + logger.error("Invalid expiry_time_in_minutes: %s", message) + raise PaymentError(f"Invalid expiry_time_in_minutes: {message}") from e + + logger.error("Failed to create payment session: %s", str(e)) + raise PaymentError(f"Failed to create payment session: {str(e)}") from e + + def get_payment_session( + self, + payment_session_id: str, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Retrieve payment session details. + + Args: + payment_session_id: Unique identifier for the session + user_id: Unique identifier for the user (optional, omitted for bearer auth) + + Returns: + Dictionary containing session details including remaining_amount and spent_amount + + Raises: + PaymentSessionNotFound: If session not found + PaymentError: If API call fails + """ + user_id = user_id.strip() if user_id else None + + try: + logger.info("Retrieving payment session %s for user %s", payment_session_id, user_id) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "paymentSessionId": payment_session_id, + } + + result = self._payment_client.get_payment_session(**params) + logger.info("Successfully retrieved session %s", payment_session_id) + # Unwrap the nested paymentSession response + return result.get("paymentSession", result) + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ResourceNotFoundException" or "not found" in message.lower(): + logger.error("Session not found: %s", payment_session_id) + raise PaymentSessionNotFound(f"Session not found: {payment_session_id}") from e + + if error_code == "AccessDeniedException" or "unauthorized" in message.lower(): + logger.error("Unauthorized access to session %s", payment_session_id) + raise PaymentError(f"Unauthorized access to session: {payment_session_id}") from e + + logger.error("Failed to get payment session: %s", str(e)) + raise PaymentError(f"Failed to get payment session: {str(e)}") from e + + def list_payment_sessions( + self, + user_id: Optional[str] = None, + max_results: int = 100, + next_token: Optional[str] = None, + ) -> Dict[str, Any]: + """List payment sessions for a user. + + Args: + user_id: Unique identifier for the user (optional, omitted for bearer auth) + max_results: Maximum number of results to return (default 100) + next_token: Token for pagination + + Returns: + Dictionary containing list of sessions and next_token if more results exist + + Raises: + PaymentError: If API call fails + """ + user_id = user_id.strip() if user_id else None + + try: + logger.info("Listing payment sessions for user %s", user_id) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "maxResults": max_results, + } + + if next_token: + params["nextToken"] = next_token + + result = self._payment_client.list_payment_sessions(**params) + # Unwrap the nested paymentSessions response + sessions = result.get("paymentSessions", result.get("sessions", [])) + logger.info("Retrieved %d sessions for user %s", len(sessions), user_id) + response = {"paymentSessions": sessions} + if "nextToken" in result: + response["nextToken"] = result["nextToken"] + return response + + except ClientError as e: + logger.error("Failed to list payment sessions: %s", str(e)) + raise PaymentError(f"Failed to list payment sessions: {str(e)}") from e + + def delete_payment_session( + self, + payment_session_id: str, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete a payment session. + + Permanently removes a payment session record (hard delete). Once deleted, + the session can no longer be used for payment processing. + + Deleting a non-existent or already-deleted session returns PaymentSessionNotFound. + + Args: + payment_session_id: Unique identifier for the session to delete + user_id: Unique identifier for the user (optional, omitted for bearer auth) + + Returns: + Dictionary containing deletion status: {"status": "DELETED"} + + Raises: + PaymentSessionNotFound: If session not found or already deleted + PaymentError: If API call fails + + Example: + ```python + result = manager.delete_payment_session( + payment_session_id="payment-session-abc123", + user_id="user-123", + ) + # result: {"status": "DELETED"} + ``` + """ + user_id = user_id.strip() if user_id else None + + try: + logger.info("Deleting payment session %s for user %s", payment_session_id, user_id) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "paymentSessionId": payment_session_id, + } + + result = self._payment_client.delete_payment_session(**params) + logger.info("Successfully deleted payment session %s", payment_session_id) + return result + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ResourceNotFoundException" or "not found" in message.lower(): + logger.error("Session not found: %s", payment_session_id) + raise PaymentSessionNotFound(f"Session not found: {payment_session_id}") from e + + logger.error("Failed to delete payment session: %s", str(e)) + raise PaymentError(f"Failed to delete payment session: {str(e)}") from e + + def delete_payment_instrument( + self, + payment_instrument_id: str, + payment_connector_id: str, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete a payment instrument. + + Marks a payment instrument as deleted (soft delete). The record is preserved + for audit and compliance purposes but is excluded from normal list and get operations. + + Deleting an already-deleted or non-existent instrument returns PaymentInstrumentNotFound. + + Args: + payment_instrument_id: Unique identifier for the instrument to delete + payment_connector_id: ID of the payment connector (required) + user_id: Unique identifier for the user (optional, omitted for bearer auth) + + Returns: + Dictionary containing deletion status: {"status": "DELETED"} + + Raises: + PaymentInstrumentNotFound: If instrument not found or already deleted + PaymentError: If API call fails + + Example: + ```python + result = manager.delete_payment_instrument( + payment_instrument_id="payment-instrument-xyz789", + payment_connector_id="connector-456", + user_id="user-123", + ) + # result: {"status": "DELETED"} + ``` + """ + user_id = user_id.strip() if user_id else None + + try: + logger.info("Deleting payment instrument %s for user %s", payment_instrument_id, user_id) + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "paymentConnectorId": payment_connector_id, + "paymentInstrumentId": payment_instrument_id, + } + + result = self._payment_client.delete_payment_instrument(**params) + logger.info("Successfully deleted payment instrument %s", payment_instrument_id) + return result + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ResourceNotFoundException" or "not found" in message.lower(): + logger.error("Instrument not found: %s", payment_instrument_id) + raise PaymentInstrumentNotFound(f"Instrument not found: {payment_instrument_id}") from e + + logger.error("Failed to delete payment instrument: %s", str(e)) + raise PaymentError(f"Failed to delete payment instrument: {str(e)}") from e + + def process_payment( + self, + payment_session_id: str, + payment_instrument_id: str, + payment_type: str, + payment_input: Dict[str, Any], + user_id: Optional[str] = None, + client_token: Optional[str] = None, + payment_connector_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Process a payment transaction. + + Args: + payment_session_id: Unique identifier for the payment session + payment_instrument_id: Unique identifier for the payment instrument + payment_type: Type of payment being processed (e.g., CRYPTO_X402) + payment_input: Payment input details specific to the payment type + user_id: Unique identifier for the user (optional, omitted for bearer auth) + client_token: Optional idempotency token for request uniqueness + payment_connector_id: Optional payment connector ID to route the payment + + Returns: + Dictionary containing processPaymentId and transaction details + + Raises: + PaymentInstrumentNotFound: If payment instrument not found + InsufficientBudget: If payment amount exceeds remaining budget + PaymentSessionExpired: If payment session has expired + InvalidPaymentInstrument: If payment instrument is invalid or inactive + PaymentError: If API call fails + """ + user_id = user_id.strip() if user_id else None + + if client_token is None: + client_token = str(uuid.uuid4()) + + try: + logger.info("Processing payment of type %s for user %s", payment_type, user_id) + + params = { + **({"userId": user_id} if user_id and not self._is_bearer_auth else {}), + "paymentManagerArn": self._payment_manager_arn, + "paymentSessionId": payment_session_id, + "paymentInstrumentId": payment_instrument_id, + "paymentType": payment_type, + "paymentInput": payment_input, + "clientToken": client_token, + } + if payment_connector_id is not None: + params["paymentConnectorId"] = payment_connector_id + + result = self._payment_client.process_payment(**params) + logger.info("Successfully processed payment for user %s", user_id) + # Unwrap the nested processPayment response + return result.get("processPayment", result) + + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + message = e.response.get("Error", {}).get("Message", "") + + if error_code == "ValidationException": + if "budget" in message.lower() or "insufficient" in message.lower(): + logger.error("Insufficient budget: %s", message) + raise InsufficientBudget(f"Insufficient budget: {message}") from e + if "expired" in message.lower(): + logger.error("Session expired: %s", message) + raise PaymentSessionExpired(f"Session expired: {message}") from e + if "instrument" in message.lower() or "inactive" in message.lower(): + logger.error("Invalid instrument: %s", message) + raise InvalidPaymentInstrument(f"Invalid instrument: {message}") from e + if "session not found" in message.lower(): + logger.error("PaymentSession not found: %s", message) + raise PaymentSessionNotFound(f"Session not found or expired: {payment_session_id}") from e + + logger.error("Failed to process payment: %s", str(e)) + raise PaymentError(f"Failed to process payment: {str(e)}") from e + + def generate_payment_header( + self, + payment_instrument_id: str, + payment_session_id: str, + payment_required_request: Dict[str, Any], + user_id: Optional[str] = None, + network_preferences: Optional[list[str]] = None, + client_token: Optional[str] = None, + payment_connector_id: Optional[str] = None, + ) -> Dict[str, str]: + """Generate a payment header for 402 payment required request. + + This method orchestrates the complete payment header generation workflow: + 1. Validates input parameters + 2. Generates or validates client_token + 3. Retrieves payment instrument details + 4. Extracts payment requirement from 402 payment required request + 5. Selects appropriate blockchain network accept header. Here is the Selection process: + 1. Filter accepts to those matching the instrument's blockchain type + 2. Use provided network_preferences or default to NETWORK_PREFERENCES from constants + 3. Pick the first network from preferences that matches a filtered accept + 4. If no match found, return the first filtered accept + 6. Processes the payment transaction + 7. Builds the final payment header (v1 or v2 format) + + Args: + payment_instrument_id: Unique identifier for the payment instrument + payment_session_id: Unique identifier for the payment session + payment_required_request: Dictionary containing 402 response with statusCode, headers, and body + user_id: Unique identifier for the user (optional, omitted for bearer auth) + network_preferences: Optional list of network identifiers in order of preference. + If not provided, defaults to NETWORK_PREFERENCES from constants. + client_token: Optional unique token for idempotency. If not provided, a new one is generated. + payment_connector_id: Optional payment connector ID to pass to process_payment. + + Returns: + Dictionary with header name and value (e.g., {"X-PAYMENT": "base64..."} or + {"PAYMENT-SIGNATURE": "base64..."}) for X402 payment required request + + Raises: + PaymentError: For validation or processing failures + PaymentInstrumentNotFound: If instrument not found + PaymentSessionNotFound: If session not found + PaymentSessionExpired: If session has expired + InsufficientBudget: If payment amount exceeds budget + + Example: + ```python + header = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-456", + payment_session_id="session-789", + payment_required_request={ + "statusCode": 402, + "headers": {"..."}, + "body": {...} + }, + client_token="optional-token-123", + network_preferences=["solana-mainnet", "eip155:8453"] + ) + # Returns: {"X-PAYMENT": "base64..."} or {"PAYMENT-SIGNATURE": "base64..."} + ``` + """ + user_id = user_id.strip() if user_id else None + + logger.info( + "Generating payment header for user %s with instrument %s and session %s", + user_id, + payment_instrument_id, + payment_session_id, + ) + + try: + # Step 1: Validate input parameters (including client_token) + self._validate_input_parameters( + user_id, + payment_instrument_id, + payment_session_id, + payment_required_request, + ) + logger.debug("Input validation passed") + + # Step 2: Check statusCode == 402 + status_code = payment_required_request.get("statusCode") + logger.debug("Checking 402 status code: %s", status_code) + if status_code != 402: + raise PaymentError( + f"402 Status Validation: Invalid status code - Expected statusCode 402, got {status_code}" + ) + logger.debug("Status code validation passed") + + # Step 3: Generate client_token if not provided + if client_token is None: + client_token = str(uuid.uuid4()) + logger.debug("Generated new client_token: %s", client_token[:8] + "...") + else: + # Validate client_token is a string and not empty + if not isinstance(client_token, str): + raise PaymentError("client_token is invalid - must be a string") + if not client_token.strip(): + raise PaymentError("client_token is invalid - cannot be empty") + logger.debug("Using provided client_token: %s", client_token[:8] + "...") + + # Step 4: Extract X.402 payload and detect version. + # Will have another method for MPP or any other payments protocols + x402_payload, x402_version = self._extract_x402_payload(payment_required_request) + logger.debug("Extracted X.402 payload version %d", x402_version) + + # Step 5: Retrieve payment instrument and extract network + instrument = self.get_payment_instrument( + user_id=user_id, + payment_instrument_id=payment_instrument_id, + ) + logger.debug("Retrieved instrument: %s", instrument) + + # Extract network from nested structure: paymentInstrumentDetails.embeddedCryptoWallet.network + network = None + if "paymentInstrumentDetails" in instrument: + details = instrument.get("paymentInstrumentDetails", {}) + if "embeddedCryptoWallet" in details: + network = details.get("embeddedCryptoWallet", {}).get("network") + + if not network: + raise PaymentError( + "Instrument Retrieval: Missing network information - " + "instrument details do not contain network information at " + "paymentInstrumentDetails.embeddedCryptoWallet.network" + ) + logger.debug("Retrieved instrument with network: %s", network) + + # Step 6: Validate instrument network and select matching accept + selected_accept = self._select_accept_for_instrument_network(x402_payload, network, network_preferences) + logger.debug("Selected accept for instrument network: %s", network) + + # Step 7: Process payment + logger.debug("Processing payment with type CRYPTO_X402") + payment_input = { + "cryptoX402": { + "version": str(x402_version), + "payload": selected_accept, + } + } + + process_payment_params = { + "user_id": user_id, + "payment_session_id": payment_session_id, + "payment_instrument_id": payment_instrument_id, + "payment_type": "CRYPTO_X402", + "payment_input": payment_input, + "client_token": client_token, + } + if payment_connector_id is not None: + process_payment_params["payment_connector_id"] = payment_connector_id + + payment_result = self.process_payment(**process_payment_params) + logger.debug("Payment processed successfully") + + # Extract cryptoX402 proof from payment result + crypto_x402_proof = payment_result.get("paymentOutput", {}).get("cryptoX402", {}) + if not crypto_x402_proof: + raise PaymentError( + "Payment Processing: Missing cryptoX402 in payment output - " + "payment result does not contain cryptoX402 proof" + ) + logger.debug("Extracted cryptoX402 proof from payment result") + + # Step 8: Build payment header + payment_header = self._build_payment_header(x402_version, x402_payload, selected_accept, crypto_x402_proof) + logger.info("Successfully generated payment header for user %s", user_id) + + return payment_header + + except PaymentError: + logger.error("Payment header generation failed: %s", str(sys.exc_info()[1])) + raise + except Exception as e: + logger.error("Unexpected error during payment header generation: %s", str(e)) + raise PaymentError(f"Unexpected error: {str(e)}") from e + + def __getattr__(self, name: str): + """Dynamically forward method calls to the PaymentClient. + + This method enables access to allowed PaymentClient methods without explicitly + defining them. Methods are looked up on the PaymentClient instance only if they + are in the allowed list. + + Args: + name: The method name being accessed + + Returns: + A callable method from the PaymentClient + + Raises: + AttributeError: If the method doesn't exist on PaymentClient or is not allowed + + Example: + ```python + # Access allowed PaymentClient methods directly + manager = PaymentManager(config) + + # These calls are forwarded to the PaymentClient + instruments = manager.list_payment_instruments(...) + ``` + """ + if name in self._ALLOWED_PAYMENTS_DP_METHODS and hasattr(self._payment_client, name): + method = getattr(self._payment_client, name) + return method + + # Method not found on client or not in allowed list + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'. " + f"Method not found on _payment_client or not in allowed methods. " + f"Available methods can be found in the boto3 documentation for " + f"'bedrock-agentcore' services." + ) + + # Network mappings for blockchain identification (normalized to lowercase) + _ETHEREUM_NETWORKS = { + n.lower() + for n in { + "eip155:8453", # Base mainnet (low fees) + "eip155:1", # Ethereum mainnet + "base", + "eip155:42161", # Arbitrum One + "eip155:10", # Optimism + "ethereum", + "sepolia", + "base-sepolia", + "eip155:84532", # Base Sepolia (testnet) + "eip155:11155111", # Base Sepolia (Test) + } + } + + _SOLANA_NETWORKS = { + n.lower() + for n in { + "solana", # Generic Solana identifier + "solana-mainnet", # Solana Mainnet (simplified identifier) + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", # Mainnet genesis hash (32 chars, CAIP-2) + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", # Mainnet full genesis hash (44 chars) + "solana-devnet", # Solana Devnet (simplified identifier) + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", # Devnet genesis hash (32 chars, CAIP-2) + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", # Devnet full genesis hash (44 chars) + "solana-testnet", # Solana Testnet (simplified identifier) + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", # Testnet genesis hash (32 chars, CAIP-2) + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY", # Testnet full genesis hash (44 chars) + } + } + + def _validate_input_parameters( + self, + user_id: str, + instrument_id: str, + session_id: str, + payment_required_request: Dict[str, Any], + ) -> None: + """Validate all input parameters for generatePaymentHeader. + + Args: + user_id: User identifier to validate + instrument_id: Instrument identifier to validate + session_id: Session identifier to validate + payment_required_request: X.402 response dictionary to validate + client_token: Optional client token to validate + + Raises: + PaymentError: If any parameter is invalid + """ + if not self._is_bearer_auth: + if not user_id or not isinstance(user_id, str) or not user_id.strip(): + raise PaymentError("Input Validation: user_id is empty - user_id must be a non-empty string") + + if not instrument_id or not isinstance(instrument_id, str) or not instrument_id.strip(): + raise PaymentError("Input Validation: instrument_id is empty - instrument_id must be a non-empty string") + + if not session_id or not isinstance(session_id, str) or not session_id.strip(): + raise PaymentError("Input Validation: session_id is empty - session_id must be a non-empty string") + + if not isinstance(payment_required_request, dict) or not payment_required_request: + raise PaymentError( + "Input Validation: payment_required_request is invalid - " + "payment_required_request must be a non-empty dictionary" + ) + + # Validate required fields in payment_required_request + required_fields = {"statusCode", "headers", "body"} + if not all(field in payment_required_request for field in required_fields): + raise PaymentError( + "Input Validation: 402 payment required request is missing required fields - " + "402 payment required request must contain statusCode, headers, and body" + ) + + def _extract_x402_payload(self, payment_required_request: Dict[str, Any]) -> tuple: + """Extract X.402 payload from payment_required_request and detect version. + + Args: + payment_required_request: X.402 response dictionary + + Returns: + Tuple of (x402_payload, x402_version) + + Raises: + PaymentError: If extraction or validation fails + """ + try: + # Try to detect version from headers first (v2) + headers = payment_required_request.get("headers", {}) + payment_required_header = None + + # Check for "payment-required" header (case-insensitive) + for key, value in headers.items(): + if key.lower() == "payment-required": + payment_required_header = value + break + + if payment_required_header is not None: + if not payment_required_header: + raise PaymentError("X.402 Extraction: payment-required header is present but empty") + # v2: Decode base64 header + try: + decoded = base64.b64decode(payment_required_header) + x402_payload = json.loads(decoded) + + # Validate that decoded payload is a dictionary + if not isinstance(x402_payload, dict): + raise PaymentError( + f"X.402 Extraction: v2 payload decoded to {type(x402_payload).__name__}, " + f"expected a JSON object" + ) + + # Require x402Version field + if "x402Version" not in x402_payload: + raise PaymentError( + "X.402 Extraction: Missing x402Version - x402Payload must contain x402Version field" + ) + try: + x402_version = int(x402_payload["x402Version"]) + except (ValueError, TypeError) as ve: + raise PaymentError( + f"X.402 Extraction: Invalid x402Version '{x402_payload['x402Version']}' - " + f"must be an integer" + ) from ve + except (ValueError, json.JSONDecodeError, binascii.Error) as e: + raise PaymentError( + f"X.402 Extraction: Failed to decode v2 payload - " + f"payment-required header contains invalid base64 or JSON: {str(e)}" + ) from e + else: + # v1: Extract from body + body = payment_required_request.get("body") + if isinstance(body, str): + try: + x402_payload = json.loads(body) + # Validate that decoded payload is a dictionary + if not isinstance(x402_payload, dict): + raise PaymentError( + f"X.402 Extraction: v1 payload decoded to {type(x402_payload).__name__}, " + f"expected a JSON object" + ) + except json.JSONDecodeError as e: + raise PaymentError( + f"X.402 Extraction: Failed to parse v1 payload from body - " + f"body contains invalid JSON: {str(e)}" + ) from e + elif isinstance(body, dict): + x402_payload = body + else: + raise PaymentError( + "X.402 Extraction: Invalid body format - body must be a JSON string or dictionary" + ) + + # Require x402Version field + if "x402Version" not in x402_payload: + raise PaymentError( + "X.402 Extraction: Missing x402Version - x402Payload must contain x402Version field" + ) + try: + x402_version = int(x402_payload["x402Version"]) + except (ValueError, TypeError) as ve: + raise PaymentError( + f"X.402 Extraction: Invalid x402Version '{x402_payload['x402Version']}' - must be an integer" + ) from ve + + # Validate required fields + required_fields = {"x402Version", "accepts"} + missing_fields = required_fields - set(x402_payload.keys()) + if missing_fields: + raise PaymentError( + f"X.402 Validation: Missing required fields - " + f"x402Payload must contain {', '.join(sorted(required_fields))}, " + f"but missing: {', '.join(sorted(missing_fields))}" + ) + + # Validate accepts is a list + if not isinstance(x402_payload.get("accepts"), list): + raise PaymentError("X.402 Validation: Invalid accepts field - accepts must be a list of accept headers") + + logger.debug("Successfully extracted X.402 payload version %d", x402_version) + return x402_payload, x402_version + + except PaymentError: + raise + except Exception as e: + raise PaymentError(f"X.402 Extraction: Unexpected error - {str(e)}") from e + + def _determine_blockchain_type(self, network: str) -> str: + """Determine blockchain type from network identifier. + + Args: + network: Network identifier from instrument (ETHEREUM or SOLANA) + + Returns: + Blockchain type: "ethereum" or "solana" + + Raises: + PaymentError: If network is not supported + """ + network_upper = network.upper() + if network_upper == "ETHEREUM": + return "ethereum" + elif network_upper == "SOLANA": + return "solana" + else: + raise PaymentError( + f"Instrument Network: Unsupported network - instrument network '{network}' is not supported. " + f"Supported networks are ETHEREUM and SOLANA." + ) + + def _select_accept_for_instrument_network( + self, + x402_payload: Dict[str, Any], + instrument_network: str, + network_preferences: Optional[list[str]] = None, + ) -> Dict[str, Any]: + """Select appropriate accept header based on instrument network and preferences. + + Selection process: + 1. Filter accepts to those matching the instrument's blockchain type + 2. Use provided network_preferences or default to NETWORK_PREFERENCES from constants + 3. Pick the first network from preferences that matches a filtered accept + 4. If no match found, return the first filtered accept + + Args: + x402_payload: Extracted X.402 payload + instrument_network: Instrument network type (ETHEREUM or SOLANA) + network_preferences: Optional list of network identifiers in order of preference. + If not provided, defaults to NETWORK_PREFERENCES from constants. + + Returns: + Selected accept header + + Raises: + PaymentError: If no matching accept found for instrument network + """ + from bedrock_agentcore.payments.constants import NETWORK_PREFERENCES + + # Determine blockchain type from instrument network + blockchain_type = self._determine_blockchain_type(instrument_network) + + # Get the appropriate network set based on blockchain type + if blockchain_type == "ethereum": + supported_networks = self._ETHEREUM_NETWORKS + else: # solana + supported_networks = self._SOLANA_NETWORKS + + # Step 1: Filter accepts to those matching the instrument's blockchain type + accepts = x402_payload.get("accepts", []) + filtered_accepts = [] + for accept in accepts: + accept_network = accept.get("network", "").lower() + if accept_network in supported_networks: + filtered_accepts.append(accept) + + if not filtered_accepts: + raise PaymentError( + f"Accept Selection: No matching accept - No accept header found for " + f"instrument network '{instrument_network}' in X.402 payload. " + f"Instrument does not support the network for header generation." + ) + + # Step 2: Use provided preferences or default + preferences = network_preferences if network_preferences is not None else NETWORK_PREFERENCES + + # Step 3: Pick the first network from preferences that matches a filtered accept + for preferred_network in preferences: + for accept in filtered_accepts: + accept_network = accept.get("network", "").lower() + if accept_network == preferred_network.lower(): + logger.debug( + "Selected accept for instrument network: %s using preference: %s", + instrument_network, + preferred_network, + ) + return accept + + # Step 4: If no match found, return the first filtered accept + logger.debug( + "No preference match found, selecting first available accept for instrument network: %s", + instrument_network, + ) + return filtered_accepts[0] + + def _build_payment_header( + self, + x402_version: int, + x402_payload: Dict[str, Any], + selected_accept: Dict[str, Any], + crypto_x402_proof: Dict[str, Any], + ) -> Dict[str, str]: + """Build the final payment header from cryptoX402 proof. + + Args: + x402_version: X.402 version (1 or 2) + x402_payload: Extracted X.402 payload + selected_accept: Selected accept header + crypto_x402_proof: CryptoX402 proof from payment result + + Returns: + Dictionary with header name and encoded value + (e.g., {"X-PAYMENT": "base64..."} or {"PAYMENT-SIGNATURE": "base64..."}) + + Raises: + PaymentError: If header building fails + """ + try: + if x402_version == 1: + # v1: X-PAYMENT format + x402_header = { + "x402Version": 1, + "scheme": selected_accept.get("scheme"), + "network": selected_accept.get("network"), + "payload": crypto_x402_proof.get("payload"), + } + header_json = json.dumps(x402_header) + encoded = base64.b64encode(header_json.encode()).decode() + return {"X-PAYMENT": encoded} + + elif x402_version == 2: + # v2: PAYMENT-SIGNATURE format + payment_signature = { + "x402Version": 2, + "resource": x402_payload.get("resource"), + "accepted": selected_accept, + "extension": x402_payload.get("extension", {}), + "payload": crypto_x402_proof.get("payload"), + } + header_json = json.dumps(payment_signature) + encoded = base64.b64encode(header_json.encode()).decode() + return {"PAYMENT-SIGNATURE": encoded} + + else: + raise PaymentError( + f"Header Building: Unsupported X.402 version - " + f"x402Version {x402_version} is not supported. " + f"Supported versions: 1, 2" + ) + + except PaymentError: + raise + except Exception as e: + raise PaymentError(f"Header Building: Encoding failed - {str(e)}") from e diff --git a/src/bedrock_agentcore/services/identity.py b/src/bedrock_agentcore/services/identity.py index 7bb21da9..c1574c0f 100644 --- a/src/bedrock_agentcore/services/identity.py +++ b/src/bedrock_agentcore/services/identity.py @@ -298,3 +298,119 @@ async def get_api_key(self, *, provider_name: str, agent_identity_token: str) -> req = {"resourceCredentialProviderName": provider_name, "workloadIdentityToken": agent_identity_token} return self.dp_client.get_resource_api_key(**req)["apiKey"] + + def create_payment_credential_provider( + self, name: str, credential_provider_vendor: str, provider_configuration_input: Dict + ) -> Dict: + """Create a payment credential provider. + + Args: + name: Unique name for the payment credential provider + credential_provider_vendor: The vendor type (e.g., CoinbaseCDP, StripePrivy) + provider_configuration_input: Configuration specific to the vendor, including API credentials + + Returns: + Response containing the created payment credential provider details + + Raises: + botocore.exceptions.ClientError: If the service request fails (e.g., permission denied, + invalid configuration, resource already exists) + """ + self.logger.info( + "Creating payment credential provider '%s' for vendor '%s'...", + name, + credential_provider_vendor, + ) + return self.cp_client.create_payment_credential_provider( + name=name, + credentialProviderVendor=credential_provider_vendor, + providerConfigurationInput=provider_configuration_input, + ) + + def update_payment_credential_provider( + self, name: str, credential_provider_vendor: str, provider_configuration_input: Dict + ) -> Dict: + """Update an existing payment credential provider. + + Args: + name: Name of the payment credential provider to update + credential_provider_vendor: The vendor type (e.g., CoinbaseCDP, StripePrivy) + provider_configuration_input: Updated configuration specific to the vendor + + Returns: + Response containing the updated payment credential provider details + + Raises: + botocore.exceptions.ClientError: If the service request fails (e.g., provider not found, + permission denied, invalid configuration) + """ + self.logger.info( + "Updating payment credential provider '%s' for vendor '%s'...", + name, + credential_provider_vendor, + ) + return self.cp_client.update_payment_credential_provider( + name=name, + credentialProviderVendor=credential_provider_vendor, + providerConfigurationInput=provider_configuration_input, + ) + + def delete_payment_credential_provider(self, name: str) -> Dict: + """Delete a payment credential provider. + + Args: + name: Name of the payment credential provider to delete + + Returns: + Response confirming the deletion + + Raises: + botocore.exceptions.ClientError: If the service request fails (e.g., provider not found, + permission denied) + """ + self.logger.info("Deleting payment credential provider '%s'...", name) + return self.cp_client.delete_payment_credential_provider(name=name) + + def get_payment_credential_provider(self, name: str) -> Dict: + """Retrieve information about a payment credential provider. + + Args: + name: Name of the payment credential provider to retrieve + + Returns: + Response containing the payment credential provider details + + Raises: + botocore.exceptions.ClientError: If the service request fails (e.g., provider not found, + permission denied) + """ + self.logger.info("Fetching payment credential provider '%s'...", name) + return self.cp_client.get_payment_credential_provider(name=name) + + def list_payment_credential_providers( + self, next_token: Optional[str] = None, max_results: Optional[int] = None + ) -> Dict: + """List all payment credential providers. + + Args: + next_token: Token for pagination to retrieve the next set of results + max_results: Maximum number of results to return (1-20) + + Returns: + Response containing a list of payment credential providers + + Raises: + ValueError: If max_results is not in the valid range (1-20) + botocore.exceptions.ClientError: If the service request fails (e.g., permission denied, + service unavailable) + """ + if max_results is not None and (max_results < 1 or max_results > 20): + raise ValueError(f"max_results must be between 1 and 20, got: {max_results}") + + self.logger.info("Listing payment credential providers...") + req = {} + if next_token is not None: + req["nextToken"] = next_token + if max_results is not None: + req["maxResults"] = max_results + return self.cp_client.list_payment_credential_providers(**req) diff --git a/tests/bedrock_agentcore/payments/__init__.py b/tests/bedrock_agentcore/payments/__init__.py new file mode 100644 index 00000000..0c868a7c --- /dev/null +++ b/tests/bedrock_agentcore/payments/__init__.py @@ -0,0 +1 @@ +"""Tests for Bedrock AgentCore Payment SDK.""" diff --git a/tests/bedrock_agentcore/payments/integrations/__init__.py b/tests/bedrock_agentcore/payments/integrations/__init__.py new file mode 100644 index 00000000..c025f034 --- /dev/null +++ b/tests/bedrock_agentcore/payments/integrations/__init__.py @@ -0,0 +1 @@ +"""Tests for payment integrations.""" diff --git a/tests/bedrock_agentcore/payments/integrations/strands/__init__.py b/tests/bedrock_agentcore/payments/integrations/strands/__init__.py new file mode 100644 index 00000000..88909d9f --- /dev/null +++ b/tests/bedrock_agentcore/payments/integrations/strands/__init__.py @@ -0,0 +1 @@ +"""Tests for Strands payment integration.""" diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_config.py b/tests/bedrock_agentcore/payments/integrations/strands/test_config.py new file mode 100644 index 00000000..64b0cdce --- /dev/null +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_config.py @@ -0,0 +1,587 @@ +"""Tests for AgentCorePaymentsPluginConfig.""" + +import pytest + +from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + + +class TestAgentCorePaymentsPluginConfigValidation: + """Test validation of AgentCorePaymentsPluginConfig required fields.""" + + def test_empty_payment_manager_arn_raises_error(self): + """Test that empty payment_manager_arn raises ValueError.""" + with pytest.raises(ValueError, match="payment_manager_arn is required"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + + def test_invalid_arn_format_raises_error(self): + """Test that invalid ARN format raises ValueError.""" + with pytest.raises(ValueError, match="Invalid ARN format"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="not-an-arn", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + + def test_empty_user_id_raises_error(self): + """Test that empty user_id raises ValueError.""" + with pytest.raises(ValueError, match="user_id is required"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + + def test_empty_payment_instrument_id_raises_error(self): + """Test that empty payment_instrument_id raises ValueError via update method.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + with pytest.raises(ValueError, match="payment_instrument_id cannot be empty"): + config.update_payment_instrument_id("") + + def test_empty_payment_session_id_raises_error(self): + """Test that empty payment_session_id raises ValueError via update method.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + with pytest.raises(ValueError, match="payment_session_id cannot be empty"): + config.update_payment_session_id("") + + def test_whitespace_only_payment_manager_arn(self): + """Test that whitespace-only payment_manager_arn raises Invalid ARN format error.""" + with pytest.raises(ValueError, match="Invalid ARN format"): + AgentCorePaymentsPluginConfig( + payment_manager_arn=" ", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + + def test_valid_config(self): + """Test that valid configuration is accepted.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + assert config.payment_manager_arn == "arn:aws:payment:us-east-1:123456789012:payment-manager/test" + assert config.user_id == "test-user" + assert config.payment_instrument_id == "test-instrument" + assert config.payment_session_id == "test-session" + + def test_config_with_network_preferences(self): + """Test configuration with network preferences.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + network_preferences_config=["eip155:1", "solana:mainnet"], + ) + assert config.network_preferences_config == ["eip155:1", "solana:mainnet"] + + def test_config_without_region(self): + """Test configuration without explicit region.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + assert config.region is None + + +class TestAgentCorePaymentsPluginConfigAutoPayment: + """Test auto_payment configuration field.""" + + def test_auto_payment_defaults_to_true(self): + """Test that auto_payment defaults to True.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + assert config.auto_payment is True + + def test_auto_payment_can_be_set_to_false(self): + """Test that auto_payment can be explicitly set to False.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=False, + ) + assert config.auto_payment is False + + def test_auto_payment_can_be_set_to_true(self): + """Test that auto_payment can be explicitly set to True.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=True, + ) + assert config.auto_payment is True + + def test_auto_payment_rejects_non_boolean_string(self): + """Test that auto_payment validation rejects string values.""" + with pytest.raises(ValueError, match="auto_payment must be a boolean"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment="true", # type: ignore + ) + + def test_auto_payment_rejects_non_boolean_int(self): + """Test that auto_payment validation rejects integer values.""" + with pytest.raises(ValueError, match="auto_payment must be a boolean"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=1, # type: ignore + ) + + def test_auto_payment_rejects_non_boolean_none(self): + """Test that auto_payment validation rejects None values.""" + with pytest.raises(ValueError, match="auto_payment must be a boolean"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=None, # type: ignore + ) + + def test_auto_payment_rejects_non_boolean_dict(self): + """Test that auto_payment validation rejects dict values.""" + with pytest.raises(ValueError, match="auto_payment must be a boolean"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment={}, # type: ignore + ) + + def test_auto_payment_rejects_non_boolean_list(self): + """Test that auto_payment validation rejects list values.""" + with pytest.raises(ValueError, match="auto_payment must be a boolean"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=[], # type: ignore + ) + + def test_auto_payment_validation_error_message(self): + """Test that validation error message is descriptive.""" + with pytest.raises(ValueError) as exc_info: + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment="invalid", # type: ignore + ) + assert "auto_payment must be a boolean" in str(exc_info.value) + assert "str" in str(exc_info.value) + + def test_backward_compatibility_without_auto_payment(self): + """Test backward compatibility - config works without auto_payment field.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + # Should not raise any exception + assert config is not None + assert config.auto_payment is True + + def test_config_with_all_fields(self): + """Test configuration with all fields including auto_payment.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + region="us-east-1", + auto_payment=False, + ) + assert config.payment_manager_arn == "arn:aws:payment:us-east-1:123456789012:payment-manager/test" + assert config.user_id == "test-user" + assert config.payment_instrument_id == "test-instrument" + assert config.payment_session_id == "test-session" + assert config.region == "us-east-1" + assert config.auto_payment is False + + def test_auto_payment_field_is_accessible(self): + """Test that auto_payment field is accessible after initialization.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=False, + ) + # Should be able to access the field + assert hasattr(config, "auto_payment") + assert config.auto_payment is False + + +class TestAgentCorePaymentsPluginConfigOptionalFields: + """Test optional payment_instrument_id and payment_session_id fields.""" + + def test_config_without_instrument_and_session(self): + """Test config creation without payment_instrument_id and payment_session_id.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.payment_instrument_id is None + assert config.payment_session_id is None + + def test_config_with_instrument_and_session(self): + """Test config creation with explicit payment_instrument_id and payment_session_id.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="instrument-123", + payment_session_id="session-456", + ) + assert config.payment_instrument_id == "instrument-123" + assert config.payment_session_id == "session-456" + + def test_update_payment_instrument_id(self): + """Test updating payment_instrument_id after creation.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.payment_instrument_id is None + config.update_payment_instrument_id("new-instrument") + assert config.payment_instrument_id == "new-instrument" + + def test_update_payment_session_id(self): + """Test updating payment_session_id after creation.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.payment_session_id is None + config.update_payment_session_id("new-session") + assert config.payment_session_id == "new-session" + + def test_update_payment_instrument_id_empty_raises(self): + """Test that updating with empty string raises ValueError.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + with pytest.raises(ValueError, match="payment_instrument_id cannot be empty"): + config.update_payment_instrument_id("") + + def test_update_payment_session_id_empty_raises(self): + """Test that updating with empty string raises ValueError.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + with pytest.raises(ValueError, match="payment_session_id cannot be empty"): + config.update_payment_session_id("") + + +class TestAgentCorePaymentsPluginConfigMaxInterruptRetries: + """Test max_interrupt_retries configuration field.""" + + def test_max_interrupt_retries_defaults_to_5(self): + """Test that max_interrupt_retries defaults to 5.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.max_interrupt_retries == 5 + + def test_max_interrupt_retries_custom_value(self): + """Test setting a custom max_interrupt_retries value.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=10, + ) + assert config.max_interrupt_retries == 10 + + def test_max_interrupt_retries_zero_disables(self): + """Test setting max_interrupt_retries to 0.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=0, + ) + assert config.max_interrupt_retries == 0 + + +class TestAgentCorePaymentsPluginConfigAgentName: + """Test agent_name configuration field.""" + + def test_agent_name_defaults_to_none(self): + """Test that agent_name defaults to None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.agent_name is None + + def test_agent_name_can_be_set(self): + """Test that agent_name can be explicitly set.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + agent_name="my-agent", + ) + assert config.agent_name == "my-agent" + + def test_agent_name_included_in_full_config(self): + """Test configuration with all fields including agent_name.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + region="us-east-1", + auto_payment=True, + max_interrupt_retries=3, + agent_name="my-payment-agent", + ) + assert config.agent_name == "my-payment-agent" + assert config.payment_manager_arn == "arn:aws:payment:us-east-1:123456789012:payment-manager/test" + assert config.user_id == "test-user" + + def test_backward_compatibility_without_agent_name(self): + """Test backward compatibility - config works without agent_name field.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + assert config is not None + assert config.agent_name is None + + +class TestAgentCorePaymentsPluginConfigBearerAuth: + """Test bearer_token and token_provider configuration fields.""" + + def test_bearer_token_defaults_to_none(self): + """Test that bearer_token defaults to None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.bearer_token is None + + def test_token_provider_defaults_to_none(self): + """Test that token_provider defaults to None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.token_provider is None + + def test_bearer_token_can_be_set(self): + """Test that bearer_token can be explicitly set.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + bearer_token="eyJhbGciOiJSUzI1NiJ9.test", + ) + assert config.bearer_token == "eyJhbGciOiJSUzI1NiJ9.test" + + def test_token_provider_can_be_set(self): + """Test that token_provider can be set to a callable.""" + + def provider(): + return "fresh-token" + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + token_provider=provider, + ) + assert config.token_provider is provider + assert config.token_provider() == "fresh-token" + + def test_mutual_exclusivity_raises_value_error(self): + """Test that setting both bearer_token and token_provider raises ValueError.""" + with pytest.raises(ValueError, match="mutually exclusive"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + bearer_token="token", + token_provider=lambda: "token", + ) + + def test_bearer_token_with_all_fields(self): + """Test configuration with all fields including bearer_token.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + region="us-east-1", + auto_payment=True, + max_interrupt_retries=3, + agent_name="my-agent", + bearer_token="my-jwt", + ) + assert config.bearer_token == "my-jwt" + assert config.agent_name == "my-agent" + assert config.token_provider is None + + def test_backward_compatibility_without_bearer_fields(self): + """Test backward compatibility - config works without bearer fields.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + assert config.bearer_token is None + assert config.token_provider is None + + +class TestAgentCorePaymentsPluginConfigUserId: + """Test user_id behavior with SigV4 vs bearer auth.""" + + def test_user_id_required_for_sigv4(self): + """Test that user_id is required when no bearer auth is configured.""" + with pytest.raises(ValueError, match="user_id is required for SigV4"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + ) + + def test_user_id_optional_with_bearer_token(self): + """Test that user_id is optional when bearer_token is set.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + bearer_token="my-jwt", + ) + assert config.user_id is None + assert config.bearer_token == "my-jwt" + + def test_user_id_optional_with_token_provider(self): + """Test that user_id is optional when token_provider is set.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + token_provider=lambda: "fresh", + ) + assert config.user_id is None + + def test_user_id_can_be_set_with_bearer_token(self): + """Test that user_id can still be provided with bearer auth.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="explicit-user", + bearer_token="my-jwt", + ) + assert config.user_id == "explicit-user" + assert config.bearer_token == "my-jwt" + + +class TestAgentCorePaymentsPluginConfigBearerTokenValidation: + """Test bearer_token and token_provider type validation.""" + + def test_bearer_token_non_string_raises_error(self): + """Test that non-string bearer_token raises ValueError.""" + with pytest.raises(ValueError, match="bearer_token must be a string"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + bearer_token=12345, # type: ignore + ) + + def test_token_provider_non_callable_raises_error(self): + """Test that non-callable token_provider raises ValueError.""" + with pytest.raises(ValueError, match="token_provider must be callable"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + token_provider="not-callable", # type: ignore + ) + + +class TestAgentCorePaymentsPluginConfigPaymentToolAllowlist: + """Test payment_tool_allowlist configuration field.""" + + def test_payment_tool_allowlist_accepts_valid_list(self): + """Test that payment_tool_allowlist accepts a valid list of strings.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_tool_allowlist=["http_request", "api_call", "fetch_data"], + ) + assert config.payment_tool_allowlist == ["http_request", "api_call", "fetch_data"] + + def test_payment_tool_allowlist_rejects_non_list(self): + """Test that payment_tool_allowlist rejects non-list values.""" + with pytest.raises(ValueError, match="payment_tool_allowlist must be a list"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_tool_allowlist="http_request", # type: ignore + ) + + def test_payment_tool_allowlist_rejects_list_with_non_strings(self): + """Test that payment_tool_allowlist rejects list with non-string entries.""" + with pytest.raises(ValueError, match="All entries in payment_tool_allowlist must be strings"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_tool_allowlist=["http_request", 123, "api_call"], # type: ignore + ) + + def test_payment_tool_allowlist_defaults_to_none(self): + """Test that payment_tool_allowlist defaults to None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + ) + assert config.payment_tool_allowlist is None + + +class TestAgentCorePaymentsPluginConfigWhitespaceUserId: + """Test whitespace-only user_id validation.""" + + def test_whitespace_only_user_id_raises_value_error(self): + """Test that whitespace-only user_id raises ValueError.""" + with pytest.raises(ValueError, match="user_id cannot be whitespace-only"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id=" ", + ) + + def test_whitespace_tabs_user_id_raises_value_error(self): + """Test that tabs-only user_id raises ValueError.""" + with pytest.raises(ValueError, match="user_id cannot be whitespace-only"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="\t\t", + ) diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py b/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py new file mode 100644 index 00000000..9dfb90f9 --- /dev/null +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py @@ -0,0 +1,1767 @@ +"""Unit tests for AgentCorePaymentsPlugin.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig +from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin +from bedrock_agentcore.payments.manager import ( + PaymentError, + PaymentInstrumentConfigurationRequired, + PaymentInstrumentNotFound, + PaymentSessionConfigurationRequired, + PaymentSessionExpired, +) + + +def _create_mock_agent(): + """Create a mock agent with a dict-backed state object.""" + agent = MagicMock() + state_store = {} + + def state_get(key=None): + if key is None: + return dict(state_store) + return state_store.get(key) + + def state_set(key, value): + state_store[key] = value + + def state_delete(key): + state_store.pop(key, None) + + agent.state.get = MagicMock(side_effect=state_get) + agent.state.set = MagicMock(side_effect=state_set) + agent.state.delete = MagicMock(side_effect=state_delete) + agent._state_store = state_store # expose for test assertions + return agent + + +def _setup_plugin_with_agent(config, mock_pm_instance=None): + """Create a plugin and initialize it with a mock agent.""" + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + if mock_pm_instance: + with patch( + "bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager", return_value=mock_pm_instance + ): + plugin.init_agent(agent) + else: + with patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager"): + plugin.init_agent(agent) + if mock_pm_instance: + plugin.payment_manager = mock_pm_instance + return plugin, agent + + +def _create_event_with_agent(event_attrs=None, agent=None): + """Create a mock event with an agent that has dict-backed state.""" + if agent is None: + agent = _create_mock_agent() + event = MagicMock() + event.agent = agent + if event_attrs: + for k, v in event_attrs.items(): + setattr(event, k, v) + return event, agent + + +class TestAgentCorePaymentsPluginInitialization: + """Tests for plugin initialization.""" + + def test_plugin_init_with_valid_config(self): + """Test plugin initialization with valid configuration.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + region="us-west-2", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + assert plugin.config == config + assert plugin.payment_manager is None + assert plugin.name == "agent-core-payments-plugin" + + def test_plugin_init_with_network_preferences(self): + """Test plugin initialization with network preferences.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + region="us-west-2", + network_preferences_config=["eip155:1", "solana:mainnet"], + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + assert plugin.config.network_preferences_config == ["eip155:1", "solana:mainnet"] + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_init_agent_success(self, mock_payment_manager_class): + """Test successful agent initialization.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + region="us-west-2", + ) + + mock_pm_instance = MagicMock() + mock_payment_manager_class.return_value = mock_pm_instance + + plugin = AgentCorePaymentsPlugin(config=config) + mock_agent = MagicMock() + + plugin.init_agent(mock_agent) + + mock_payment_manager_class.assert_called_once_with( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + region_name="us-west-2", + agent_name=None, + bearer_token=None, + token_provider=None, + ) + assert plugin.payment_manager == mock_pm_instance + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_init_agent_failure(self, mock_payment_manager_class): + """Test agent initialization failure.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + region="us-west-2", + ) + + mock_payment_manager_class.side_effect = Exception("Connection failed") + + plugin = AgentCorePaymentsPlugin(config=config) + mock_agent = MagicMock() + + with pytest.raises(RuntimeError, match="Failed to initialize PaymentManager"): + plugin.init_agent(mock_agent) + + +class TestBeforeToolCall: + """Tests for before_tool_call hook.""" + + def test_before_tool_call_no_payment_failure(self): + """Test before_tool_call when no payment failure is stored.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.invocation_state = {} + event.interrupt = MagicMock() + + plugin.before_tool_call(event) + + event.interrupt.assert_not_called() + + def test_before_tool_call_with_payment_failure(self): + """Test before_tool_call when payment failure is stored.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + tool_use_id = "tool-use-123" + failure_info = { + "toolUseId": tool_use_id, + "exceptionType": "PaymentError", + "exceptionMessage": "Insufficient budget", + } + + event, agent = _create_event_with_agent( + { + "invocation_state": {f"payment_failure_{tool_use_id}": failure_info}, + "tool_use": {"name": "test_tool"}, + } + ) + event.interrupt = MagicMock() + + plugin.before_tool_call(event) + + event.interrupt.assert_called_once() + call_args = event.interrupt.call_args + assert call_args[0][0].startswith(f"payment-failure-{tool_use_id}") + assert call_args[1]["reason"] == failure_info + assert f"payment_failure_{tool_use_id}" not in event.invocation_state + + def test_before_tool_call_multiple_failures(self): + """Test before_tool_call with multiple payment failures.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + tool_use_id_1 = "tool-use-123" + tool_use_id_2 = "tool-use-456" + failure_info_1 = {"toolUseId": tool_use_id_1, "exceptionType": "PaymentError"} + failure_info_2 = {"toolUseId": tool_use_id_2, "exceptionType": "PaymentError"} + + event, agent = _create_event_with_agent( + { + "invocation_state": { + f"payment_failure_{tool_use_id_1}": failure_info_1, + f"payment_failure_{tool_use_id_2}": failure_info_2, + }, + "tool_use": {"name": "test_tool"}, + } + ) + event.interrupt = MagicMock() + + plugin.before_tool_call(event) + + # Should only interrupt for the first failure found + event.interrupt.assert_called_once() + + +class TestAfterToolCall: + """Tests for after_tool_call hook.""" + + def test_after_tool_call_no_result(self): + """Test after_tool_call when result is None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "result": None, + "invocation_state": {}, + "retry": False, + "tool_use": {"name": "test_tool", "toolUseId": "tool-123"}, + } + ) + + plugin.after_tool_call(event) + + # Should return early without processing + assert event.retry is False + + def test_after_tool_call_non_402_status(self): + """Test after_tool_call with non-402 status code.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "result": [{"text": "Status Code: 200"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + # Should not set retry for non-402 status + assert event.retry is False + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_after_tool_call_no_handler_for_tool(self, mock_payment_manager_class): + """Test after_tool_call with generic handler for unknown tools.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64-encoded"} + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "unknown_tool", "toolUseId": "tool-123", "input": {}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is True + mock_pm_instance.generate_payment_header.assert_called_once() + assert "X-PAYMENT" in event.tool_use["input"]["headers"] + assert event.tool_use["input"]["headers"]["X-PAYMENT"] == "base64-encoded" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_after_tool_call_402_payment_success(self, mock_payment_manager_class): + """Test after_tool_call successfully processes 402 payment.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64-encoded-payment"} + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is True + mock_pm_instance.generate_payment_header.assert_called_once() + assert "X-PAYMENT" in event.tool_use["input"]["headers"] + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_after_tool_call_payment_error(self, mock_payment_manager_class): + """Test after_tool_call handles payment error.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.side_effect = PaymentInstrumentNotFound("Instrument not found") + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert "payment_failure_tool-123" in event.invocation_state + + def test_after_tool_call_402_validation_failure_skips_payment(self): + """Test after_tool_call skips payment processing when tool input validation fails.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": "not a dict"}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + # Should not call generate_payment_header + mock_pm_instance.generate_payment_header.assert_not_called() + assert event.retry is False + assert "payment_failure_tool-123" in event.invocation_state + + def test_after_tool_call_retry_limit_reached(self): + """Test after_tool_call when retry limit is reached.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.result = [{"text": "Status Code: 402"}] + event.tool_use = {"name": "http_request", "toolUseId": "tool-123", "input": {}} + event.invocation_state = {"payment_retry_count_tool-123": 3} + event.retry = False + + plugin.after_tool_call(event) + + # Should return early without processing + assert event.retry is False + + +class TestCheckPaymentRetryLimit: + """Tests for _check_payment_retry_limit method.""" + + def test_retry_limit_not_reached(self): + """Test when retry limit has not been reached.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event, agent = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 1}, + } + ) + + result = plugin._check_payment_retry_limit(event) + assert result is False + + def test_retry_limit_reached(self): + """Test when retry limit has been reached.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event, agent = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 3}, + } + ) + + result = plugin._check_payment_retry_limit(event) + assert result is True + + def test_retry_limit_no_prior_attempts(self): + """Test when no prior retry attempts exist.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event, agent = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {}, + } + ) + + result = plugin._check_payment_retry_limit(event) + assert result is False + + +class TestIncrementPaymentRetryCount: + """Tests for _increment_payment_retry_count method.""" + + def test_increment_from_zero(self): + """Test incrementing retry count from zero.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.tool_use = {"toolUseId": "tool-123"} + event.invocation_state = {} + + plugin._increment_payment_retry_count(event) + + assert event.invocation_state["payment_retry_count_tool-123"] == 1 + + def test_increment_from_existing_count(self): + """Test incrementing retry count from existing value.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.tool_use = {"toolUseId": "tool-123"} + event.invocation_state = {"payment_retry_count_tool-123": 2} + + plugin._increment_payment_retry_count(event) + + assert event.invocation_state["payment_retry_count_tool-123"] == 3 + + +class TestStorePaymentFailureState: + """Tests for _store_payment_failure_state method.""" + + def test_store_payment_failure_state(self): + """Test storing payment failure state.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.tool_use = { + "toolUseId": "tool-123", + "name": "http_request", + "input": {"url": "https://example.com"}, + } + event.invocation_state = {} + + exception = PaymentInstrumentNotFound("Instrument not found") + + plugin._store_payment_failure_state(event, exception) + + failure_key = "payment_failure_tool-123" + assert failure_key in event.invocation_state + + failure_info = event.invocation_state[failure_key] + assert failure_info["tool"] == "http_request" + assert failure_info["toolUseId"] == "tool-123" + assert failure_info["exceptionType"] == "PaymentInstrumentNotFound" + assert failure_info["exceptionMessage"] == "Instrument not found" + assert failure_info["maxRetries"] == 3 + + def test_store_payment_failure_state_with_retry_count(self): + """Test storing payment failure state with existing retry count.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.tool_use = { + "toolUseId": "tool-123", + "name": "http_request", + "input": {}, + } + event.invocation_state = {"payment_retry_count_tool-123": 2} + + exception = PaymentSessionExpired("Session expired") + + plugin._store_payment_failure_state(event, exception) + + failure_info = event.invocation_state["payment_failure_tool-123"] + assert failure_info["retryAttempt"] == 2 + + +class TestProcessX402Payment: + """Tests for _process_x402_payment method.""" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_process_x402_payment_success(self, mock_payment_manager_class): + """Test successful X.402 payment processing.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64-encoded"} + mock_payment_manager_class.return_value = mock_pm_instance + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + x402_response = { + "statusCode": 402, + "headers": {"X-Payment-Required": "true"}, + "body": {"scheme": "exact", "network": "ethereum"}, + } + + result = plugin._process_payment_required_request(x402_response) + + assert result == {"X-PAYMENT": "base64-encoded"} + mock_pm_instance.generate_payment_header.assert_called_once() + + def test_process_x402_payment_no_payment_manager(self): + """Test X.402 payment processing when PaymentManager is not initialized.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = None + + x402_response = { + "statusCode": 402, + "headers": {}, + "body": {}, + } + + with pytest.raises(PaymentError, match="PaymentManager not initialized"): + plugin._process_payment_required_request(x402_response) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_process_x402_payment_with_network_preferences(self, mock_payment_manager_class): + """Test X.402 payment processing with network preferences.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + network_preferences_config=["eip155:1", "solana:mainnet"], + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64-encoded"} + mock_payment_manager_class.return_value = mock_pm_instance + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + x402_response = { + "statusCode": 402, + "headers": {}, + "body": {}, + } + + plugin._process_payment_required_request(x402_response) + + # Verify network_preferences were passed + call_kwargs = mock_pm_instance.generate_payment_header.call_args[1] + assert call_kwargs["network_preferences"] == ["eip155:1", "solana:mainnet"] + + +class TestAfterToolCallEdgeCases: + """Tests for edge cases in after_tool_call hook.""" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_after_tool_call_handler_apply_header_fails(self, mock_payment_manager_class): + """Test after_tool_call when handler fails to apply payment header.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64-encoded"} + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": "not a dict"}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + # Should store failure state when handler fails + assert "payment_failure_tool-123" in event.invocation_state + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_after_tool_call_unexpected_exception(self, mock_payment_manager_class): + """Test after_tool_call handles unexpected exceptions.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.side_effect = Exception("Unexpected error") + mock_payment_manager_class.return_value = mock_pm_instance + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [ + {"text": "Status Code: 402"}, + {"text": "Headers: {}"}, + {"text": "Body: {}"}, + ], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + # Should not set retry for unexpected exceptions + assert event.retry is False + + +class TestProcessX402PaymentEdgeCases: + """Tests for edge cases in _process_x402_payment method.""" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_process_x402_payment_with_uuid_generation(self, mock_payment_manager_class): + """Test that _process_x402_payment generates a client token.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64-encoded"} + mock_payment_manager_class.return_value = mock_pm_instance + + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + x402_response = { + "statusCode": 402, + "headers": {}, + "body": {}, + } + + plugin._process_payment_required_request(x402_response) + + # Verify client_token was passed and is a valid UUID string + call_kwargs = mock_pm_instance.generate_payment_header.call_args[1] + client_token = call_kwargs["client_token"] + assert isinstance(client_token, str) + # Verify it's a valid UUID format + assert len(client_token) == 36 # UUID string length + + +class TestBeforeToolCallEdgeCases: + """Tests for edge cases in before_tool_call hook.""" + + def test_before_tool_call_with_non_payment_failure_keys(self): + """Test before_tool_call ignores non-payment-failure keys.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.invocation_state = { + "other_key": "value", + "payment_retry_count_tool-123": 1, + } + event.interrupt = MagicMock() + + plugin.before_tool_call(event) + + # Should not interrupt for non-payment-failure keys + event.interrupt.assert_not_called() + + +class TestPluginMaxRetries: + """Tests for MAX_PAYMENT_RETRIES constant.""" + + def test_max_payment_retries_constant(self): + """Test that MAX_PAYMENT_RETRIES is set correctly.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + assert plugin.MAX_PAYMENT_RETRIES == 3 + + def test_retry_count_increments_to_max(self): + """Test that retry count can increment to MAX_PAYMENT_RETRIES.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + event = MagicMock() + event.tool_use = {"toolUseId": "tool-123"} + event.invocation_state = {} + + # Increment to max + for _ in range(plugin.MAX_PAYMENT_RETRIES): + plugin._increment_payment_retry_count(event) + + assert event.invocation_state["payment_retry_count_tool-123"] == plugin.MAX_PAYMENT_RETRIES + + # Next check should return True (limit reached) + result = plugin._check_payment_retry_limit(event) + assert result is True + + +class TestProcessPaymentRequiredConfigChecks: + """Tests for _process_payment_required_request config validation.""" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_raises_instrument_config_required_when_none(self, mock_payment_manager_class): + """Test that PaymentInstrumentConfigurationRequired is raised when instrument_id is None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + with pytest.raises(PaymentInstrumentConfigurationRequired, match="payment_instrument_id is required"): + plugin._process_payment_required_request({"statusCode": 402, "headers": {}, "body": {}}) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_raises_session_config_required_when_none(self, mock_payment_manager_class): + """Test that PaymentSessionConfigurationRequired is raised when session_id is None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + with pytest.raises(PaymentSessionConfigurationRequired, match="payment_session_id is required"): + plugin._process_payment_required_request({"statusCode": 402, "headers": {}, "body": {}}) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_raises_instrument_before_session_when_both_none(self, mock_payment_manager_class): + """Test that instrument check comes before session check when both are None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + with pytest.raises(PaymentInstrumentConfigurationRequired): + plugin._process_payment_required_request({"statusCode": 402, "headers": {}, "body": {}}) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_succeeds_after_config_update(self, mock_payment_manager_class): + """Test that payment processing succeeds after updating config.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + # First call fails + with pytest.raises(PaymentInstrumentConfigurationRequired): + plugin._process_payment_required_request({"statusCode": 402, "headers": {}, "body": {}}) + + # Update config + config.update_payment_instrument_id("instrument-123") + config.update_payment_session_id("session-456") + + # Second call succeeds + result = plugin._process_payment_required_request({"statusCode": 402, "headers": {}, "body": {}}) + assert result == {"X-PAYMENT": "base64"} + + +class TestInterruptRetryLimit: + """Tests for interrupt retry limit functionality.""" + + def test_check_interrupt_retry_limit_no_agent(self): + """Test that limit is reached when agent is None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + assert plugin._check_interrupt_retry_limit(None, "tool-123") is True + + def test_check_interrupt_retry_limit_zero_max(self): + """Test that limit is reached when max_interrupt_retries is 0.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=0, + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + assert plugin._check_interrupt_retry_limit(agent, "tool-123") is True + + def test_check_interrupt_retry_limit_not_reached(self): + """Test that limit is not reached with count below max.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=5, + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + assert plugin._check_interrupt_retry_limit(agent, "tool-123") is False + + def test_check_interrupt_retry_limit_reached(self): + """Test that limit is reached when count equals max.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=3, + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + for _ in range(3): + plugin._increment_interrupt_retry_count(agent, "tool-123") + + assert plugin._check_interrupt_retry_limit(agent, "tool-123") is True + + def test_increment_interrupt_retry_count(self): + """Test incrementing interrupt retry count in agent state.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + plugin._increment_interrupt_retry_count(agent, "tool-123") + assert agent._state_store["payment_interrupt_retry_tool-123"] == 1 + + plugin._increment_interrupt_retry_count(agent, "tool-123") + assert agent._state_store["payment_interrupt_retry_tool-123"] == 2 + + def test_increment_interrupt_retry_count_no_agent(self): + """Test that increment is a no-op when agent is None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Should not raise + plugin._increment_interrupt_retry_count(None, "tool-123") + + def test_interrupt_retry_independent_per_tool(self): + """Test that interrupt retry counts are independent per tool_use_id.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=2, + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + plugin._increment_interrupt_retry_count(agent, "tool-1") + plugin._increment_interrupt_retry_count(agent, "tool-1") + plugin._increment_interrupt_retry_count(agent, "tool-2") + + assert plugin._check_interrupt_retry_limit(agent, "tool-1") is True + assert plugin._check_interrupt_retry_limit(agent, "tool-2") is False + + +class TestBeforeToolCallInterruptRetry: + """Tests for interrupt retry limit in before_tool_call.""" + + def test_before_tool_call_skips_interrupt_when_limit_reached(self): + """Test that before_tool_call skips interrupt when interrupt retry limit is reached.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=1, + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + # Simulate one interrupt already happened + plugin._increment_interrupt_retry_count(agent, "tool-123") + + failure_info = { + "toolUseId": "tool-123", + "exceptionType": "PaymentError", + "exceptionMessage": "Some error", + } + event, _ = _create_event_with_agent( + { + "invocation_state": {"payment_failure_tool-123": failure_info}, + "tool_use": {"name": "test_tool"}, + }, + agent=agent, + ) + event.interrupt = MagicMock() + + plugin.before_tool_call(event) + + event.interrupt.assert_not_called() + assert "payment_failure_tool-123" not in event.invocation_state + + def test_before_tool_call_raises_interrupt_when_below_limit(self): + """Test that before_tool_call raises interrupt when below limit.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=5, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + failure_info = { + "toolUseId": "tool-123", + "exceptionType": "PaymentInstrumentConfigurationRequired", + "exceptionMessage": "payment_instrument_id is not set.", + } + event, agent = _create_event_with_agent( + { + "invocation_state": {"payment_failure_tool-123": failure_info}, + "tool_use": {"name": "test_tool"}, + } + ) + event.interrupt = MagicMock() + + plugin.before_tool_call(event) + + event.interrupt.assert_called_once() + assert agent._state_store["payment_interrupt_retry_tool-123"] == 1 + + +class TestCheckPaymentRetryLimitWithInterrupt: + """Tests for _check_payment_retry_limit (payment-only, decoupled from interrupt).""" + + def test_returns_false_when_interrupt_limit_reached_but_payment_below(self): + """Test that _check_payment_retry_limit returns False even when interrupt limit is reached. + + Interrupt limits only gate interrupts in before_tool_call, not 402 payment processing. + """ + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=2, + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + plugin._increment_interrupt_retry_count(agent, "tool-123") + plugin._increment_interrupt_retry_count(agent, "tool-123") + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {}, + }, + agent=agent, + ) + + result = plugin._check_payment_retry_limit(event) + assert result is False + + def test_returns_false_when_payment_retries_below_limit(self): + """Test returns False when payment retries are below limit.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + max_interrupt_retries=5, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + event, agent = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 1}, + } + ) + + result = plugin._check_payment_retry_limit(event) + assert result is False + + def test_max_interrupt_retries_zero_does_not_block_payment_processing(self): + """Test that max_interrupt_retries=0 does not block 402 payment processing.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + max_interrupt_retries=0, + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is True + mock_pm_instance.generate_payment_header.assert_called_once() + + +class TestResetInterruptRetryCount: + """Tests for _reset_interrupt_retry_count method.""" + + def test_reset_clears_state(self): + """Test that reset deletes the interrupt retry key from agent state.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + agent = _create_mock_agent() + + plugin._increment_interrupt_retry_count(agent, "tool-123") + assert agent._state_store.get("payment_interrupt_retry_tool-123") == 1 + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + }, + agent=agent, + ) + plugin._reset_interrupt_retry_count(event) + assert "payment_interrupt_retry_tool-123" not in agent._state_store + + def test_reset_no_agent_is_noop(self): + """Test that reset is a no-op when agent is None.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + event = MagicMock() + event.agent = None + event.tool_use = {"toolUseId": "tool-123"} + plugin._reset_interrupt_retry_count(event) + + def test_reset_after_successful_payment(self): + """Test that successful payment processing resets the interrupt retry count.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + max_interrupt_retries=3, + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + agent = _create_mock_agent() + # Simulate prior interrupt retries + plugin._increment_interrupt_retry_count(agent, "tool-123") + plugin._increment_interrupt_retry_count(agent, "tool-123") + assert agent._state_store["payment_interrupt_retry_tool-123"] == 2 + + # Trigger successful payment processing via after_tool_call + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + }, + agent=agent, + ) + + plugin.after_tool_call(event) + + assert event.retry is True + assert "payment_interrupt_retry_tool-123" not in agent._state_store + + +class TestAgentCorePaymentsPluginAgentName: + """Tests for agent_name propagation from config to PaymentManager.""" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_init_agent_passes_agent_name_to_payment_manager(self, mock_payment_manager_class): + """Test that agent_name from config is passed to PaymentManager.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + region="us-west-2", + agent_name="my-agent", + ) + + mock_pm_instance = MagicMock() + mock_payment_manager_class.return_value = mock_pm_instance + + plugin = AgentCorePaymentsPlugin(config=config) + mock_agent = MagicMock() + + plugin.init_agent(mock_agent) + + mock_payment_manager_class.assert_called_once_with( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + region_name="us-west-2", + agent_name="my-agent", + bearer_token=None, + token_provider=None, + ) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_init_agent_passes_none_agent_name_when_not_set(self, mock_payment_manager_class): + """Test that agent_name defaults to None when not set in config.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + region="us-west-2", + ) + + mock_pm_instance = MagicMock() + mock_payment_manager_class.return_value = mock_pm_instance + + plugin = AgentCorePaymentsPlugin(config=config) + mock_agent = MagicMock() + + plugin.init_agent(mock_agent) + + mock_payment_manager_class.assert_called_once_with( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + region_name="us-west-2", + agent_name=None, + bearer_token=None, + token_provider=None, + ) + + +class TestIsPostPaymentFailure: + """Tests for _is_post_payment_failure method.""" + + def test_returns_false_on_first_attempt(self): + """Test that first 402 is not treated as a post-payment failure.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 1}, + } + ) + + body = {"error": "invalid_exact_evm_insufficient_balance"} + assert plugin._is_post_payment_failure(event, body) is False + + def test_returns_true_on_second_attempt_with_non_payment_error(self): + """Test that a non-'payment required' error on retry is detected as post-payment failure.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 2}, + } + ) + + body = {"error": "invalid_exact_evm_insufficient_balance"} + assert plugin._is_post_payment_failure(event, body) is True + + def test_returns_false_on_second_attempt_with_payment_required_error(self): + """Test that 'payment required' error on retry is NOT treated as post-payment failure.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 2}, + } + ) + + body = {"error": "Payment required"} + assert plugin._is_post_payment_failure(event, body) is False + + def test_returns_false_when_body_is_none(self): + """Test that None body is not treated as post-payment failure.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 2}, + } + ) + + assert plugin._is_post_payment_failure(event, None) is False + + def test_returns_false_when_body_has_empty_error(self): + """Test that empty error string is not treated as post-payment failure.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 2}, + } + ) + + body = {"error": ""} + assert plugin._is_post_payment_failure(event, body) is False + + def test_returns_false_when_body_has_no_error_key(self): + """Test that body without error key is not treated as post-payment failure.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + ) + plugin = AgentCorePaymentsPlugin(config=config) + + event, _ = _create_event_with_agent( + { + "tool_use": {"toolUseId": "tool-123"}, + "invocation_state": {"payment_retry_count_tool-123": 2}, + } + ) + + body = {"statusCode": 402} + assert plugin._is_post_payment_failure(event, body) is False + + +class TestExtractPaymentErrorMessage: + """Tests for _extract_payment_error_message static method.""" + + def test_extracts_error_from_body(self): + """Test extracting error message from body dict.""" + body = {"error": "invalid_exact_evm_insufficient_balance"} + assert AgentCorePaymentsPlugin._extract_payment_error_message(body) == "invalid_exact_evm_insufficient_balance" + + def test_returns_unknown_for_none_body(self): + """Test returns 'unknown error' when body is None.""" + assert AgentCorePaymentsPlugin._extract_payment_error_message(None) == "unknown error" + + def test_returns_unknown_for_empty_error(self): + """Test returns 'unknown error' when error is empty string.""" + body = {"error": ""} + assert AgentCorePaymentsPlugin._extract_payment_error_message(body) == "unknown error" + + def test_returns_unknown_for_missing_error_key(self): + """Test returns 'unknown error' when error key is missing.""" + body = {"statusCode": 402} + assert AgentCorePaymentsPlugin._extract_payment_error_message(body) == "unknown error" + + def test_returns_unknown_for_non_string_error(self): + """Test returns 'unknown error' when error is not a string.""" + body = {"error": 42} + assert AgentCorePaymentsPlugin._extract_payment_error_message(body) == "unknown error" + + +class TestAfterToolCallPostPaymentFailure: + """Tests for the post-payment failure path in after_tool_call.""" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_post_payment_failure_stores_failure_and_does_not_retry(self, mock_payment_manager_class): + """Test that a 402 with non-payment-required error after retry stores failure without retrying.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + # Simulate second 402 after a payment retry (retry count already at 1) + error_body = { + "x402Version": 2, + "error": "invalid_exact_evm_insufficient_balance", + "resource": {"url": "https://example.com"}, + "accepts": [{"scheme": "exact", "network": "eip155:84532", "amount": "1000"}], + } + event, _ = _create_event_with_agent( + { + "result": [ + {"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': error_body})}"} + ], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {"payment_retry_count_tool-123": 1}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + # Should NOT retry + assert event.retry is False + # Should store failure state + assert "payment_failure_tool-123" in event.invocation_state + failure = event.invocation_state["payment_failure_tool-123"] + assert "invalid_exact_evm_insufficient_balance" in failure["exceptionMessage"] + # Should NOT have called generate_payment_header + mock_pm_instance.generate_payment_header.assert_not_called() + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_first_402_with_payment_required_error_proceeds_normally(self, mock_payment_manager_class): + """Test that the first 402 with 'Payment required' error processes payment normally.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64-encoded"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + payment_body = { + "x402Version": 2, + "error": "Payment required", + "accepts": [{"scheme": "exact"}], + } + event, _ = _create_event_with_agent( + { + "result": [ + { + "text": ( + f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': payment_body})}" + ) + } + ], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + # Should retry with payment + assert event.retry is True + mock_pm_instance.generate_payment_header.assert_called_once() + + +class TestAfterToolCallAutoPaymentDisabled: + """Tests for auto_payment=False in after_tool_call.""" + + def test_auto_payment_disabled_skips_processing(self): + """Test that after_tool_call skips payment processing when auto_payment is False.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + auto_payment=False, + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is False + mock_pm_instance.generate_payment_header.assert_not_called() + + +class TestAfterToolCallPaymentErrorNoRetry: + """Tests for PaymentError exception path — should NOT set retry.""" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_payment_error_does_not_set_retry(self, mock_payment_manager_class): + """Test that PaymentError stores failure but does NOT set event.retry=True.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.side_effect = PaymentError("Payment processing failed") + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + # Should NOT retry on PaymentError + assert event.retry is False + # Should store failure + assert "payment_failure_tool-123" in event.invocation_state + + +class TestAfterToolCallConfigurationErrors: + """Tests for PaymentInstrumentConfigurationRequired and PaymentSessionConfigurationRequired in after_tool_call.""" + + def test_instrument_config_required_stores_failure_no_retry(self): + """Test that PaymentInstrumentConfigurationRequired stores failure without retry.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + # No payment_instrument_id — will trigger PaymentInstrumentConfigurationRequired + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is False + assert "payment_failure_tool-123" in event.invocation_state + failure = event.invocation_state["payment_failure_tool-123"] + assert failure["exceptionType"] == "PaymentInstrumentConfigurationRequired" + + def test_session_config_required_stores_failure_no_retry(self): + """Test that PaymentSessionConfigurationRequired stores failure without retry.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="instrument-123", + # No payment_session_id — will trigger PaymentSessionConfigurationRequired + ) + + mock_pm_instance = MagicMock() + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is False + assert "payment_failure_tool-123" in event.invocation_state + failure = event.invocation_state["payment_failure_tool-123"] + assert failure["exceptionType"] == "PaymentSessionConfigurationRequired" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_generic_exception_stores_failure_no_retry(self, mock_payment_manager_class): + """Test that unexpected Exception stores failure without retry.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="instrument-123", + payment_session_id="session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.side_effect = RuntimeError("Something unexpected") + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is False + assert "payment_failure_tool-123" in event.invocation_state + failure = event.invocation_state["payment_failure_tool-123"] + assert failure["exceptionType"] == "RuntimeError" + + +class TestPaymentToolAllowlist: + """Tests for payment_tool_allowlist behavior in after_tool_call.""" + + def test_allowlist_blocks_non_listed_tool(self): + """Test that a tool not in the allowlist is skipped for payment processing.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="instrument-123", + payment_session_id="session-456", + payment_tool_allowlist=["http_request"], + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64token"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "not_in_allowlist", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + plugin.after_tool_call(event) + + assert event.retry is False + mock_pm_instance.generate_payment_header.assert_not_called() + + def test_allowlist_none_allows_all_tools(self): + """Test that allowlist=None allows all tools (default behavior).""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="instrument-123", + payment_session_id="session-456", + payment_tool_allowlist=None, + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64token"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, _ = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "any_tool_name", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + + with patch("bedrock_agentcore.payments.integrations.strands.plugin.get_payment_handler") as mock_get_handler: + mock_handler = MagicMock() + mock_handler.extract_status_code.return_value = 402 + mock_handler.extract_headers.return_value = {} + mock_handler.extract_body.return_value = {} + mock_handler.validate_tool_input.return_value = True + mock_handler.apply_payment_header.return_value = True + mock_get_handler.return_value = mock_handler + + plugin.after_tool_call(event) + + mock_handler.extract_status_code.assert_called_once() + mock_pm_instance.generate_payment_header.assert_called_once() diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py new file mode 100644 index 00000000..9de2fc57 --- /dev/null +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py @@ -0,0 +1,1683 @@ +"""Tests for payment tools utilities.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from bedrock_agentcore.payments.integrations.strands.tools import ( + format_error_response, + format_success_response, + validate_required_params, +) + + +class TestValidateRequiredParams: + """Test validate_required_params function.""" + + def test_valid_required_params(self): + """Test validation passes with all required parameters.""" + params = {"user_id": "test-user", "payment_id": "test-payment"} + result = validate_required_params(params, required=["user_id", "payment_id"]) + assert result is None + + def test_missing_required_param(self): + """Test validation fails when required parameter is missing.""" + params = {"user_id": "test-user"} + result = validate_required_params(params, required=["user_id", "payment_id"]) + assert result is not None + assert result["error"] == "ValidationError" + assert "Missing required parameter: payment_id" in result["message"] + + def test_empty_string_required_param(self): + """Test validation fails when required parameter is empty string.""" + params = {"user_id": "", "payment_id": "test-payment"} + result = validate_required_params(params, required=["user_id", "payment_id"]) + assert result is not None + assert result["error"] == "ValidationError" + assert "Parameter cannot be empty: user_id" in result["message"] + + def test_whitespace_only_required_param(self): + """Test validation fails when required parameter is whitespace only.""" + params = {"user_id": " ", "payment_id": "test-payment"} + result = validate_required_params(params, required=["user_id", "payment_id"]) + assert result is not None + assert result["error"] == "ValidationError" + assert "Parameter cannot be empty: user_id" in result["message"] + + def test_optional_params_not_provided(self): + """Test validation passes when optional parameters are not provided.""" + params = {"user_id": "test-user"} + result = validate_required_params(params, required=["user_id"], optional=["payment_id", "connector_id"]) + assert result is None + + def test_optional_params_provided_valid(self): + """Test validation passes when optional parameters are provided and valid.""" + params = {"user_id": "test-user", "payment_id": "test-payment", "connector_id": "test-connector"} + result = validate_required_params(params, required=["user_id"], optional=["payment_id", "connector_id"]) + assert result is None + + def test_optional_param_empty_string(self): + """Test validation fails when optional parameter is empty string.""" + params = {"user_id": "test-user", "payment_id": ""} + result = validate_required_params(params, required=["user_id"], optional=["payment_id"]) + assert result is not None + assert result["error"] == "ValidationError" + assert "Parameter cannot be empty: payment_id" in result["message"] + + def test_optional_param_whitespace_only(self): + """Test validation fails when optional parameter is whitespace only.""" + params = {"user_id": "test-user", "payment_id": " "} + result = validate_required_params(params, required=["user_id"], optional=["payment_id"]) + assert result is not None + assert result["error"] == "ValidationError" + assert "Parameter cannot be empty: payment_id" in result["message"] + + def test_non_string_optional_param_not_validated(self): + """Test validation passes for non-string optional parameters.""" + params = {"user_id": "test-user", "max_results": 100} + result = validate_required_params(params, required=["user_id"], optional=["max_results"]) + assert result is None + + def test_non_string_required_param_not_validated(self): + """Test validation passes for non-string required parameters.""" + params = {"user_id": "test-user", "count": 5} + result = validate_required_params(params, required=["user_id", "count"]) + assert result is None + + def test_multiple_missing_params_reports_first(self): + """Test validation reports first missing parameter.""" + params = {} + result = validate_required_params(params, required=["user_id", "payment_id"]) + assert result is not None + assert "Missing required parameter: user_id" in result["message"] + + def test_empty_required_list(self): + """Test validation passes with empty required list.""" + params = {"user_id": "test-user"} + result = validate_required_params(params, required=[]) + assert result is None + + def test_none_optional_list(self): + """Test validation passes with None optional list.""" + params = {"user_id": "test-user"} + result = validate_required_params(params, required=["user_id"], optional=None) + assert result is None + + +class TestFormatErrorResponse: + """Test format_error_response function.""" + + def test_format_value_error(self): + """Test formatting ValueError.""" + exception = ValueError("Invalid value provided") + result = format_error_response("tool-123", exception) + assert result["toolUseId"] == "tool-123" + assert result["status"] == "error" + assert len(result["content"]) == 1 + assert "text" in result["content"][0] + # Parse the JSON text + error_data = json.loads(result["content"][0]["text"]) + assert error_data["error"] == "ValueError" + assert error_data["message"] == "Invalid value provided" + + def test_format_key_error(self): + """Test formatting KeyError.""" + exception = KeyError("missing_key") + result = format_error_response("tool-456", exception) + assert result["toolUseId"] == "tool-456" + assert result["status"] == "error" + error_data = json.loads(result["content"][0]["text"]) + assert error_data["error"] == "KeyError" + + def test_format_runtime_error(self): + """Test formatting RuntimeError.""" + exception = RuntimeError("Runtime error occurred") + result = format_error_response("tool-789", exception) + assert result["toolUseId"] == "tool-789" + assert result["status"] == "error" + error_data = json.loads(result["content"][0]["text"]) + assert error_data["error"] == "RuntimeError" + assert error_data["message"] == "Runtime error occurred" + + def test_format_exception_with_empty_message(self): + """Test formatting exception with empty message.""" + exception = Exception() + result = format_error_response("tool-000", exception) + assert result["toolUseId"] == "tool-000" + assert result["status"] == "error" + error_data = json.loads(result["content"][0]["text"]) + assert error_data["error"] == "Exception" + + def test_format_exception_with_special_characters(self): + """Test formatting exception with special characters in message.""" + exception = ValueError('Error with "quotes" and \\backslashes\\') + result = format_error_response("tool-special", exception) + assert result["toolUseId"] == "tool-special" + assert result["status"] == "error" + error_data = json.loads(result["content"][0]["text"]) + assert error_data["error"] == "ValueError" + assert "quotes" in error_data["message"] + + def test_format_exception_with_multiline_message(self): + """Test formatting exception with multiline message.""" + exception = ValueError("Line 1\nLine 2\nLine 3") + result = format_error_response("tool-multi", exception) + assert result["toolUseId"] == "tool-multi" + assert result["status"] == "error" + error_data = json.loads(result["content"][0]["text"]) + assert "Line 1" in error_data["message"] + assert "Line 2" in error_data["message"] + + +class TestFormatSuccessResponse: + """Test format_success_response function.""" + + def test_format_simple_dict(self): + """Test formatting simple dictionary.""" + data = {"id": "123", "name": "test"} + result = format_success_response("tool-123", data) + assert result["toolUseId"] == "tool-123" + assert result["status"] == "success" + assert len(result["content"]) == 1 + assert "text" in result["content"][0] + response_data = json.loads(result["content"][0]["text"]) + assert response_data["id"] == "123" + assert response_data["name"] == "test" + + def test_format_nested_dict(self): + """Test formatting nested dictionary.""" + data = {"instrument": {"id": "instr-123", "details": {"type": "crypto", "address": "0x123"}}} + result = format_success_response("tool-456", data) + assert result["toolUseId"] == "tool-456" + assert result["status"] == "success" + response_data = json.loads(result["content"][0]["text"]) + assert response_data["instrument"]["id"] == "instr-123" + assert response_data["instrument"]["details"]["type"] == "crypto" + + def test_format_list_data(self): + """Test formatting list data.""" + data = {"items": [{"id": "1", "name": "item1"}, {"id": "2", "name": "item2"}]} + result = format_success_response("tool-789", data) + assert result["toolUseId"] == "tool-789" + assert result["status"] == "success" + response_data = json.loads(result["content"][0]["text"]) + assert len(response_data["items"]) == 2 + assert response_data["items"][0]["id"] == "1" + + def test_format_empty_dict(self): + """Test formatting empty dictionary.""" + data = {} + result = format_success_response("tool-empty", data) + assert result["toolUseId"] == "tool-empty" + assert result["status"] == "success" + response_data = json.loads(result["content"][0]["text"]) + assert response_data == {} + + def test_format_dict_with_special_characters(self): + """Test formatting dictionary with special characters.""" + data = {"message": 'Contains "quotes" and \\backslashes\\', "unicode": "Contains émojis 🎉"} + result = format_success_response("tool-special", data) + assert result["toolUseId"] == "tool-special" + assert result["status"] == "success" + response_data = json.loads(result["content"][0]["text"]) + assert "quotes" in response_data["message"] + assert "émojis" in response_data["unicode"] + + def test_format_dict_with_none_values(self): + """Test formatting dictionary with None values.""" + data = {"id": "123", "optional_field": None, "name": "test"} + result = format_success_response("tool-none", data) + assert result["toolUseId"] == "tool-none" + assert result["status"] == "success" + response_data = json.loads(result["content"][0]["text"]) + assert response_data["optional_field"] is None + + def test_format_dict_with_numeric_values(self): + """Test formatting dictionary with numeric values.""" + data = {"count": 42, "amount": 99.99, "negative": -10, "zero": 0} + result = format_success_response("tool-numeric", data) + assert result["toolUseId"] == "tool-numeric" + assert result["status"] == "success" + response_data = json.loads(result["content"][0]["text"]) + assert response_data["count"] == 42 + assert response_data["amount"] == 99.99 + assert response_data["negative"] == -10 + assert response_data["zero"] == 0 + + def test_format_dict_with_boolean_values(self): + """Test formatting dictionary with boolean values.""" + data = {"active": True, "deleted": False} + result = format_success_response("tool-bool", data) + assert result["toolUseId"] == "tool-bool" + assert result["status"] == "success" + response_data = json.loads(result["content"][0]["text"]) + assert response_data["active"] is True + assert response_data["deleted"] is False + + +class TestGetPaymentInstrumentTool: + """Test getPaymentInstrument tool implementation.""" + + def test_get_payment_instrument_success(self): + """Test getPaymentInstrument returns instrument details on success.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + # Mock PaymentManager + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.return_value = { + "paymentInstrumentId": "instr-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": {"address": "0x123"}, + "status": "ACTIVE", + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + # Call tool + result = plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + + # Verify result + assert result["paymentInstrumentId"] == "instr-123" + assert result["paymentInstrumentType"] == "EMBEDDED_CRYPTO_WALLET" + assert result["status"] == "ACTIVE" + mock_payment_manager.get_payment_instrument.assert_called_once_with( + user_id="test-user", + payment_instrument_id="instr-123", + payment_connector_id=None, + ) + + def test_get_payment_instrument_with_connector_id(self): + """Test getPaymentInstrument with optional connector_id parameter.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.return_value = { + "paymentInstrumentId": "instr-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + payment_connector_id="connector-456", + ) + + assert result["paymentInstrumentId"] == "instr-123" + mock_payment_manager.get_payment_instrument.assert_called_once_with( + user_id="test-user", + payment_instrument_id="instr-123", + payment_connector_id="connector-456", + ) + + def test_get_payment_instrument_missing_user_id_falls_back_to_config(self): + """Test getPaymentInstrument falls back to config user_id when not provided.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.get_payment_instrument.return_value = {"paymentInstrumentId": "instr-123"} + + plugin.get_payment_instrument( + user_id="", + payment_instrument_id="instr-123", + ) + plugin.payment_manager.get_payment_instrument.assert_called_once_with( + user_id="test-user", payment_instrument_id="instr-123", payment_connector_id=None + ) + + def test_get_payment_instrument_empty_id_falls_back_to_config(self): + """Test getPaymentInstrument falls back to config when payment_instrument_id is empty.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.get_payment_instrument.return_value = {"paymentInstrumentId": "test-instrument"} + + plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="", + ) + plugin.payment_manager.get_payment_instrument.assert_called_once_with( + user_id="test-user", payment_instrument_id="test-instrument", payment_connector_id=None + ) + + def test_get_payment_instrument_no_id_anywhere_raises(self): + """Test getPaymentInstrument raises error when no instrument_id in param or config.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentError + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + # payment_instrument_id not set + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + + with pytest.raises(PaymentError, match="payment_instrument_id is not set"): + plugin.get_payment_instrument( + user_id="test-user", + ) + + def test_get_payment_instrument_whitespace_user_id_falls_back_to_config(self): + """Test getPaymentInstrument falls back to config user_id when whitespace provided.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.get_payment_instrument.return_value = {"paymentInstrumentId": "instr-123"} + + plugin.get_payment_instrument( + user_id=" ", + payment_instrument_id="instr-123", + ) + plugin.payment_manager.get_payment_instrument.assert_called_once_with( + user_id="test-user", payment_instrument_id="instr-123", payment_connector_id=None + ) + + def test_get_payment_instrument_payment_manager_not_initialized(self): + """Test getPaymentInstrument raises error when PaymentManager is not initialized.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = None + + with pytest.raises(Exception, match="PaymentManager not initialized"): + plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + + def test_get_payment_instrument_not_found_error(self): + """Test getPaymentInstrument handles PaymentInstrumentNotFound exception.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.side_effect = PaymentInstrumentNotFound( + "Instrument not found: instr-123" + ) + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(PaymentInstrumentNotFound): + plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + + def test_get_payment_instrument_payment_error(self): + """Test getPaymentInstrument handles PaymentError exception.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentError + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.side_effect = PaymentError("API call failed") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(PaymentError): + plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + + def test_get_payment_instrument_unexpected_exception(self): + """Test getPaymentInstrument handles unexpected exceptions.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.side_effect = RuntimeError("Unexpected error") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(RuntimeError): + plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + + def test_get_payment_instrument_complex_response(self): + """Test getPaymentInstrument with complex nested response.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.return_value = { + "paymentInstrumentId": "instr-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "address": "0x123abc", + "network": "ethereum", + "balance": "100.50", + }, + "status": "ACTIVE", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-15T12:30:00Z", + "metadata": { + "verified": True, + "riskLevel": "LOW", + }, + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + + assert result["paymentInstrumentId"] == "instr-123" + assert result["paymentInstrumentDetails"]["network"] == "ethereum" + assert result["metadata"]["verified"] is True + + +class TestListPaymentInstrumentsTool: + """Test listPaymentInstruments tool implementation.""" + + def test_list_payment_instruments_success(self): + """Test listPaymentInstruments returns instruments list on success.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.list_payment_instruments.return_value = { + "paymentInstruments": [ + { + "paymentInstrumentId": "instr-1", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "status": "ACTIVE", + }, + { + "paymentInstrumentId": "instr-2", + "paymentInstrumentType": "CREDIT_CARD", + "status": "ACTIVE", + }, + ] + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.list_payment_instruments(user_id="test-user") + + assert len(result["paymentInstruments"]) == 2 + assert result["paymentInstruments"][0]["paymentInstrumentId"] == "instr-1" + assert result["paymentInstruments"][1]["paymentInstrumentId"] == "instr-2" + mock_payment_manager.list_payment_instruments.assert_called_once_with( + user_id="test-user", + payment_connector_id=None, + max_results=100, + next_token=None, + ) + + def test_list_payment_instruments_with_optional_params(self): + """Test listPaymentInstruments with optional parameters.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.list_payment_instruments.return_value = { + "paymentInstruments": [ + {"paymentInstrumentId": "instr-1", "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET"} + ], + "nextToken": "token-123", + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.list_payment_instruments( + user_id="test-user", + payment_connector_id="connector-456", + max_results=50, + next_token="prev-token", + ) + + assert len(result["paymentInstruments"]) == 1 + assert result["nextToken"] == "token-123" + mock_payment_manager.list_payment_instruments.assert_called_once_with( + user_id="test-user", + payment_connector_id="connector-456", + max_results=50, + next_token="prev-token", + ) + + def test_list_payment_instruments_empty_list(self): + """Test listPaymentInstruments returns empty list when no instruments found.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.list_payment_instruments.return_value = {"paymentInstruments": []} + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.list_payment_instruments(user_id="test-user") + + assert result["paymentInstruments"] == [] + assert "nextToken" not in result + + def test_list_payment_instruments_missing_user_id_falls_back_to_config(self): + """Test listPaymentInstruments falls back to config user_id when not provided.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.list_payment_instruments.return_value = {"paymentInstruments": []} + + plugin.list_payment_instruments(user_id="") + plugin.payment_manager.list_payment_instruments.assert_called_once_with( + user_id="test-user", payment_connector_id=None, max_results=100, next_token=None + ) + + def test_list_payment_instruments_empty_user_id_falls_back_to_config(self): + """Test listPaymentInstruments falls back to config user_id when whitespace provided.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.list_payment_instruments.return_value = {"paymentInstruments": []} + + plugin.list_payment_instruments(user_id=" ") + plugin.payment_manager.list_payment_instruments.assert_called_once_with( + user_id="test-user", payment_connector_id=None, max_results=100, next_token=None + ) + + def test_list_payment_instruments_payment_manager_not_initialized(self): + """Test listPaymentInstruments raises error when PaymentManager is not initialized.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = None + + with pytest.raises(Exception, match="PaymentManager not initialized"): + plugin.list_payment_instruments(user_id="test-user") + + def test_list_payment_instruments_payment_error(self): + """Test listPaymentInstruments handles PaymentError exception.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentError + + mock_payment_manager = MagicMock() + mock_payment_manager.list_payment_instruments.side_effect = PaymentError("API call failed") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(PaymentError): + plugin.list_payment_instruments(user_id="test-user") + + def test_list_payment_instruments_unexpected_exception(self): + """Test listPaymentInstruments handles unexpected exceptions.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.list_payment_instruments.side_effect = RuntimeError("Unexpected error") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(RuntimeError): + plugin.list_payment_instruments(user_id="test-user") + + def test_list_payment_instruments_with_pagination(self): + """Test listPaymentInstruments with pagination tokens.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.list_payment_instruments.return_value = { + "paymentInstruments": [ + {"paymentInstrumentId": f"instr-{i}", "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET"} + for i in range(50) + ], + "nextToken": "next-page-token", + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.list_payment_instruments( + user_id="test-user", + max_results=50, + next_token="current-page-token", + ) + + assert len(result["paymentInstruments"]) == 50 + assert result["nextToken"] == "next-page-token" + mock_payment_manager.list_payment_instruments.assert_called_once_with( + user_id="test-user", + payment_connector_id=None, + max_results=50, + next_token="current-page-token", + ) + + +class TestGetPaymentSessionTool: + """Test getPaymentSession tool implementation.""" + + def test_get_payment_session_success(self): + """Test getPaymentSession returns session details on success.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_session.return_value = { + "paymentSessionId": "session-123", + "userId": "test-user", + "remainingAmount": {"value": "500.00", "currency": "USD"}, + "spentAmount": {"value": "100.00", "currency": "USD"}, + "limits": {"maxSpendAmount": {"value": "600.00", "currency": "USD"}}, + "expiryTime": "2024-12-31T23:59:59Z", + "createdAt": "2024-01-01T00:00:00Z", + "status": "ACTIVE", + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.get_payment_session( + user_id="test-user", + payment_session_id="session-123", + ) + + assert result["paymentSessionId"] == "session-123" + assert result["userId"] == "test-user" + assert result["remainingAmount"]["value"] == "500.00" + assert result["status"] == "ACTIVE" + mock_payment_manager.get_payment_session.assert_called_once_with( + user_id="test-user", + payment_session_id="session-123", + ) + + def test_get_payment_session_missing_user_id_falls_back_to_config(self): + """Test getPaymentSession falls back to config user_id when not provided.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.get_payment_session.return_value = {"paymentSessionId": "session-123"} + + plugin.get_payment_session( + user_id="", + payment_session_id="session-123", + ) + plugin.payment_manager.get_payment_session.assert_called_once_with( + user_id="test-user", payment_session_id="session-123" + ) + + def test_get_payment_session_empty_session_id_falls_back_to_config(self): + """Test getPaymentSession falls back to config when payment_session_id is empty.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.get_payment_session.return_value = {"paymentSessionId": "test-session"} + + plugin.get_payment_session( + user_id="test-user", + payment_session_id="", + ) + plugin.payment_manager.get_payment_session.assert_called_once_with( + user_id="test-user", payment_session_id="test-session" + ) + + def test_get_payment_session_no_session_id_anywhere_raises(self): + """Test getPaymentSession raises error when no session_id in param or config.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentError + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + # payment_session_id not set + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + + with pytest.raises(PaymentError, match="payment_session_id is not set"): + plugin.get_payment_session( + user_id="test-user", + ) + + def test_get_payment_session_whitespace_user_id_falls_back_to_config(self): + """Test getPaymentSession falls back to config user_id when whitespace provided.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + plugin.payment_manager.get_payment_session.return_value = {"paymentSessionId": "session-123"} + + plugin.get_payment_session( + user_id=" ", + payment_session_id="session-123", + ) + plugin.payment_manager.get_payment_session.assert_called_once_with( + user_id="test-user", payment_session_id="session-123" + ) + + def test_get_payment_session_payment_manager_not_initialized(self): + """Test getPaymentSession raises error when PaymentManager is not initialized.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = None + + with pytest.raises(Exception, match="PaymentManager not initialized"): + plugin.get_payment_session( + user_id="test-user", + payment_session_id="session-123", + ) + + def test_get_payment_session_not_found_error(self): + """Test getPaymentSession handles PaymentSessionNotFound exception.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentSessionNotFound + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_session.side_effect = PaymentSessionNotFound("Session not found: session-123") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(PaymentSessionNotFound): + plugin.get_payment_session( + user_id="test-user", + payment_session_id="session-123", + ) + + def test_get_payment_session_payment_error(self): + """Test getPaymentSession handles PaymentError exception.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentError + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_session.side_effect = PaymentError("API call failed") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(PaymentError): + plugin.get_payment_session( + user_id="test-user", + payment_session_id="session-123", + ) + + def test_get_payment_session_unexpected_exception(self): + """Test getPaymentSession handles unexpected exceptions.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_session.side_effect = RuntimeError("Unexpected error") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + with pytest.raises(RuntimeError): + plugin.get_payment_session( + user_id="test-user", + payment_session_id="session-123", + ) + + def test_get_payment_session_complex_response(self): + """Test getPaymentSession with complex nested response.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_session.return_value = { + "paymentSessionId": "session-123", + "userId": "test-user", + "remainingAmount": {"value": "500.00", "currency": "USD"}, + "spentAmount": {"value": "100.00", "currency": "USD"}, + "limits": { + "maxSpendAmount": {"value": "600.00", "currency": "USD"}, + "dailyLimit": {"value": "200.00", "currency": "USD"}, + }, + "expiryTime": "2024-12-31T23:59:59Z", + "createdAt": "2024-01-01T00:00:00Z", + "status": "ACTIVE", + "metadata": { + "region": "US", + "riskLevel": "LOW", + }, + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + result = plugin.get_payment_session( + user_id="test-user", + payment_session_id="session-123", + ) + + assert result["paymentSessionId"] == "session-123" + assert result["limits"]["dailyLimit"]["value"] == "200.00" + assert result["metadata"]["riskLevel"] == "LOW" + + +class TestAfterToolCallHookWithAutoPayment: + """Test after_tool_call hook behavior with auto_payment flag.""" + + def test_after_tool_call_auto_payment_disabled_skips_processing(self): + """Test after_tool_call skips payment processing when auto_payment=False.""" + from strands.hooks import AfterToolCallEvent + + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=False, + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + + # Create a mock event with 402 response + event = MagicMock(spec=AfterToolCallEvent) + event.tool_use = {"name": "test_tool", "input": {}} + event.result = {"statusCode": 402, "headers": {}, "body": {}} + + # Call after_tool_call + plugin.after_tool_call(event) + + # Verify retry was NOT set (payment processing was skipped) + assert not hasattr(event, "retry") or event.retry is not True + + def test_after_tool_call_auto_payment_enabled_processes_402(self): + """Test after_tool_call processes 402 when auto_payment=True.""" + from strands.hooks import AfterToolCallEvent + + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=True, + ) + plugin = AgentCorePaymentsPlugin(config) + mock_payment_manager = MagicMock() + plugin.payment_manager = mock_payment_manager + + # Set up mock agent for interrupt retry tracking + mock_agent = MagicMock() + mock_agent.state.get.return_value = 0 + + # Create a mock event with 402 response + event = MagicMock(spec=AfterToolCallEvent) + event.agent = mock_agent + event.tool_use = {"name": "test_tool", "input": {}} + event.result = {"statusCode": 402, "headers": {}, "body": {}} + event.invocation_state = {} + with patch("bedrock_agentcore.payments.integrations.strands.plugin.get_payment_handler") as mock_get_handler: + mock_handler = MagicMock() + mock_handler.extract_status_code.return_value = 402 + mock_handler.extract_headers.return_value = {} + mock_handler.extract_body.return_value = {} + mock_handler.apply_payment_header.return_value = True + mock_get_handler.return_value = mock_handler + + # Mock PaymentManager.generate_payment_header + mock_payment_manager.generate_payment_header.return_value = {"Authorization": "Bearer token"} + + # Call after_tool_call + plugin.after_tool_call(event) + + # Verify handler was called to extract status code + mock_handler.extract_status_code.assert_called_once() + + def test_after_tool_call_auto_payment_true_non_402_response(self): + """Test after_tool_call with auto_payment=True and non-402 response.""" + from strands.hooks import AfterToolCallEvent + + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=True, + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + + # Set up mock agent for interrupt retry tracking + mock_agent = MagicMock() + mock_agent.state.get.return_value = 0 + + # Create a mock event with 200 response + event = MagicMock(spec=AfterToolCallEvent) + event.agent = mock_agent + event.tool_use = {"name": "test_tool", "input": {}} + event.result = {"statusCode": 200, "headers": {}, "body": {"data": "success"}} + event.invocation_state = {} + + with patch("bedrock_agentcore.payments.integrations.strands.plugin.get_payment_handler") as mock_get_handler: + mock_handler = MagicMock() + mock_handler.extract_status_code.return_value = 200 + mock_get_handler.return_value = mock_handler + + # Call after_tool_call + plugin.after_tool_call(event) + + # Verify handler was called but no retry was set + mock_handler.extract_status_code.assert_called_once() + assert not hasattr(event, "retry") or event.retry is not True + + def test_after_tool_call_auto_payment_false_non_402_response(self): + """Test after_tool_call with auto_payment=False and non-402 response.""" + from strands.hooks import AfterToolCallEvent + + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=False, + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + + # Create a mock event with 200 response + event = MagicMock(spec=AfterToolCallEvent) + event.tool_use = {"name": "test_tool", "input": {}} + event.result = {"statusCode": 200, "headers": {}, "body": {"data": "success"}} + + # Call after_tool_call + plugin.after_tool_call(event) + + # Verify no processing occurred + assert not hasattr(event, "retry") or event.retry is not True + + def test_after_tool_call_auto_payment_default_true(self): + """Test after_tool_call with default auto_payment=True.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + + # Create config without specifying auto_payment (should default to True) + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + + # Verify auto_payment defaults to True + assert config.auto_payment is True + + def test_after_tool_call_no_result_skips_processing(self): + """Test after_tool_call skips processing when result is None.""" + from strands.hooks import AfterToolCallEvent + + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=True, + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = MagicMock() + + # Create a mock event with no result + mock_agent = MagicMock() + mock_agent.state.get.return_value = 0 + event = MagicMock(spec=AfterToolCallEvent) + event.agent = mock_agent + event.tool_use = {"name": "test_tool", "toolUseId": "tool-123", "input": {}} + event.result = None + event.invocation_state = {} + + # Call after_tool_call + plugin.after_tool_call(event) + + # Verify no processing occurred + assert not hasattr(event, "retry") or event.retry is not True + + +class TestToolDiscoverability: + """Test tool discoverability and descriptions.""" + + def test_get_payment_instrument_has_description(self): + """Test that getPaymentInstrument tool has a description attribute.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify the tool method has a docstring + assert hasattr(plugin.get_payment_instrument, "__doc__") + assert plugin.get_payment_instrument.__doc__ is not None + assert len(plugin.get_payment_instrument.__doc__.strip()) > 0 + + def test_list_payment_instruments_has_description(self): + """Test that listPaymentInstruments tool has a description attribute.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify the tool method has a docstring + assert hasattr(plugin.list_payment_instruments, "__doc__") + assert plugin.list_payment_instruments.__doc__ is not None + assert len(plugin.list_payment_instruments.__doc__.strip()) > 0 + + def test_get_payment_session_has_description(self): + """Test that getPaymentSession tool has a description attribute.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify the tool method has a docstring + assert hasattr(plugin.get_payment_session, "__doc__") + assert plugin.get_payment_session.__doc__ is not None + assert len(plugin.get_payment_session.__doc__.strip()) > 0 + + def test_tool_descriptions_are_non_empty_strings(self): + """Test that all tool descriptions are non-empty strings.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify all tool descriptions are non-empty strings + tools = [ + ("get_payment_instrument", plugin.get_payment_instrument), + ("list_payment_instruments", plugin.list_payment_instruments), + ("get_payment_session", plugin.get_payment_session), + ] + + for tool_name, tool_method in tools: + assert isinstance(tool_method.__doc__, str), f"{tool_name} docstring is not a string" + assert len(tool_method.__doc__.strip()) > 0, f"{tool_name} docstring is empty" + # Verify docstring contains meaningful content (not just whitespace) + assert tool_method.__doc__.strip().startswith("Retrieve") or tool_method.__doc__.strip().startswith( + "List" + ), f"{tool_name} docstring does not start with expected verb" + + +class TestToolAvailability: + """Test that tools are always available regardless of auto_payment setting.""" + + def test_tools_available_with_auto_payment_true(self): + """Test all tools are callable when auto_payment=True.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=True, + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify all tools are callable + assert callable(plugin.get_payment_instrument) + assert callable(plugin.list_payment_instruments) + assert callable(plugin.get_payment_session) + + def test_tools_available_with_auto_payment_false(self): + """Test all tools are callable when auto_payment=False.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=False, + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify all tools are callable + assert callable(plugin.get_payment_instrument) + assert callable(plugin.list_payment_instruments) + assert callable(plugin.get_payment_session) + + def test_tools_function_identically_regardless_of_auto_payment(self): + """Test tools function identically regardless of auto_payment setting.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + # Create two plugins with different auto_payment settings + config_true = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=True, + ) + plugin_true = AgentCorePaymentsPlugin(config_true) + + config_false = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + auto_payment=False, + ) + plugin_false = AgentCorePaymentsPlugin(config_false) + + # Mock PaymentManager for both + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.return_value = { + "paymentInstrumentId": "instr-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "status": "ACTIVE", + } + + plugin_true.payment_manager = mock_payment_manager + plugin_false.payment_manager = mock_payment_manager + + # Call get_payment_instrument on both plugins + result_true = plugin_true.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + result_false = plugin_false.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + + # Verify results are identical + assert result_true == result_false + assert result_true["paymentInstrumentId"] == "instr-123" + assert result_false["paymentInstrumentId"] == "instr-123" + + # Verify PaymentManager was called identically for both + assert mock_payment_manager.get_payment_instrument.call_count == 2 + call_args_list = mock_payment_manager.get_payment_instrument.call_args_list + assert call_args_list[0] == call_args_list[1] + + +class TestErrorHandlingConsistency: + """Test comprehensive error handling across all tools.""" + + def test_error_response_format_consistency_across_tools(self): + """Test all error responses have consistent format across tools.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentError + + # Test get_payment_instrument error (no instrument_id in param or config) + config_no_instrument = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + # payment_instrument_id not set + payment_session_id="test-session", + ) + plugin_no_instrument = AgentCorePaymentsPlugin(config_no_instrument) + plugin_no_instrument.payment_manager = MagicMock() + + with pytest.raises(PaymentError, match="payment_instrument_id is not set"): + plugin_no_instrument.get_payment_instrument(payment_instrument_id="") + + # Test getPaymentSession error (no session_id in param or config) + config_no_session = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + # payment_session_id not set + ) + plugin_no_session = AgentCorePaymentsPlugin(config_no_session) + plugin_no_session.payment_manager = MagicMock() + + with pytest.raises(PaymentError, match="payment_session_id is not set"): + plugin_no_session.get_payment_session(payment_session_id="") + + def test_error_responses_include_error_and_message_fields(self): + """Test error responses include 'error' and 'message' fields.""" + error_response = format_error_response("tool-123", ValueError("Test error")) + + assert "content" in error_response + assert len(error_response["content"]) > 0 + assert "text" in error_response["content"][0] + + error_data = json.loads(error_response["content"][0]["text"]) + assert "error" in error_data + assert "message" in error_data + assert error_data["error"] == "ValueError" + assert error_data["message"] == "Test error" + + def test_error_responses_are_json_serializable(self): + """Test error responses are JSON serializable.""" + + error_response = format_error_response("tool-123", RuntimeError("Serialization test")) + + # Verify the response can be JSON serialized + try: + json_str = json.dumps(error_response) + assert json_str is not None + # Verify it can be deserialized + deserialized = json.loads(json_str) + assert deserialized["status"] == "error" + except (TypeError, ValueError) as e: + pytest.fail(f"Error response is not JSON serializable: {e}") + + def test_error_logging_with_appropriate_log_levels(self): + """Test error logging uses appropriate log levels.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + from bedrock_agentcore.payments.manager import PaymentError + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.side_effect = PaymentError("API error") + plugin.payment_manager = mock_payment_manager + + # Capture logs + with patch("bedrock_agentcore.payments.integrations.strands.plugin.logger") as mock_logger: + try: + plugin.get_payment_instrument( + user_id="test-user", + payment_instrument_id="instr-123", + ) + except PaymentError: + pass + + # Verify error was logged + mock_logger.error.assert_called() + + def test_validation_error_responses_consistent_format(self): + """Test validation error responses have consistent format.""" + error_response = validate_required_params( + {"user_id": ""}, + required=["user_id"], + ) + + assert error_response is not None + assert "error" in error_response + assert "message" in error_response + assert error_response["error"] == "ValidationError" + assert "Parameter cannot be empty" in error_response["message"] + + def test_payment_manager_error_responses_consistent_format(self): + """Test PaymentManager error responses have consistent format.""" + from bedrock_agentcore.payments.manager import PaymentError + + error_response = format_error_response("tool-123", PaymentError("Payment API failed")) + + assert "content" in error_response + assert error_response["status"] == "error" + + error_data = json.loads(error_response["content"][0]["text"]) + assert error_data["error"] == "PaymentError" + assert "Payment API failed" in error_data["message"] + + def test_unexpected_exception_responses_consistent_format(self): + """Test unexpected exception responses have consistent format.""" + error_response = format_error_response("tool-123", RuntimeError("Unexpected error")) + + assert "content" in error_response + assert error_response["status"] == "error" + + error_data = json.loads(error_response["content"][0]["text"]) + assert error_data["error"] == "RuntimeError" + assert error_data["message"] == "Unexpected error" + + def test_error_response_includes_tool_use_id(self): + """Test error responses include toolUseId.""" + error_response = format_error_response("tool-abc-123", ValueError("Test")) + + assert "toolUseId" in error_response + assert error_response["toolUseId"] == "tool-abc-123" + + def test_error_response_status_is_error(self): + """Test error responses have status='error'.""" + error_response = format_error_response("tool-123", ValueError("Test")) + + assert "status" in error_response + assert error_response["status"] == "error" + + def test_success_response_status_is_success(self): + """Test success responses have status='success'.""" + success_response = format_success_response("tool-123", {"data": "test"}) + + assert "status" in success_response + assert success_response["status"] == "success" + + def test_all_error_types_handled_consistently(self): + """Test various error types are handled consistently.""" + error_types = [ + ValueError("Value error"), + KeyError("Key error"), + RuntimeError("Runtime error"), + Exception("Generic exception"), + TypeError("Type error"), + ] + + for error in error_types: + error_response = format_error_response("tool-123", error) + + # Verify consistent structure + assert "toolUseId" in error_response + assert "status" in error_response + assert error_response["status"] == "error" + assert "content" in error_response + assert len(error_response["content"]) > 0 + assert "text" in error_response["content"][0] + + # Verify JSON serializable + error_data = json.loads(error_response["content"][0]["text"]) + assert "error" in error_data + assert "message" in error_data + + +class TestUserIdPrecedence: + """Test user_id resolution: explicit > config > None.""" + + def test_explicit_user_id_takes_precedence_over_config(self): + """Test that explicit user_id passed to tool takes precedence over config.user_id.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.return_value = { + "paymentInstrumentId": "instr-123", + "status": "ACTIVE", + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="config-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + plugin.get_payment_instrument( + user_id="override-user", + payment_instrument_id="instr-123", + ) + + mock_payment_manager.get_payment_instrument.assert_called_once_with( + user_id="override-user", + payment_instrument_id="instr-123", + payment_connector_id=None, + ) + + def test_bearer_auth_mode_user_id_none_passes_none_to_manager(self): + """Test that bearer auth with no user_id passes None to manager.""" + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + mock_payment_manager = MagicMock() + mock_payment_manager.get_payment_instrument.return_value = { + "paymentInstrumentId": "instr-123", + "status": "ACTIVE", + } + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + bearer_token="my-jwt-token", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + plugin = AgentCorePaymentsPlugin(config) + plugin.payment_manager = mock_payment_manager + + plugin.get_payment_instrument( + payment_instrument_id="instr-123", + ) + + mock_payment_manager.get_payment_instrument.assert_called_once_with( + user_id=None, + payment_instrument_id="instr-123", + payment_connector_id=None, + ) diff --git a/tests/bedrock_agentcore/payments/integrations/test_handlers.py b/tests/bedrock_agentcore/payments/integrations/test_handlers.py new file mode 100644 index 00000000..567c2b5a --- /dev/null +++ b/tests/bedrock_agentcore/payments/integrations/test_handlers.py @@ -0,0 +1,1073 @@ +"""Tests for payment response handlers. + +Tests for both the generic handler and tool-specific handlers. +""" + +import json + +import pytest + +from bedrock_agentcore.payments.integrations.handlers import ( + GenericPaymentHandler, + HttpRequestPaymentHandler, + MCPRequestPaymentHandler, + get_payment_handler, +) + + +class TestGenericPaymentHandler: + """Tests for GenericPaymentHandler.""" + + def setup_method(self): + """Set up test fixtures.""" + self.handler = GenericPaymentHandler() + + # PAYMENT_REQUIRED Marker Extraction Tests + def test_extract_status_code_from_payment_required_marker(self): + """Test extracting status code from PAYMENT_REQUIRED marker in content blocks.""" + payment_structure = {"statusCode": 402, "headers": {}, "body": {}} + result = [ + {"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}, + ] + assert self.handler.extract_status_code(result) == 402 + + def test_extract_headers_from_payment_required_marker(self): + """Test extracting headers from PAYMENT_REQUIRED marker.""" + headers = {"content-type": "application/json"} + payment_structure = {"statusCode": 402, "headers": headers, "body": {}} + result = [ + {"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}, + ] + assert self.handler.extract_headers(result) == headers + + def test_extract_body_from_payment_required_marker(self): + """Test extracting body from PAYMENT_REQUIRED marker.""" + body = {"error": "Payment required"} + payment_structure = {"statusCode": 402, "headers": {}, "body": body} + result = [ + {"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}, + ] + assert self.handler.extract_body(result) == body + + def test_extract_all_fields_from_payment_required_marker(self): + """Test extracting all fields from PAYMENT_REQUIRED marker.""" + headers = {"content-type": "application/json"} + body = {"error": "Payment required"} + payment_structure = {"statusCode": 402, "headers": headers, "body": body} + result = [ + {"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}, + ] + assert self.handler.extract_status_code(result) == 402 + assert self.handler.extract_headers(result) == headers + assert self.handler.extract_body(result) == body + + # Fallback: Direct Dict Response Tests + def test_extract_status_code_from_dict_direct(self): + """Test extracting status code from marker format.""" + payment_structure = {"statusCode": 402, "headers": {}, "body": {}} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_status_code(result) == 402 + + def test_extract_headers_from_dict_direct(self): + """Test extracting headers from marker format.""" + headers = {"content-type": "application/json"} + payment_structure = {"statusCode": 402, "headers": headers, "body": {}} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_headers(result) == headers + + def test_extract_body_from_dict_direct(self): + """Test extracting body from marker format.""" + body = {"error": "Payment required"} + payment_structure = {"statusCode": 402, "headers": {}, "body": body} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_body(result) == body + + def test_extract_all_fields_from_dict_response(self): + """Test extracting all fields from marker format response.""" + headers = {"content-type": "application/json"} + body = {"error": "Payment required"} + payment_structure = { + "statusCode": 402, + "headers": headers, + "body": body, + } + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_status_code(result) == 402 + assert self.handler.extract_headers(result) == headers + assert self.handler.extract_body(result) == body + + # Error Handling Tests + def test_extract_status_code_returns_none_when_not_found(self): + """Test that None is returned when status code not found.""" + result = [{"text": 'PAYMENT_REQUIRED: {"headers": {}, "body": {}}'}] + assert self.handler.extract_status_code(result) is None + + def test_extract_status_code_returns_none_for_non_402_status(self): + """Test that status code is returned even for non-402 status codes.""" + payment_structure = {"statusCode": 200, "headers": {}, "body": {}} + result = [ + {"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}, + ] + assert self.handler.extract_status_code(result) == 200 + + def test_extract_headers_returns_none_when_not_found(self): + """Test that None is returned when headers not found.""" + result = [{"text": 'PAYMENT_REQUIRED: {"statusCode": 402, "body": {}}'}] + assert self.handler.extract_headers(result) is None + + def test_extract_body_returns_none_when_not_found(self): + """Test that None is returned when body not found.""" + result = {"statusCode": 402, "headers": {}} + assert self.handler.extract_body(result) is None + + def test_extract_status_code_handles_invalid_marker_format(self): + """Test that invalid marker format is handled gracefully.""" + result = [{"text": "PAYMENT_REQUIRED: invalid json"}] + assert self.handler.extract_status_code(result) is None + + def test_extract_status_code_handles_empty_content_blocks(self): + """Test that empty content blocks are handled gracefully.""" + result = [] + assert self.handler.extract_status_code(result) is None + + status_code = self.handler.extract_status_code(result) + + assert status_code is None + + def test_extract_status_code_invalid_format(self): + """Test with invalid status code format.""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Status Code: invalid"}] + + status_code = handler.extract_status_code(result) + + assert status_code is None + + def test_extract_status_code_with_reason_text(self): + """Test extracting status code when reason text follows the number.""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Status Code: 402 Payment Required"}] + + status_code = handler.extract_status_code(result) + + assert status_code == 402 + + def test_extract_status_code_with_multiple_reason_words(self): + """Test extracting status code with multiple words in reason.""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Status Code: 500 Internal Server Error"}] + + status_code = handler.extract_status_code(result) + + assert status_code == 500 + + # Verify structuredContent is NOT handled by GenericPaymentHandler + def test_generic_handler_does_not_handle_structured_content(self): + """Test that GenericPaymentHandler ignores structuredContent x402 data.""" + result = { + "structuredContent": { + "x402Version": 1, + "accepts": [{"scheme": "exact"}], + } + } + assert self.handler.extract_status_code(result) is None + assert self.handler.extract_headers(result) is None + assert self.handler.extract_body(result) is None + + # Verify MCP-shaped input is NOT handled by GenericPaymentHandler.apply_payment_header + def test_generic_handler_apply_header_uses_top_level_for_mcp_shaped_input(self): + """Test that GenericPaymentHandler puts headers at top level even for MCP-shaped input.""" + tool_input = {"toolName": "some_tool", "parameters": {"url": "https://example.com"}} + payment_header = {"X-PAYMENT": "base64"} + assert self.handler.apply_payment_header(tool_input, payment_header) is True + # Headers go at top level, NOT inside parameters + assert tool_input["headers"] == {"X-PAYMENT": "base64"} + assert "headers" not in tool_input["parameters"] + + +class TestMCPRequestPaymentHandler: + """Tests for MCPRequestPaymentHandler.""" + + def setup_method(self): + """Set up test fixtures.""" + self.handler = MCPRequestPaymentHandler() + self.x402_result = { + "structuredContent": { + "x402Version": 1, + "accepts": [{"scheme": "exact", "network": "base-sepolia"}], + } + } + + # extract_status_code tests + def test_extract_status_code_returns_402_for_x402_data(self): + """Test returns 402 when structuredContent has x402 payment data.""" + assert self.handler.extract_status_code(self.x402_result) == 402 + + def test_extract_status_code_returns_none_for_non_x402(self): + """Test returns None when structuredContent lacks x402 fields.""" + result = {"structuredContent": {"someOther": "data"}} + assert self.handler.extract_status_code(result) is None + + def test_extract_status_code_returns_none_for_missing_structured_content(self): + """Test returns None when no structuredContent key.""" + assert self.handler.extract_status_code({"other": "data"}) is None + + def test_extract_status_code_returns_none_for_non_dict_result(self): + """Test returns None for non-dict result.""" + assert self.handler.extract_status_code("not a dict") is None + assert self.handler.extract_status_code([]) is None + assert self.handler.extract_status_code(None) is None + + def test_extract_status_code_returns_none_for_partial_x402(self): + """Test returns None when only x402Version present without accepts.""" + result = {"structuredContent": {"x402Version": 1}} + assert self.handler.extract_status_code(result) is None + + # extract_headers tests + def test_extract_headers_returns_content_type_for_x402(self): + """Test returns content-type header for x402 data.""" + assert self.handler.extract_headers(self.x402_result) == {"content-type": "application/json"} + + def test_extract_headers_returns_none_for_non_x402(self): + """Test returns None when no x402 data.""" + result = {"structuredContent": {"other": "data"}} + assert self.handler.extract_headers(result) is None + + def test_extract_headers_returns_none_for_non_dict(self): + """Test returns None for non-dict result.""" + assert self.handler.extract_headers("string") is None + + # extract_body tests + def test_extract_body_returns_structured_content_for_x402(self): + """Test returns structuredContent dict directly for x402 data.""" + body = self.handler.extract_body(self.x402_result) + assert body == self.x402_result["structuredContent"] + assert body["x402Version"] == 1 + assert body["accepts"] == [{"scheme": "exact", "network": "base-sepolia"}] + + def test_extract_body_returns_none_for_non_x402(self): + """Test returns None when no x402 data.""" + result = {"structuredContent": {"other": "data"}} + assert self.handler.extract_body(result) is None + + def test_extract_body_returns_none_for_non_dict(self): + """Test returns None for non-dict result.""" + assert self.handler.extract_body([]) is None + + # apply_payment_header tests + def test_apply_header_places_in_parameters_headers(self): + """Test places headers inside parameters.headers.""" + tool_input = {"toolName": "proxy_tool_call", "parameters": {"url": "https://example.com"}} + payment_header = {"X-PAYMENT": "base64-encoded"} + assert self.handler.apply_payment_header(tool_input, payment_header) is True + assert tool_input["parameters"]["headers"] == {"X-PAYMENT": "base64-encoded"} + + def test_apply_header_adds_to_existing_parameters_headers(self): + """Test adds to existing parameters.headers.""" + tool_input = { + "toolName": "proxy_tool_call", + "parameters": {"headers": {"existing": "value"}}, + } + payment_header = {"X-PAYMENT": "base64"} + assert self.handler.apply_payment_header(tool_input, payment_header) is True + assert tool_input["parameters"]["headers"]["existing"] == "value" + assert tool_input["parameters"]["headers"]["X-PAYMENT"] == "base64" + + def test_apply_header_creates_parameters_headers_if_missing(self): + """Test creates headers dict inside parameters if missing.""" + tool_input = {"toolName": "proxy_tool_call", "parameters": {}} + payment_header = {"X-PAYMENT": "base64"} + assert self.handler.apply_payment_header(tool_input, payment_header) is True + assert tool_input["parameters"]["headers"] == {"X-PAYMENT": "base64"} + + def test_apply_header_returns_false_for_non_dict_parameters(self): + """Test returns False when parameters is not a dict.""" + tool_input = {"toolName": "proxy_tool_call", "parameters": "not a dict"} + payment_header = {"X-PAYMENT": "base64"} + assert self.handler.apply_payment_header(tool_input, payment_header) is False + + def test_validate_tool_input_returns_false_for_non_dict_input(self): + """Test validate_tool_input returns False for non-dict tool input.""" + assert self.handler.validate_tool_input("not a dict") is False + + def test_validate_tool_input_returns_false_without_tool_name(self): + """Test validate_tool_input returns False when toolName is missing.""" + tool_input = {"parameters": {"url": "https://example.com"}} + assert self.handler.validate_tool_input(tool_input) is False + + def test_validate_tool_input_returns_false_without_parameters(self): + """Test validate_tool_input returns False when parameters is missing.""" + tool_input = {"toolName": "proxy_tool_call"} + assert self.handler.validate_tool_input(tool_input) is False + + def test_validate_tool_input_returns_false_for_non_dict_parameters(self): + """Test validate_tool_input returns False when parameters is not a dict.""" + tool_input = {"toolName": "proxy_tool_call", "parameters": "not a dict"} + assert self.handler.validate_tool_input(tool_input) is False + + def test_validate_tool_input_returns_true_for_valid_input(self): + """Test validate_tool_input returns True for valid MCP-shaped input.""" + tool_input = {"toolName": "proxy_tool_call", "parameters": {"url": "https://example.com"}} + assert self.handler.validate_tool_input(tool_input) is True + + # Full flow test + def test_full_mcp_x402_extraction(self): + """Test complete extraction flow for MCP x402 response.""" + result = { + "structuredContent": { + "x402Version": 2, + "accepts": [ + {"scheme": "exact", "network": "eip155:8453", "maxAmountRequired": "1000"}, + ], + "error": "Payment Required", + } + } + assert self.handler.extract_status_code(result) == 402 + assert self.handler.extract_headers(result) == {"content-type": "application/json"} + body = self.handler.extract_body(result) + assert body["x402Version"] == 2 + assert body["accepts"][0]["network"] == "eip155:8453" + + +class TestHttpRequestPaymentHandlerExtractHeaders: + """Tests for HttpRequestPaymentHandler.extract_headers.""" + + def test_extract_headers_json_format(self): + """Test extracting headers in JSON format.""" + handler = HttpRequestPaymentHandler() + + headers_dict = {"Content-Type": "application/json", "X-Custom": "value"} + result = [{"text": f"Headers: {json.dumps(headers_dict)}"}] + + headers = handler.extract_headers(result) + + assert headers == headers_dict + + def test_extract_headers_dict_string_format(self): + """Test extracting headers in Python dict string format (single-quoted keys).""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Headers: {'Content-Type': 'application/json'}"}] + + headers = handler.extract_headers(result) + + assert headers == {"Content-Type": "application/json"} + + def test_extract_headers_not_found(self): + """Test when headers are not found.""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Some other text"}] + + headers = handler.extract_headers(result) + + assert headers is None + + def test_extract_headers_empty_content(self): + """Test with empty content.""" + handler = HttpRequestPaymentHandler() + + result = [] + + headers = handler.extract_headers(result) + + assert headers is None + + def test_extract_headers_invalid_json(self): + """Test with invalid JSON in headers.""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Headers: {invalid json}"}] + + headers = handler.extract_headers(result) + + assert headers is None + + +class TestHttpRequestPaymentHandlerExtractBody: + """Tests for HttpRequestPaymentHandler.extract_body.""" + + def test_extract_body_json_format(self): + """Test extracting body in JSON format.""" + handler = HttpRequestPaymentHandler() + + body_dict = {"scheme": "exact", "network": "ethereum"} + result = [{"text": f"Body: {json.dumps(body_dict)}"}] + + body = handler.extract_body(result) + + assert body == body_dict + + def test_extract_body_not_found(self): + """Test when body is not found.""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Some other text"}] + + body = handler.extract_body(result) + + assert body is None + + def test_extract_body_empty_content(self): + """Test with empty content.""" + handler = HttpRequestPaymentHandler() + + result = [] + + body = handler.extract_body(result) + + assert body is None + + def test_extract_body_invalid_json(self): + """Test with invalid JSON in body.""" + handler = HttpRequestPaymentHandler() + + result = [{"text": "Body: {invalid json}"}] + + body = handler.extract_body(result) + + assert body is None + + +class TestHttpRequestPaymentHandlerApplyPaymentHeader: + """Tests for HttpRequestPaymentHandler.apply_payment_header.""" + + def setup_method(self): + """Set up test fixtures.""" + self.handler = HttpRequestPaymentHandler() + + def test_apply_payment_header_success(self): + """Test successfully applying payment header.""" + handler = HttpRequestPaymentHandler() + + tool_input = {"url": "https://example.com", "headers": {}} + payment_header = {"X-PAYMENT": "base64-encoded"} + + result = handler.apply_payment_header(tool_input, payment_header) + + assert result is True + assert tool_input["headers"]["X-PAYMENT"] == "base64-encoded" + + def test_apply_payment_header_creates_headers_dict(self): + """Test that headers dict is created if it doesn't exist.""" + tool_input = {} + payment_header = {"X-PAYMENT": "base64-encoded-value"} + assert self.handler.apply_payment_header(tool_input, payment_header) is True + assert tool_input["headers"] == payment_header + + def test_apply_payment_header_adds_to_existing_headers(self): + """Test that payment header is added to existing headers.""" + tool_input = {"headers": {"content-type": "application/json"}} + payment_header = {"X-PAYMENT": "base64-encoded-value"} + assert self.handler.apply_payment_header(tool_input, payment_header) is True + assert tool_input["headers"]["X-PAYMENT"] == "base64-encoded-value" + assert tool_input["headers"]["content-type"] == "application/json" + + def test_apply_payment_header_returns_false_for_non_dict_input(self): + """Test that False is returned for non-dict input.""" + tool_input = "not a dict" + assert self.handler.validate_tool_input(tool_input) is False + + def test_validate_tool_input_returns_true_for_valid_input(self): + """Test that True is returned for valid dict input.""" + tool_input = {"url": "https://example.com"} + assert self.handler.validate_tool_input(tool_input) is True + + def test_apply_payment_header_returns_false_for_non_dict_headers(self): + """Test that False is returned when headers is not a dict.""" + tool_input = {"headers": "not a dict"} + payment_header = {"X-PAYMENT": "base64-encoded-value"} + assert self.handler.apply_payment_header(tool_input, payment_header) is False + + # Content Block Extraction Tests + def test_extract_from_content_dict_with_marker(self): + """Test extracting from dict with content key containing marker.""" + payment_structure = {"statusCode": 402, "headers": {"x-test": "value"}, "body": {"msg": "test"}} + result = { + "content": [ + {"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}, + ] + } + assert self.handler.extract_status_code(result) == 402 + assert self.handler.extract_headers(result) == {"x-test": "value"} + assert self.handler.extract_body(result) == {"msg": "test"} + + def test_handler_with_object_attributes(self): + """Test handler with objects that have attributes.""" + + class ContentBlock: + def __init__(self, text): + self.text = text + + class Response: + def __init__(self, content): + self.content = content + + payment_structure = {"statusCode": 402, "headers": {"x-test": "value"}, "body": {"msg": "test"}} + result = Response( + [ + ContentBlock(f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"), + ] + ) + assert self.handler.extract_status_code(result) == 402 + assert self.handler.extract_headers(result) == {"x-test": "value"} + assert self.handler.extract_body(result) == {"msg": "test"} + + +class TestHttpRequestPaymentHandler: + """Tests for HttpRequestPaymentHandler.""" + + def setup_method(self): + """Set up test fixtures.""" + self.handler = HttpRequestPaymentHandler() + + def test_http_request_handler_inherits_from_generic(self): + """Test that HttpRequestPaymentHandler inherits from GenericPaymentHandler.""" + assert isinstance(self.handler, GenericPaymentHandler) + + def test_http_request_handler_extracts_status_code_from_marker(self): + """Test that http_request handler can extract status code from marker.""" + payment_structure = {"statusCode": 402, "headers": {}, "body": {}} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_status_code(result) == 402 + + def test_http_request_handler_extracts_headers_from_marker(self): + """Test that http_request handler can extract headers from marker.""" + headers = {"content-type": "application/json"} + payment_structure = {"statusCode": 402, "headers": headers, "body": {}} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_headers(result) == headers + + def test_http_request_handler_extracts_body_from_marker(self): + """Test that http_request handler can extract body from marker.""" + body = {"error": "Payment required"} + payment_structure = {"statusCode": 402, "headers": {}, "body": body} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_body(result) == body + + +class TestHandlerRegistry: + """Tests for handler registry and resolution.""" + + def test_get_payment_handler_returns_http_request_handler(self): + """Test that http_request tool gets the specific handler.""" + handler = get_payment_handler("http_request", {}) + assert isinstance(handler, HttpRequestPaymentHandler) + + def test_get_payment_handler_returns_generic_for_unknown_tool(self): + """Test that unknown tools get the generic handler.""" + handler = get_payment_handler("unknown_tool", {}) + assert isinstance(handler, GenericPaymentHandler) + assert not isinstance(handler, HttpRequestPaymentHandler) + + def test_get_payment_handler_returns_generic_for_custom_tool(self): + """Test that custom tools get the generic handler.""" + handler = get_payment_handler("my_custom_http_tool", {}) + assert isinstance(handler, GenericPaymentHandler) + assert not isinstance(handler, HttpRequestPaymentHandler) + + def test_get_payment_handler_returns_mcp_for_mcp_shaped_input(self): + """Test that MCP Gateway shaped input returns MCPRequestPaymentHandler.""" + tool_input = {"toolName": "proxy_tool_call", "parameters": {"url": "https://example.com"}} + handler = get_payment_handler("some_mcp_tool", tool_input) + assert isinstance(handler, MCPRequestPaymentHandler) + + def test_get_payment_handler_mcp_detection_requires_both_keys(self): + """Test that MCP detection requires both toolName and parameters.""" + # Only toolName + handler = get_payment_handler("some_tool", {"toolName": "proxy_tool_call"}) + assert isinstance(handler, GenericPaymentHandler) + + # Only parameters + handler = get_payment_handler("some_tool", {"parameters": {}}) + assert isinstance(handler, GenericPaymentHandler) + + def test_get_payment_handler_name_registry_takes_precedence_over_shape(self): + """Test that name-based registry match takes precedence over MCP shape detection.""" + tool_input = {"toolName": "proxy_tool_call", "parameters": {}} + handler = get_payment_handler("http_request", tool_input) + assert isinstance(handler, HttpRequestPaymentHandler) + + def test_get_payment_handler_handles_empty_args(self): + """Test that empty args fall back to generic handler.""" + handler = get_payment_handler("", {}) + assert isinstance(handler, GenericPaymentHandler) + + def test_get_payment_handler_handles_non_dict_input(self): + """Test that non-dict input field falls back to generic handler.""" + handler = get_payment_handler("some_tool", "not a dict") + assert isinstance(handler, GenericPaymentHandler) + + def test_generic_handler_works_with_marker_format(self): + """Test that generic handler works with PAYMENT_REQUIRED marker format.""" + handler = get_payment_handler("some_unknown_tool", {}) + + # Test with marker format + tool_input = { + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer token", + } + } + payment_header = {"X-PAYMENT": "base64-encoded"} + + result = handler.apply_payment_header(tool_input, payment_header) + + assert result is True + assert tool_input["headers"]["Content-Type"] == "application/json" + assert tool_input["headers"]["Authorization"] == "Bearer token" + assert tool_input["headers"]["X-PAYMENT"] == "base64-encoded" + + +class TestPaymentResponseHandlerAbstractClass: + """Tests for PaymentResponseHandler abstract base class.""" + + def test_cannot_instantiate_abstract_class(self): + """Test that PaymentResponseHandler cannot be instantiated directly.""" + from bedrock_agentcore.payments.integrations.handlers import PaymentResponseHandler + + with pytest.raises(TypeError): + PaymentResponseHandler() + + +class TestHttpRequestPaymentHandlerEdgeCasesWithNonTextBlocks: + """Tests for edge cases with non-text content blocks.""" + + def test_extract_status_code_with_non_text_blocks(self): + """Test extracting status code when blocks don't have text.""" + handler = HttpRequestPaymentHandler() + + payment_structure = { + "statusCode": 402, + "headers": {"x-payment-required": "true"}, + "body": {"error": "Payment required"}, + } + marker_response = [ + {"other_key": "value"}, + {"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}, + ] + assert handler.extract_status_code(marker_response) == 402 + assert handler.extract_headers(marker_response) is not None + assert handler.extract_body(marker_response) is not None + + def test_generic_handler_works_with_dict_format(self): + """Test that generic handler works with marker format in content array.""" + handler = get_payment_handler("some_unknown_tool", {}) + + # Test with marker format (spec-compliant) + payment_structure = { + "statusCode": 402, + "headers": {"x-payment-required": "true"}, + "body": {"error": "Payment required"}, + } + marker_response = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + + assert handler.extract_status_code(marker_response) == 402 + assert handler.extract_headers(marker_response) is not None + assert handler.extract_body(marker_response) is not None + + +class TestGenericPaymentHandlerEdgeCases: + """Tests for GenericPaymentHandler edge cases and exception paths.""" + + def setup_method(self): + """Set up test fixtures.""" + self.handler = GenericPaymentHandler() + + def test_extract_content_array_from_object_with_content_attr(self): + """Test _extract_content_array with an object that has a content attribute.""" + + class MockResult: + content = [{"text": "hello"}] + + result = self.handler._extract_content_array(MockResult()) + assert result == [{"text": "hello"}] + + def test_extract_content_array_returns_none_for_non_matching(self): + """Test _extract_content_array returns None for unsupported types.""" + assert self.handler._extract_content_array(42) is None + assert self.handler._extract_content_array("string") is None + + def test_extract_text_from_block_with_text_attribute(self): + """Test _extract_text_from_block with an object that has a text attribute.""" + + class MockBlock: + text = "some text" + + assert self.handler._extract_text_from_block(MockBlock()) == "some text" + + def test_extract_text_from_block_returns_none_for_non_matching(self): + """Test _extract_text_from_block returns None for unsupported types.""" + assert self.handler._extract_text_from_block(42) is None + assert self.handler._extract_text_from_block({"other": "key"}) is None + + def test_parse_json_or_dict_returns_none_for_non_dict_json(self): + """Test _parse_json_or_dict returns None when JSON parses to non-dict.""" + assert self.handler._parse_json_or_dict("[1, 2, 3]") is None + assert self.handler._parse_json_or_dict('"just a string"') is None + + def test_parse_json_or_dict_returns_none_for_invalid_json(self): + """Test _parse_json_or_dict returns None for invalid JSON.""" + assert self.handler._parse_json_or_dict("not json at all") is None + + def test_extract_payment_required_structure_exception_path(self): + """Test _extract_payment_required_structure handles exceptions gracefully.""" + + # A result that causes an exception during iteration + class BadResult: + @property + def content(self): + raise RuntimeError("boom") + + assert self.handler._extract_payment_required_structure(BadResult()) is None + + def test_extract_status_code_exception_path(self): + """Test extract_status_code handles exceptions in _extract_payment_required_structure.""" + # Patch to raise an exception + from unittest.mock import patch + + with patch.object(self.handler, "_extract_payment_required_structure", side_effect=Exception("boom")): + assert self.handler.extract_status_code([]) is None + + def test_extract_headers_exception_path(self): + """Test extract_headers handles exceptions gracefully.""" + from unittest.mock import patch + + with patch.object(self.handler, "_extract_payment_required_structure", side_effect=Exception("boom")): + assert self.handler.extract_headers([]) is None + + def test_extract_body_exception_path(self): + """Test extract_body handles exceptions gracefully.""" + from unittest.mock import patch + + with patch.object(self.handler, "_extract_payment_required_structure", side_effect=Exception("boom")): + assert self.handler.extract_body([]) is None + + def test_apply_payment_header_non_dict_headers_returns_false(self): + """Test apply_payment_header returns False when headers is not a dict.""" + tool_input = {"headers": "not-a-dict"} + assert self.handler.apply_payment_header(tool_input, {"X-PAYMENT": "val"}) is False + + def test_apply_payment_header_exception_path(self): + """Test apply_payment_header handles exceptions gracefully.""" + + # Frozen dict that raises on update + class FrozenDict(dict): + def __setitem__(self, key, value): + raise TypeError("frozen") + + tool_input = FrozenDict() + assert self.handler.apply_payment_header(tool_input, {"X-PAYMENT": "val"}) is False + + def test_extract_status_code_non_int_status_code(self): + """Test extract_status_code returns None when statusCode is not an int.""" + payment_structure = {"statusCode": "402", "headers": {}, "body": {}} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_status_code(result) is None + + def test_extract_headers_non_dict_headers(self): + """Test extract_headers returns None when headers is not a dict.""" + payment_structure = {"statusCode": 402, "headers": "not-a-dict", "body": {}} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_headers(result) is None + + def test_extract_body_non_dict_body(self): + """Test extract_body returns None when body is not a dict.""" + payment_structure = {"statusCode": 402, "headers": {}, "body": "not-a-dict"} + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_structure)}"}] + assert self.handler.extract_body(result) is None + + +class TestMCPRequestPaymentHandlerExceptionPaths: + """Tests for MCPRequestPaymentHandler exception handling paths.""" + + def setup_method(self): + """Set up test fixtures.""" + self.handler = MCPRequestPaymentHandler() + + def test_extract_status_code_exception_path(self): + """Test extract_status_code handles exceptions gracefully.""" + + # Object whose .get raises + class BadDict(dict): + def get(self, key, default=None): + raise RuntimeError("boom") + + assert self.handler.extract_status_code(BadDict()) is None + + def test_extract_headers_exception_path(self): + """Test extract_headers handles exceptions gracefully.""" + + class BadDict(dict): + def get(self, key, default=None): + raise RuntimeError("boom") + + assert self.handler.extract_headers(BadDict()) is None + + def test_extract_body_exception_path(self): + """Test extract_body handles exceptions gracefully.""" + + class BadDict(dict): + def get(self, key, default=None): + raise RuntimeError("boom") + + assert self.handler.extract_body(BadDict()) is None + + def test_apply_payment_header_non_dict_headers_returns_false(self): + """Test apply_payment_header returns False when parameters.headers is not a dict.""" + tool_input = { + "toolName": "proxy_tool_call", + "parameters": {"headers": "not-a-dict"}, + } + assert self.handler.apply_payment_header(tool_input, {"X-PAYMENT": "val"}) is False + + def test_apply_payment_header_exception_path(self): + """Test apply_payment_header handles exceptions gracefully.""" + # Missing 'parameters' key entirely + tool_input = {} + assert self.handler.apply_payment_header(tool_input, {"X-PAYMENT": "val"}) is False + + +class TestHttpRequestPaymentHandlerExceptionPaths: + """Tests for HttpRequestPaymentHandler exception handling paths.""" + + def setup_method(self): + """Set up test fixtures.""" + self.handler = HttpRequestPaymentHandler() + + def test_extract_status_code_outer_exception_path(self): + """Test extract_status_code handles outer exceptions gracefully.""" + from unittest.mock import patch + + with patch.object(HttpRequestPaymentHandler, "_extract_content_array", side_effect=Exception("boom")): + assert self.handler.extract_status_code([]) is None + + def test_extract_headers_outer_exception_path(self): + """Test extract_headers handles outer exceptions gracefully.""" + from unittest.mock import patch + + with patch.object(HttpRequestPaymentHandler, "_extract_content_array", side_effect=Exception("boom")): + assert self.handler.extract_headers([]) is None + + def test_extract_body_outer_exception_path(self): + """Test extract_body handles outer exceptions gracefully.""" + from unittest.mock import patch + + with patch.object(HttpRequestPaymentHandler, "_extract_content_array", side_effect=Exception("boom")): + assert self.handler.extract_body([]) is None + + def test_extract_body_invalid_json_continues(self): + """Test extract_body continues past invalid JSON body blocks.""" + result = [ + {"text": "Body: not-valid-json"}, + {"text": 'Body: {"valid": "json"}'}, + ] + body = self.handler.extract_body(result) + assert body == {"valid": "json"} + + def test_extract_headers_with_non_text_blocks_skipped(self): + """Test extract_headers skips non-text content blocks.""" + result = [ + {"image": "data"}, + {"text": 'Headers: {"x-test": "value"}'}, + ] + headers = self.handler.extract_headers(result) + assert headers == {"x-test": "value"} + + def test_extract_body_with_non_text_blocks_skipped(self): + """Test extract_body skips non-text content blocks.""" + result = [ + {"image": "data"}, + {"text": 'Body: {"key": "value"}'}, + ] + body = self.handler.extract_body(result) + assert body == {"key": "value"} + + def test_extract_status_code_empty_status_code_text(self): + """Test extract_status_code with 'Status Code:' but no number.""" + result = [{"text": "Status Code: "}] + assert self.handler.extract_status_code(result) is None + + +class TestHttpRequestPaymentHandlerX402V2: + """Tests for HttpRequestPaymentHandler X402 v2 support. + + X402 v2 conveys the payment requirement via a Payment-Required HTTP response header + whose value is a base64-encoded JSON payload. The http_request tool includes this + header in its Headers: {...} text block (using Python dict repr with single quotes). + """ + + def setup_method(self): + """Set up test fixtures.""" + import base64 + + self.handler = HttpRequestPaymentHandler() + + # Build a realistic x402 v2 payload + self.x402_v2_payload = { + "x402Version": 2, + "error": "Payment required", + "resource": { + "url": "https://example.com/weather", + "description": "Weather report", + "mimeType": "application/json", + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF71", + "payTo": "0x2eDbF699657ae1A09D9C3833FD162A6b59344364", + "maxTimeoutSeconds": 300, + "extra": {"name": "USDC", "version": "2"}, + } + ], + } + self.x402_v2_base64 = base64.b64encode(json.dumps(self.x402_v2_payload).encode()).decode() + + def test_extract_headers_with_payment_required_single_quotes(self): + """Test extracting headers containing Payment-Required in Python dict repr format.""" + # This is what the http_request tool actually produces: str(dict) with single quotes + result = [ + {"text": "Status Code: 402"}, + {"text": f"Headers: {{'payment-required': '{self.x402_v2_base64}', 'Content-Type': 'application/json'}}"}, + {"text": "Body: {}"}, + ] + + headers = self.handler.extract_headers(result) + + assert headers is not None + assert "payment-required" in headers + assert headers["payment-required"] == self.x402_v2_base64 + assert headers["Content-Type"] == "application/json" + + def test_extract_headers_with_payment_required_json_format(self): + """Test extracting headers containing Payment-Required in JSON format.""" + import json as json_mod + + headers_dict = {"Payment-Required": self.x402_v2_base64, "Content-Type": "application/json"} + result = [ + {"text": "Status Code: 402"}, + {"text": f"Headers: {json_mod.dumps(headers_dict)}"}, + {"text": "Body: {}"}, + ] + + headers = self.handler.extract_headers(result) + + assert headers is not None + assert "Payment-Required" in headers + assert headers["Payment-Required"] == self.x402_v2_base64 + + def test_extract_status_code_from_402_v2_response(self): + """Test extracting 402 status code from a v2 response.""" + result = [ + {"text": "Status Code: 402"}, + {"text": f"Headers: {{'payment-required': '{self.x402_v2_base64}', 'Content-Type': 'application/json'}}"}, + {"text": "Body: {}"}, + ] + + status_code = self.handler.extract_status_code(result) + assert status_code == 402 + + def test_full_v2_extraction_flow(self): + """Test the complete v2 extraction flow: status code, headers, and body. + + This simulates the full flow where: + 1. Handler extracts status_code=402 + 2. Handler extracts headers containing Payment-Required + 3. Plugin builds payment_required_request + 4. PaymentManager._extract_x402_payload finds the header and decodes it + """ + import base64 + + result = [ + {"text": "Status Code: 402"}, + {"text": f"Headers: {{'payment-required': '{self.x402_v2_base64}', 'Content-Type': 'application/json'}}"}, + {"text": 'Body: {"error": "Payment required"}'}, + ] + + # Step 1: Extract status code + status_code = self.handler.extract_status_code(result) + assert status_code == 402 + + # Step 2: Extract headers (with Payment-Required) + headers = self.handler.extract_headers(result) + assert headers is not None + assert "payment-required" in headers + + # Step 3: Extract body + body = self.handler.extract_body(result) + assert body == {"error": "Payment required"} + + # Step 4: Simulate what PaymentManager._extract_x402_payload does + payment_required_header = None + for key, value in headers.items(): + if key.lower() == "payment-required": + payment_required_header = value + break + + assert payment_required_header is not None + + # Step 5: Decode the base64 payload + decoded = base64.b64decode(payment_required_header) + x402_payload = json.loads(decoded) + + assert x402_payload["x402Version"] == 2 + assert "accepts" in x402_payload + assert x402_payload["accepts"][0]["scheme"] == "exact" + assert x402_payload["accepts"][0]["network"] == "eip155:84532" + + def test_extract_headers_case_insensitive_payment_required(self): + """Test that various casings of Payment-Required are preserved in extracted headers.""" + # Server might return different casings + for header_name in ["Payment-Required", "payment-required", "PAYMENT-REQUIRED"]: + result = [ + {"text": f"Headers: {{'{header_name}': '{self.x402_v2_base64}'}}"}, + ] + headers = self.handler.extract_headers(result) + assert headers is not None + assert header_name in headers + assert headers[header_name] == self.x402_v2_base64 + + def test_apply_payment_signature_header(self): + """Test applying PAYMENT-SIGNATURE header (v2 format) to tool input.""" + tool_input = {"method": "GET", "url": "https://example.com/weather", "headers": {}} + payment_header = {"PAYMENT-SIGNATURE": "base64-encoded-v2-signature"} + + result = self.handler.apply_payment_header(tool_input, payment_header) + + assert result is True + assert tool_input["headers"]["PAYMENT-SIGNATURE"] == "base64-encoded-v2-signature" + + def test_v2_body_without_x402version_still_extracts(self): + """Test that body without x402Version is still extracted (v2 uses headers, not body).""" + result = [ + {"text": "Status Code: 402"}, + {"text": f"Headers: {{'payment-required': '{self.x402_v2_base64}', 'Content-Type': 'application/json'}}"}, + {"text": 'Body: {"error": "Payment required", "message": "Use Payment-Required header"}'}, + ] + + body = self.handler.extract_body(result) + assert body is not None + assert body["error"] == "Payment required" + # Body does NOT need x402Version for v2 — that's in the header + + def test_parse_headers_string_with_base64_value(self): + """Test _parse_headers_string handles base64 values with special chars correctly.""" + # Base64 can contain +, /, = which are valid in Python string literals + headers_str = f"{{'payment-required': '{self.x402_v2_base64}'}}" + + parsed = HttpRequestPaymentHandler._parse_headers_string(headers_str) + + assert parsed is not None + assert parsed["payment-required"] == self.x402_v2_base64 + + def test_parse_headers_string_returns_none_for_invalid_input(self): + """Test _parse_headers_string returns None for unparseable strings.""" + assert HttpRequestPaymentHandler._parse_headers_string("not a dict at all") is None + assert HttpRequestPaymentHandler._parse_headers_string("{broken: syntax[") is None + assert HttpRequestPaymentHandler._parse_headers_string("") is None diff --git a/tests/bedrock_agentcore/payments/test_client.py b/tests/bedrock_agentcore/payments/test_client.py new file mode 100644 index 00000000..e83bf91d --- /dev/null +++ b/tests/bedrock_agentcore/payments/test_client.py @@ -0,0 +1,1547 @@ +"""Unit tests for PaymentClient control plane operations.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest +from botocore.exceptions import ClientError + +from bedrock_agentcore.payments import PaymentClient +from bedrock_agentcore.payments.client import PaymentConnectorConfig + +# Get role ARN from environment variable, with fallback for testing +TEST_ROLE_ARN = os.getenv("TEST_PAYMENT_ROLE_ARN", "arn:aws:iam::123456789012:role/bedrock-payment-role") + + +class TestPaymentClientInitialization: + """Tests for PaymentClient initialization.""" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_init_with_valid_region(self, mock_session, mock_boto3_client): + """Test initialization with valid region_name.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-east-1") + + assert client.region_name == "us-east-1" + assert client.payments_cp_client == mock_cp_client + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_init_with_no_region_uses_session_region(self, mock_session, mock_boto3_client): + """Test initialization with no region_name uses boto3 session region.""" + mock_session.return_value.region_name = "eu-west-1" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient() + + assert client.region_name == "eu-west-1" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_init_with_no_region_uses_fallback(self, mock_session, mock_boto3_client): + """Test initialization with no region_name uses us-west-2 fallback.""" + mock_session.return_value.region_name = None + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient() + + assert client.region_name == "us-west-2" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_init_with_integration_source(self, mock_session, mock_boto3_client): + """Test initialization with integration_source.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(integration_source="my-app") + + assert client.integration_source == "my-app" + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_init_creates_control_plane_client(self, mock_session, mock_boto3_client, mock_identity_client): + """Test initialization creates control plane client.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + PaymentClient(region_name="us-west-2") + + # Verify control plane client was created (first call should be for bedrock-agentcore-control) + assert mock_boto3_client.call_count >= 1 + first_call_args = mock_boto3_client.call_args_list[0] + assert first_call_args[0][0] == "bedrock-agentcore-control" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_init_failure_raises_payment_error(self, mock_session, mock_boto3_client): + """Test initialization failure raises Exception.""" + mock_session.return_value.region_name = "us-west-2" + mock_boto3_client.side_effect = RuntimeError("Connection failed") + + with pytest.raises(RuntimeError, match="Connection failed"): + PaymentClient(region_name="us-west-2") + + +class TestPaymentManagerOperations: + """Tests for PaymentClient payment manager operations.""" + + role_arn = "arn:aws:iam::123456789012:role/bedrock-payment-role" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_create_payment_manager_success(self, mock_session, mock_boto3_client): + """Test successful payment manager creation.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.create_payment_manager.return_value = { + "paymentManagerArn": "arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123", + "paymentManagerId": "pm-123", + "status": "ACTIVE", + } + + client = PaymentClient(region_name="us-west-2") + result = client.create_payment_manager( + name="test-manager", + role_arn=self.role_arn, + ) + + assert result["paymentManagerId"] == "pm-123" + assert result["status"] == "ACTIVE" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_get_payment_manager_success(self, mock_session, mock_boto3_client): + """Test successful payment manager retrieval.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.get_payment_manager.return_value = { + "paymentManagerId": "pm-123", + "paymentManagerArn": "arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123", + "name": "test-manager", + "status": "ACTIVE", + } + + client = PaymentClient(region_name="us-west-2") + result = client.get_payment_manager(payment_manager_id="pm-123") + + assert result["paymentManagerId"] == "pm-123" + assert result["name"] == "test-manager" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_managers_success(self, mock_session, mock_boto3_client): + """Test successful payment managers listing.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.list_payment_managers.return_value = { + "paymentManagers": [ + { + "paymentManagerId": "pm-123", + "paymentManagerArn": "arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123", + "name": "test-manager", + "status": "ACTIVE", + } + ] + } + + client = PaymentClient(region_name="us-west-2") + result = client.list_payment_managers() + + assert len(result["paymentManagers"]) == 1 + assert result["paymentManagers"][0]["paymentManagerId"] == "pm-123" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_update_payment_manager_success(self, mock_session, mock_boto3_client): + """Test successful payment manager update.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.update_payment_manager.return_value = { + "paymentManagerId": "pm-123", + "paymentManagerArn": "arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123", + "name": "test-manager", + "description": "Updated description", + "status": "ACTIVE", + } + + client = PaymentClient(region_name="us-west-2") + result = client.update_payment_manager( + payment_manager_id="pm-123", + description="Updated description", + ) + + assert result["paymentManagerId"] == "pm-123" + assert result["description"] == "Updated description" + assert result["status"] == "ACTIVE" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_delete_payment_manager_success(self, mock_session, mock_boto3_client): + """Test successful payment manager deletion.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.delete_payment_manager.return_value = { + "status": "DELETED", + } + + client = PaymentClient(region_name="us-west-2") + result = client.delete_payment_manager(payment_manager_id="pm-123") + + assert result["paymentManagerId"] == "pm-123" + + +class TestPaymentConnectorOperations: + """Tests for PaymentClient payment connector operations.""" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_create_payment_connector_success(self, mock_session, mock_boto3_client): + """Test successful payment connector creation.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.create_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "status": "ACTIVE", + } + + client = PaymentClient(region_name="us-west-2") + result = client.create_payment_connector( + payment_manager_id="pm-123", + name="test-connector", + connector_type="CoinbaseCDP", + credential_provider_configurations=[ + {"coinbaseCDP": {"credentialProviderArn": "arn:aws:secretsmanager:us-west-2:123456789012:secret:test"}} + ], + ) + + assert result["paymentConnectorId"] == "pc-123" + assert result["status"] == "ACTIVE" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_get_payment_connector_success(self, mock_session, mock_boto3_client): + """Test successful payment connector retrieval.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.get_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "paymentManagerId": "pm-123", + "name": "test-connector", + "type": "CoinbaseCDP", + "status": "ACTIVE", + } + + client = PaymentClient(region_name="us-west-2") + result = client.get_payment_connector( + payment_manager_id="pm-123", + payment_connector_id="pc-123", + ) + + assert result["paymentConnectorId"] == "pc-123" + assert result["name"] == "test-connector" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_connectors_success(self, mock_session, mock_boto3_client): + """Test successful payment connectors listing.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.list_payment_connectors.return_value = { + "paymentConnectors": [ + { + "paymentConnectorId": "pc-123", + "paymentManagerId": "pm-123", + "name": "test-connector", + "type": "CoinbaseCDP", + "status": "ACTIVE", + } + ] + } + + client = PaymentClient(region_name="us-west-2") + result = client.list_payment_connectors(payment_manager_id="pm-123") + + assert len(result["paymentConnectors"]) == 1 + assert result["paymentConnectors"][0]["paymentConnectorId"] == "pc-123" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_update_payment_connector_success(self, mock_session, mock_boto3_client): + """Test successful payment connector update.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.update_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "paymentManagerId": "pm-123", + "name": "updated-connector", + "type": "CoinbaseCDP", + "status": "ACTIVE", + } + + client = PaymentClient(region_name="us-west-2") + result = client.update_payment_connector( + payment_manager_id="pm-123", + payment_connector_id="pc-123", + description="updated description", + ) + + assert result["name"] == "updated-connector" + + @patch("bedrock_agentcore.payments.client.boto3.Session") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_delete_payment_connector_success(self, mock_boto3_client, mock_session): + """Test successful payment connector deletion.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + mock_cp_client.delete_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "status": "DELETED", + } + + client = PaymentClient(region_name="us-west-2") + result = client.delete_payment_connector( + payment_manager_id="pm-123", + payment_connector_id="pc-123", + ) + + assert result["paymentConnectorId"] == "pc-123" + + +class TestErrorHandling: + """Tests for PaymentClient error handling.""" + + role_arn = "arn:aws:iam::123456789012:role/bedrock-payment-role" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_create_payment_manager_client_error(self, mock_session, mock_boto3_client): + """Test error handling for payment manager creation.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid role ARN"}}, + "CreatePaymentManager", + ) + mock_cp_client.create_payment_manager.side_effect = error + + client = PaymentClient(region_name="us-west-2") + + with pytest.raises(ClientError): + client.create_payment_manager( + name="test-manager", + role_arn=self.role_arn, + ) + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_getattr_forwards_to_cp_client(self, mock_session, mock_boto3_client): + """Test __getattr__ forwards allowed methods to control plane client.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + # Mock the create_payment_manager method on the boto3 client + mock_cp_client.create_payment_manager.return_value = { + "paymentManagerArn": "arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123", + "paymentManagerId": "pm-123", + "status": "ACTIVE", + } + + client = PaymentClient(region_name="us-west-2") + result = client.create_payment_manager(name="test", role_arn="arn") + + # Verify the method was called on the boto3 client + mock_cp_client.create_payment_manager.assert_called_once() + assert result["paymentManagerId"] == "pm-123" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_getattr_raises_for_disallowed_methods(self, mock_session, mock_boto3_client): + """Test __getattr__ raises AttributeError for disallowed methods.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + with pytest.raises(AttributeError): + client.create_payment_instrument(user_id="test") + + +class TestCreatePaymentManagerWithConnector: + """Tests for create_payment_manager_with_connector method.""" + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_successful_creation_with_all_resources(self, mock_boto_client, mock_identity_class): + """Test successful creation of payment manager, connector, and credential provider.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + provider_arn = ( + "arn:aws:acps:us-east-1:123456789012:token-vault/default/" + "paymentcredentialprovider/ansraju-coinbase-provider-kdi94" + ) + mock_identity_client.create_payment_credential_provider.return_value = { + "credentialProviderArn": provider_arn, + "name": "ansraju-coinbase-provider-kdi94", + "credentialProviderVendor": "CoinbaseCDP", + } + + mock_cp_client.create_payment_manager.return_value = { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123", + "paymentManagerId": "pm-123", + "status": "READY", + } + + mock_cp_client.create_payment_connector.return_value = { + "paymentConnectorId": "connector-456", + "status": "READY", + } + + # Create client + client = PaymentClient(region_name="us-east-1") + + payment_connector_config: PaymentConnectorConfig = { + "name": "coinbase-connector", + "description": "Coinbase CDP Connector", + "payment_credential_provider_config": { + "name": "ansraju-coinbase-provider-kdi94", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": "test-api-key-secret", + "wallet_secret": "test-wallet-secret", + }, + }, + } + + response = client.create_payment_manager_with_connector( + payment_manager_name="CDPPaymentManager", + payment_manager_description="Coinbase Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=payment_connector_config, + ) + + # Verify response structure + assert "paymentManager" in response + assert "paymentConnector" in response + assert "credentialProvider" in response + + assert ( + response["paymentManager"]["paymentManagerArn"] + == "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + ) + assert response["paymentManager"]["paymentManagerId"] == "pm-123" + assert response["paymentManager"]["name"] == "CDPPaymentManager" + assert response["paymentManager"]["status"] == "READY" + + assert response["paymentConnector"]["paymentConnectorId"] == "connector-456" + assert response["paymentConnector"]["name"] == "coinbase-connector" + assert response["paymentConnector"]["status"] == "READY" + + expected_provider_arn = ( + "arn:aws:acps:us-east-1:123456789012:token-vault/default/" + "paymentcredentialprovider/ansraju-coinbase-provider-kdi94" + ) + assert response["credentialProvider"]["credentialProviderArn"] == expected_provider_arn + assert response["credentialProvider"]["name"] == "ansraju-coinbase-provider-kdi94" + + # Verify method calls + mock_identity_client.create_payment_credential_provider.assert_called_once() + mock_cp_client.create_payment_manager.assert_called_once() + mock_cp_client.create_payment_connector.assert_called_once() + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_rollback_on_connector_creation_failure(self, mock_boto_client, mock_identity_class): + """Test rollback when payment connector creation fails.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + test_provider_arn = ( + "arn:aws:acps:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/test-provider" + ) + mock_identity_client.create_payment_credential_provider.return_value = { + "credentialProviderArn": test_provider_arn, + "name": "test-provider", + } + + mock_cp_client.create_payment_manager.return_value = { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123", + "paymentManagerId": "pm-123", + "status": "READY", + } + + # Simulate connector creation failure + mock_cp_client.create_payment_connector.side_effect = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid connector configuration"}}, + "CreatePaymentConnector", + ) + + mock_cp_client.delete_payment_manager.return_value = {"status": "DELETED"} + mock_identity_client.delete_payment_credential_provider.return_value = {} + + # Create client + client = PaymentClient(region_name="us-east-1") + + connector_config: PaymentConnectorConfig = { + "name": "coinbase-connector", + "description": "Coinbase CDP Connector", + "payment_credential_provider_config": { + "name": "test-provider", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": "test-api-key-secret", + "wallet_secret": "test-wallet-secret", + }, + }, + } + + # Verify exception is raised + with pytest.raises(ClientError) as exc_info: + client.create_payment_manager_with_connector( + payment_manager_name="CDPPaymentManager", + payment_manager_description="Coinbase Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=connector_config, + ) + + assert exc_info.value.response["Error"]["Code"] == "PaymentManagerCreationFailed" + + # Verify rollback was called + mock_cp_client.delete_payment_manager.assert_called_once_with(paymentManagerId="pm-123") + mock_identity_client.delete_payment_credential_provider.assert_called_once_with(name="test-provider") + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_rollback_on_manager_creation_failure(self, mock_boto_client, mock_identity_class): + """Test rollback when payment manager creation fails.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + test_provider_arn = ( + "arn:aws:acps:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/test-provider" + ) + mock_identity_client.create_payment_credential_provider.return_value = { + "credentialProviderArn": test_provider_arn, + "name": "test-provider", + } + + # Simulate manager creation failure + mock_cp_client.create_payment_manager.side_effect = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid role ARN"}}, + "CreatePaymentManager", + ) + + mock_identity_client.delete_payment_credential_provider.return_value = {} + + # Create client + client = PaymentClient(region_name="us-east-1") + + connector_config: PaymentConnectorConfig = { + "name": "coinbase-connector", + "description": "Coinbase CDP Connector", + "payment_credential_provider_config": { + "name": "test-provider", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": "test-api-key-secret", + "wallet_secret": "test-wallet-secret", + }, + }, + } + + # Verify exception is raised + with pytest.raises(ClientError) as exc_info: + client.create_payment_manager_with_connector( + payment_manager_name="CDPPaymentManager", + payment_manager_description="Coinbase Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=connector_config, + ) + + # Verify exception details + assert exc_info.value.response["Error"]["Code"] == "PaymentManagerCreationFailed" + assert "Failed to create payment manager with connector" in exc_info.value.response["Error"]["Message"] + assert "Invalid role ARN" in exc_info.value.response["Error"]["Message"] + + # Verify rollback was called (only credential provider should be deleted) + mock_cp_client.delete_payment_manager.assert_not_called() + mock_identity_client.delete_payment_credential_provider.assert_called_once_with(name="test-provider") + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_rollback_on_credential_provider_creation_failure(self, mock_boto_client, mock_identity_class): + """Test rollback when credential provider creation fails.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + # Mock credential provider creation to fail with "already exists" error + error_msg = "Credential provider with name: test-provider already exists" + mock_identity_client.create_payment_credential_provider.side_effect = ClientError( + { + "Error": { + "Code": "ConflictException", + "Message": error_msg, + } + }, + "CreatePaymentCredentialProvider", + ) + + # Create client + client = PaymentClient(region_name="us-east-1") + + connector_config: PaymentConnectorConfig = { + "name": "coinbase-connector", + "description": "Coinbase CDP Connector", + "payment_credential_provider_config": { + "name": "test-provider", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": "test-api-key-secret", + "wallet_secret": "test-wallet-secret", + }, + }, + } + + # Verify exception is raised + with pytest.raises(ClientError) as exc_info: + client.create_payment_manager_with_connector( + payment_manager_name="CDPPaymentManager", + payment_manager_description="Coinbase Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=connector_config, + ) + + # Verify exception details + assert exc_info.value.response["Error"]["Code"] == "PaymentManagerCreationFailed" + assert "Failed to create payment manager with connector" in exc_info.value.response["Error"]["Message"] + assert "ConflictException" in exc_info.value.response["Error"]["Message"] + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_client_token_generation(self, mock_boto_client, mock_identity_class): + """Test that client_token is generated if not provided.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + test_provider_arn = ( + "arn:aws:acps:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/test-provider" + ) + mock_identity_client.create_payment_credential_provider.return_value = { + "credentialProviderArn": test_provider_arn, + "name": "test-provider", + } + + mock_cp_client.create_payment_manager.return_value = { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123", + "paymentManagerId": "pm-123", + "status": "READY", + } + + mock_cp_client.create_payment_connector.return_value = { + "paymentConnectorId": "connector-456", + "status": "READY", + } + + # Create client + client = PaymentClient(region_name="us-east-1") + + connector_config: PaymentConnectorConfig = { + "name": "coinbase-connector", + "description": "Coinbase CDP Connector", + "payment_credential_provider_config": { + "name": "test-provider", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": "test-api-key-secret", + "wallet_secret": "test-wallet-secret", + }, + }, + } + + response = client.create_payment_manager_with_connector( + payment_manager_name="CDPPaymentManager", + payment_manager_description="Coinbase Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=connector_config, + ) + + # Verify response is successful + assert response["paymentManager"]["paymentManagerId"] == "pm-123" + + # Verify client_token was passed to create methods + manager_call_kwargs = mock_cp_client.create_payment_manager.call_args[1] + assert "clientToken" in manager_call_kwargs + assert manager_call_kwargs["clientToken"] is not None + + connector_call_kwargs = mock_cp_client.create_payment_connector.call_args[1] + assert "clientToken" in connector_call_kwargs + assert connector_call_kwargs["clientToken"] is not None + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_credential_provider_config_structure(self, mock_boto_client, mock_identity_class): + """Test that credential provider config is correctly structured.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + test_provider_arn = ( + "arn:aws:acps:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/test-provider" + ) + mock_identity_client.create_payment_credential_provider.return_value = { + "credentialProviderArn": test_provider_arn, + "name": "test-provider", + } + + mock_cp_client.create_payment_manager.return_value = { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123", + "paymentManagerId": "pm-123", + "status": "READY", + } + + mock_cp_client.create_payment_connector.return_value = { + "paymentConnectorId": "connector-456", + "status": "READY", + } + + # Create client + client = PaymentClient(region_name="us-east-1") + + payment_connector_config: PaymentConnectorConfig = { + "name": "coinbase-connector", + "description": "Coinbase CDP Connector", + "payment_credential_provider_config": { + "name": "test-provider", + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": "test-api-key-secret", + "wallet_secret": "test-wallet-secret", + }, + }, + } + + client.create_payment_manager_with_connector( + payment_manager_name="CDPPaymentManager", + payment_manager_description="Coinbase Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=payment_connector_config, + ) + + # Verify credential provider was called with correct structure + call_args = mock_identity_client.create_payment_credential_provider.call_args + assert call_args[1]["name"] == "test-provider" + assert call_args[1]["credential_provider_vendor"] == "CoinbaseCDP" + + provider_config_input = call_args[1]["provider_configuration_input"] + assert "coinbaseCdpConfiguration" in provider_config_input + assert provider_config_input["coinbaseCdpConfiguration"]["apiKeyId"] == "test-api-key-id" + assert provider_config_input["coinbaseCdpConfiguration"]["apiKeySecret"] == "test-api-key-secret" + assert provider_config_input["coinbaseCdpConfiguration"]["walletSecret"] == "test-wallet-secret" + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_unsupported_vendor_raises_error(self, mock_boto_client, mock_identity_class): + """Test that unsupported vendor type raises ValueError wrapped in ClientError.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + # Create client + client = PaymentClient(region_name="us-east-1") + + payment_connector_config: PaymentConnectorConfig = { + "name": "generic-connector", + "description": "Generic Vendor Connector", + "payment_credential_provider_config": { + "name": "test-provider", + "credential_provider_vendor": "GenericVendor", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": "test-api-key-secret", + }, + }, + } + + with pytest.raises(ClientError) as exc_info: + client.create_payment_manager_with_connector( + payment_manager_name="GenericPaymentManager", + payment_manager_description="Generic Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=payment_connector_config, + ) + + assert exc_info.value.response["Error"]["Code"] == "PaymentManagerCreationFailed" + assert "Unsupported credential_provider_vendor" in exc_info.value.response["Error"]["Message"] + assert "GenericVendor" in exc_info.value.response["Error"]["Message"] + + @patch("bedrock_agentcore.payments.client.IdentityClient") + @patch("bedrock_agentcore.payments.client.boto3.client") + def test_stripe_privy_vendor_config_structure(self, mock_boto_client, mock_identity_class): + """Test that StripePrivy vendor config uses stripePrivyConfiguration.""" + # Setup mocks + mock_cp_client = MagicMock() + mock_boto_client.return_value = mock_cp_client + + mock_identity_client = MagicMock() + mock_identity_class.return_value = mock_identity_client + + test_provider_arn = ( + "arn:aws:acps:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/test-provider" + ) + mock_identity_client.create_payment_credential_provider.return_value = { + "credentialProviderArn": test_provider_arn, + "name": "test-provider", + "credentialProviderVendor": "StripePrivy", + } + + mock_cp_client.create_payment_manager.return_value = { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123", + "paymentManagerId": "pm-123", + "status": "READY", + } + + mock_cp_client.create_payment_connector.return_value = { + "paymentConnectorId": "connector-456", + "status": "READY", + } + + # Create client + client = PaymentClient(region_name="us-east-1") + + payment_connector_config: PaymentConnectorConfig = { + "name": "stripe-privy-connector", + "description": "Stripe + Privy Connector", + "payment_credential_provider_config": { + "name": "test-provider", + "credential_provider_vendor": "StripePrivy", + "credentials": { + "app_id": "test-app-id", + "app_secret": "test-app-secret", + "authorization_private_key": "test-auth-private-key", + "authorization_id": "test-auth-id", + }, + }, + } + + response = client.create_payment_manager_with_connector( + payment_manager_name="StripePaymentManager", + payment_manager_description="Stripe + Privy Payment Manager", + authorizer_type="AWS_IAM", + role_arn="arn:aws:iam::123456789012:role/BedrockAgentCoreFullAccess", + payment_connector_config=payment_connector_config, + ) + + # Verify response structure + assert response["paymentManager"]["paymentManagerId"] == "pm-123" + assert response["paymentConnector"]["paymentConnectorId"] == "connector-456" + assert response["credentialProvider"]["credentialProviderArn"] == test_provider_arn + assert response["credentialProvider"]["credentialProviderVendor"] == "StripePrivy" + + # Verify credential provider was called with correct structure + call_args = mock_identity_client.create_payment_credential_provider.call_args + assert call_args[1]["name"] == "test-provider" + assert call_args[1]["credential_provider_vendor"] == "StripePrivy" + + provider_config_input = call_args[1]["provider_configuration_input"] + assert "stripePrivyConfiguration" in provider_config_input + assert provider_config_input["stripePrivyConfiguration"]["appId"] == "test-app-id" + assert provider_config_input["stripePrivyConfiguration"]["appSecret"] == "test-app-secret" + assert provider_config_input["stripePrivyConfiguration"]["authorizationPrivateKey"] == "test-auth-private-key" + assert provider_config_input["stripePrivyConfiguration"]["authorizationId"] == "test-auth-id" + + # Verify connector was created with stripePrivy config key + connector_call_args = mock_cp_client.create_payment_connector.call_args[1] + assert connector_call_args["credentialProviderConfigurations"] == [ + {"stripePrivy": {"credentialProviderArn": test_provider_arn}} + ] + + +class TestSafeErrorMessage: + """Tests for PaymentClient._safe_error_message static method.""" + + def test_client_error_message(self): + """Test _safe_error_message with ClientError.""" + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid input"}}, + "CreatePaymentManager", + ) + result = PaymentClient._safe_error_message(error) + assert result == "ValidationException: Invalid input" + + def test_value_error_message(self): + """Test _safe_error_message with ValueError.""" + error = ValueError("Missing required field") + result = PaymentClient._safe_error_message(error) + assert result == "ValueError: Missing required field" + + def test_generic_error_message_redacted(self): + """Test _safe_error_message redacts details for generic exceptions.""" + error = RuntimeError("sensitive internal details") + result = PaymentClient._safe_error_message(error) + assert result == "RuntimeError: (details redacted for security)" + assert "sensitive" not in result + + +class TestBuildProviderConfigInput: + """Tests for _build_provider_config_input static method.""" + + def test_coinbase_cdp_config(self): + """Test CoinbaseCDP vendor produces correct configuration.""" + config = { + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "key-id", + "api_key_secret": "key-secret", + "wallet_secret": "wallet-secret", + }, + } + result = PaymentClient._build_provider_config_input(config) + assert result == { + "coinbaseCdpConfiguration": { + "apiKeyId": "key-id", + "apiKeySecret": "key-secret", + "walletSecret": "wallet-secret", + } + } + + def test_stripe_privy_config(self): + """Test StripePrivy vendor produces correct configuration.""" + config = { + "credential_provider_vendor": "StripePrivy", + "credentials": { + "app_id": "app-id", + "app_secret": "app-secret", + "authorization_private_key": "auth-key", + "authorization_id": "auth-id", + }, + } + result = PaymentClient._build_provider_config_input(config) + assert result == { + "stripePrivyConfiguration": { + "appId": "app-id", + "appSecret": "app-secret", + "authorizationPrivateKey": "auth-key", + "authorizationId": "auth-id", + } + } + + def test_unsupported_vendor_raises_error(self): + """Test unsupported vendor raises ValueError.""" + config = { + "credential_provider_vendor": "UnknownVendor", + "credentials": {"some_key": "some_value"}, + } + with pytest.raises(ValueError, match="Unsupported credential_provider_vendor"): + PaymentClient._build_provider_config_input(config) + + def test_missing_vendor_raises_error(self): + """Test missing vendor raises ValueError.""" + config = {"credentials": {"api_key_id": "key"}} + with pytest.raises(ValueError, match="credential_provider_vendor is required"): + PaymentClient._build_provider_config_input(config) + + def test_coinbase_missing_fields_raises_error(self): + """Test CoinbaseCDP with missing fields raises ValueError.""" + config = { + "credential_provider_vendor": "CoinbaseCDP", + "credentials": {"api_key_id": "key-id"}, + } + with pytest.raises(ValueError, match="Missing required CoinbaseCDP credential fields"): + PaymentClient._build_provider_config_input(config) + + def test_stripe_privy_missing_fields_raises_error(self): + """Test StripePrivy with missing fields raises ValueError.""" + config = { + "credential_provider_vendor": "StripePrivy", + "credentials": {"app_id": "app-id"}, + } + with pytest.raises(ValueError, match="Missing required StripePrivy credential fields"): + PaymentClient._build_provider_config_input(config) + + +class TestWaitForStatus: + """Tests for PaymentClient._wait_for_status method.""" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_wait_for_status_immediate_success(self, mock_session, mock_boto3_client): + """Test _wait_for_status when resource is already in target status.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + mock_get = MagicMock(return_value={"status": "ACTIVE", "id": "resource-123"}) + result = client._wait_for_status(mock_get, "resource-123", "ACTIVE") + assert result["status"] == "ACTIVE" + mock_get.assert_called_once_with("resource-123") + + @patch("bedrock_agentcore.payments.client.time.sleep") + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_wait_for_status_polls_until_ready(self, mock_session, mock_boto3_client, mock_sleep): + """Test _wait_for_status polls until target status is reached.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + mock_get = MagicMock( + side_effect=[ + {"status": "CREATING", "id": "resource-123"}, + {"status": "CREATING", "id": "resource-123"}, + {"status": "ACTIVE", "id": "resource-123"}, + ] + ) + result = client._wait_for_status(mock_get, "resource-123", "ACTIVE", poll_interval=1) + assert result["status"] == "ACTIVE" + assert mock_get.call_count == 3 + + @patch("bedrock_agentcore.payments.client.time.sleep") + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_wait_for_status_timeout(self, mock_session, mock_boto3_client, mock_sleep): + """Test _wait_for_status raises TimeoutError when max_wait exceeded.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + mock_get = MagicMock(return_value={"status": "CREATING"}) + with pytest.raises(TimeoutError, match="Timeout waiting for resource"): + client._wait_for_status(mock_get, "resource-123", "ACTIVE", max_wait=0) + + @patch("bedrock_agentcore.payments.client.time.sleep") + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_wait_for_status_failed_status(self, mock_session, mock_boto3_client, mock_sleep): + """Test _wait_for_status raises ClientError on failed status.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + mock_get = MagicMock(return_value={"status": "CREATE_FAILED"}) + with pytest.raises(ClientError): + client._wait_for_status(mock_get, "resource-123", "ACTIVE") + + @patch("bedrock_agentcore.payments.client.time.sleep") + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_wait_for_status_resource_not_found_retries(self, mock_session, mock_boto3_client, mock_sleep): + """Test _wait_for_status retries on ResourceNotFoundException.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + not_found_error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Not found"}}, + "GetResource", + ) + mock_get = MagicMock( + side_effect=[ + not_found_error, + {"status": "ACTIVE", "id": "resource-123"}, + ] + ) + result = client._wait_for_status(mock_get, "resource-123", "ACTIVE", poll_interval=1) + assert result["status"] == "ACTIVE" + assert mock_get.call_count == 2 + + @patch("bedrock_agentcore.payments.client.time.sleep") + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_wait_for_status_non_retryable_client_error(self, mock_session, mock_boto3_client, mock_sleep): + """Test _wait_for_status raises non-retryable ClientError.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + access_denied = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Access denied"}}, + "GetResource", + ) + mock_get = MagicMock(side_effect=access_denied) + with pytest.raises(ClientError): + client._wait_for_status(mock_get, "resource-123", "ACTIVE") + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_wait_for_status_with_extra_kwargs(self, mock_session, mock_boto3_client): + """Test _wait_for_status passes extra kwargs to get_method.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + + client = PaymentClient(region_name="us-west-2") + + mock_get = MagicMock(return_value={"status": "READY"}) + result = client._wait_for_status(mock_get, "resource-123", "READY", payment_manager_id="pm-456") + assert result["status"] == "READY" + mock_get.assert_called_once_with("resource-123", payment_manager_id="pm-456") + + +class TestPaymentManagerCRUDPaths: + """Tests for PaymentClient CRUD method success and error paths.""" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_get_payment_manager_returns_formatted_response(self, mock_session, mock_boto3_client): + """Test get_payment_manager formats the response correctly.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.get_payment_manager.return_value = { + "paymentManagerId": "pm-123", + "paymentManagerArn": "arn:aws:bedrock:us-west-2:123:payment-manager/pm-123", + "name": "test-manager", + "description": "desc", + "status": "READY", + "createdAt": "2025-01-01", + "updatedAt": "2025-01-02", + } + + client = PaymentClient(region_name="us-west-2") + result = client.get_payment_manager("pm-123") + + assert result["paymentManagerId"] == "pm-123" + assert result["name"] == "test-manager" + assert result["status"] == "READY" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_get_payment_manager_error_raises(self, mock_session, mock_boto3_client): + """Test get_payment_manager raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.get_payment_manager.side_effect = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Not found"}}, + "GetPaymentManager", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.get_payment_manager("pm-nonexistent") + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_managers_returns_formatted_response(self, mock_session, mock_boto3_client): + """Test list_payment_managers formats the response correctly.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.list_payment_managers.return_value = { + "paymentManagers": [ + {"paymentManagerId": "pm-1", "name": "mgr-1", "status": "READY"}, + {"paymentManagerId": "pm-2", "name": "mgr-2", "status": "READY"}, + ], + "nextToken": "token-abc", + } + + client = PaymentClient(region_name="us-west-2") + result = client.list_payment_managers(max_results=10) + + assert len(result["paymentManagers"]) == 2 + assert result.get("nextToken") == "token-abc" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_managers_with_next_token(self, mock_session, mock_boto3_client): + """Test list_payment_managers with pagination token.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.list_payment_managers.return_value = { + "paymentManagers": [], + } + + client = PaymentClient(region_name="us-west-2") + client.list_payment_managers(next_token="page-2") + + mock_cp_client.list_payment_managers.assert_called_once() + call_kwargs = mock_cp_client.list_payment_managers.call_args[1] + assert call_kwargs["nextToken"] == "page-2" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_managers_error_raises(self, mock_session, mock_boto3_client): + """Test list_payment_managers raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.list_payment_managers.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Denied"}}, + "ListPaymentManagers", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.list_payment_managers() + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_update_payment_manager_success(self, mock_session, mock_boto3_client): + """Test update_payment_manager formats the response correctly.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.update_payment_manager.return_value = { + "paymentManagerId": "pm-123", + "paymentManagerArn": "arn:aws:bedrock:us-west-2:123:payment-manager/pm-123", + "status": "READY", + "lastUpdatedAt": "2025-01-02", + } + + client = PaymentClient(region_name="us-west-2") + result = client.update_payment_manager( + payment_manager_id="pm-123", + description="updated desc", + ) + + assert result["paymentManagerId"] == "pm-123" + assert result["status"] == "READY" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_update_payment_manager_error_raises(self, mock_session, mock_boto3_client): + """Test update_payment_manager raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.update_payment_manager.side_effect = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid"}}, + "UpdatePaymentManager", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.update_payment_manager(payment_manager_id="pm-123") + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_delete_payment_manager_success(self, mock_session, mock_boto3_client): + """Test delete_payment_manager returns formatted response.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.delete_payment_manager.return_value = { + "paymentManagerId": "pm-123", + "status": "DELETING", + } + + client = PaymentClient(region_name="us-west-2") + result = client.delete_payment_manager("pm-123") + + assert result["paymentManagerId"] == "pm-123" + assert result["status"] == "DELETING" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_delete_payment_manager_error_raises(self, mock_session, mock_boto3_client): + """Test delete_payment_manager raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.delete_payment_manager.side_effect = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Not found"}}, + "DeletePaymentManager", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.delete_payment_manager("pm-nonexistent") + + +class TestPaymentConnectorCRUDPaths: + """Tests for PaymentClient connector CRUD method paths.""" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_create_payment_connector_returns_formatted_response(self, mock_session, mock_boto3_client): + """Test create_payment_connector formats the response correctly.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.create_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "paymentManagerId": "pm-123", + "name": "test-connector", + "status": "CREATING", + } + + client = PaymentClient(region_name="us-west-2") + result = client.create_payment_connector( + payment_manager_id="pm-123", + name="test-connector", + connector_type="BLOCKCHAIN", + credential_provider_configurations=[{"coinbaseCDP": {"credentialProviderArn": "arn:test"}}], + ) + + assert result["paymentConnectorId"] == "pc-123" + assert result["status"] == "CREATING" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_create_payment_connector_error_raises(self, mock_session, mock_boto3_client): + """Test create_payment_connector raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.create_payment_connector.side_effect = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid"}}, + "CreatePaymentConnector", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.create_payment_connector( + payment_manager_id="pm-123", + name="test", + connector_type="BLOCKCHAIN", + credential_provider_configurations=[{"coinbaseCDP": {"credentialProviderArn": "arn:test"}}], + ) + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_get_payment_connector_returns_formatted_response(self, mock_session, mock_boto3_client): + """Test get_payment_connector formats the response correctly.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.get_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "paymentManagerId": "pm-123", + "name": "test-connector", + "status": "READY", + "type": "BLOCKCHAIN", + } + + client = PaymentClient(region_name="us-west-2") + result = client.get_payment_connector("pm-123", "pc-123") + + assert result["paymentConnectorId"] == "pc-123" + assert result["status"] == "READY" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_get_payment_connector_error_raises(self, mock_session, mock_boto3_client): + """Test get_payment_connector raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.get_payment_connector.side_effect = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Not found"}}, + "GetPaymentConnector", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.get_payment_connector("pm-123", "pc-nonexistent") + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_connectors_returns_formatted_response(self, mock_session, mock_boto3_client): + """Test list_payment_connectors formats the response correctly.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.list_payment_connectors.return_value = { + "paymentConnectors": [ + {"paymentConnectorId": "pc-1", "name": "conn-1"}, + ], + } + + client = PaymentClient(region_name="us-west-2") + result = client.list_payment_connectors("pm-123") + + assert len(result["paymentConnectors"]) == 1 + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_connectors_with_pagination(self, mock_session, mock_boto3_client): + """Test list_payment_connectors with pagination parameters.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.list_payment_connectors.return_value = { + "paymentConnectors": [], + "nextToken": "page-2", + } + + client = PaymentClient(region_name="us-west-2") + result = client.list_payment_connectors("pm-123", max_results=5, next_token="page-1") + + assert result.get("nextToken") == "page-2" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_list_payment_connectors_error_raises(self, mock_session, mock_boto3_client): + """Test list_payment_connectors raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.list_payment_connectors.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Denied"}}, + "ListPaymentConnectors", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.list_payment_connectors("pm-123") + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_delete_payment_connector_success(self, mock_session, mock_boto3_client): + """Test delete_payment_connector returns formatted response.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.delete_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "status": "DELETING", + } + + client = PaymentClient(region_name="us-west-2") + result = client.delete_payment_connector("pm-123", "pc-123") + + assert result["paymentConnectorId"] == "pc-123" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_delete_payment_connector_error_raises(self, mock_session, mock_boto3_client): + """Test delete_payment_connector raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.delete_payment_connector.side_effect = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Not found"}}, + "DeletePaymentConnector", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.delete_payment_connector("pm-123", "pc-nonexistent") + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_update_payment_connector_success(self, mock_session, mock_boto3_client): + """Test update_payment_connector returns formatted response.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.update_payment_connector.return_value = { + "paymentConnectorId": "pc-123", + "paymentManagerId": "pm-123", + "name": "updated-connector", + "status": "READY", + } + + client = PaymentClient(region_name="us-west-2") + result = client.update_payment_connector( + payment_manager_id="pm-123", + payment_connector_id="pc-123", + description="updated", + ) + + assert result["paymentConnectorId"] == "pc-123" + + @patch("bedrock_agentcore.payments.client.boto3.client") + @patch("bedrock_agentcore.payments.client.boto3.Session") + def test_update_payment_connector_error_raises(self, mock_session, mock_boto3_client): + """Test update_payment_connector raises ClientError on failure.""" + mock_session.return_value.region_name = "us-west-2" + mock_cp_client = MagicMock() + mock_boto3_client.return_value = mock_cp_client + mock_cp_client.update_payment_connector.side_effect = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid"}}, + "UpdatePaymentConnector", + ) + + client = PaymentClient(region_name="us-west-2") + with pytest.raises(ClientError): + client.update_payment_connector( + payment_manager_id="pm-123", + payment_connector_id="pc-123", + ) diff --git a/tests/bedrock_agentcore/payments/test_deprecated_naming.py b/tests/bedrock_agentcore/payments/test_deprecated_naming.py new file mode 100644 index 00000000..27055bac --- /dev/null +++ b/tests/bedrock_agentcore/payments/test_deprecated_naming.py @@ -0,0 +1,59 @@ +"""Lint guard: ensure correct instrument type naming is used. + +Payment instruments use 'embeddedCryptoWallet' / 'EMBEDDED_CRYPTO_WALLET'. +""" + +import pathlib +import re + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] +SCAN_DIRS = [ + REPO_ROOT / "src" / "bedrock_agentcore" / "payments", + REPO_ROOT / "tests" / "bedrock_agentcore" / "payments", + REPO_ROOT / "tests_integ" / "payments", +] + +BANNED_PATTERNS = [ + re.compile(r"\bcryptoWallet\b(?!\.)", re.IGNORECASE), + re.compile(r"\bCRYPTO_WALLET\b"), + re.compile(r"\bcrypto_wallet\b"), +] + +ALLOWED_PATTERNS = [ + re.compile(r"cryptoX402", re.IGNORECASE), + re.compile(r"CryptoX402"), + re.compile(r"CRYPTO_X402"), + re.compile(r"embeddedCryptoWallet", re.IGNORECASE), + re.compile(r"EMBEDDED_CRYPTO_WALLET"), +] + + +def _is_allowed(line: str, match: re.Match) -> bool: + start = match.start() + for pattern in ALLOWED_PATTERNS: + m = pattern.search(line) + if m and m.start() <= start < m.end(): + return True + return False + + +def test_no_deprecated_crypto_wallet_in_source_and_tests(): + """Fail if any payments source or test file uses the old cryptoWallet naming.""" + violations = [] + for scan_dir in SCAN_DIRS: + if not scan_dir.exists(): + continue + for py_file in scan_dir.rglob("*.py"): + if py_file.name == "test_deprecated_naming.py": + continue + for i, line in enumerate(py_file.read_text().splitlines(), 1): + for pattern in BANNED_PATTERNS: + for match in pattern.finditer(line): + if not _is_allowed(line, match): + rel = py_file.relative_to(REPO_ROOT) + violations.append(f" {rel}:{i}: {line.strip()}") + + assert not violations, ( + "Found 'cryptoWallet' / 'CRYPTO_WALLET'. " + "Use 'embeddedCryptoWallet' / 'EMBEDDED_CRYPTO_WALLET' instead.\n" + "\n".join(violations) + ) diff --git a/tests/bedrock_agentcore/payments/test_payment_manager.py b/tests/bedrock_agentcore/payments/test_payment_manager.py new file mode 100644 index 00000000..db3be636 --- /dev/null +++ b/tests/bedrock_agentcore/payments/test_payment_manager.py @@ -0,0 +1,3320 @@ +"""Comprehensive unit tests for PaymentManager.""" + +import base64 +import json +from unittest.mock import MagicMock, patch + +import pytest +from botocore.config import Config as BotocoreConfig +from botocore.exceptions import ClientError + +from bedrock_agentcore.payments.manager import ( + InsufficientBudget, + InvalidPaymentInstrument, + PaymentError, + PaymentInstrumentNotFound, + PaymentManager, + PaymentSessionExpired, + PaymentSessionNotFound, +) + +# ============================================================================ +# PaymentManager Initialization Tests +# ============================================================================ + + +class TestPaymentManagerInitialization: + """Tests for PaymentManager initialization.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_valid_arn(self, mock_session_class): + """Test successful initialization with valid payment_manager_arn.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + assert manager._payment_manager_arn == arn + assert manager.region_name == "us-east-1" + assert manager._payment_client is not None + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_region_name_parameter(self, mock_session_class): + """Test initialization with region_name parameter.""" + arn = "arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-west-2" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-west-2") + + assert manager.region_name == "us-west-2" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_boto3_session_parameter(self, mock_session_class): + """Test initialization with boto3_session parameter.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + + manager = PaymentManager(payment_manager_arn=arn, boto3_session=mock_session) + + assert manager.region_name == "us-east-1" + mock_session.client.assert_called_once() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_boto_client_config_parameter(self, mock_session_class): + """Test initialization with boto_client_config parameter.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + boto_config = BotocoreConfig(max_pool_connections=50) + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", boto_client_config=boto_config) + + assert manager._payment_manager_arn == arn + mock_session.client.assert_called_once() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_region_validation_conflict_detection(self, mock_session_class): + """Test region validation and conflict detection.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-west-2" + + with pytest.raises(ValueError) as exc_info: + PaymentManager(payment_manager_arn=arn, region_name="us-east-1", boto3_session=mock_session) + + assert "Region mismatch" in str(exc_info.value) + assert "us-east-1" in str(exc_info.value) + assert "us-west-2" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_raises_error_for_invalid_arn(self, mock_session_class): + """Test ValueError raised for invalid payment_manager_arn.""" + with pytest.raises(ValueError) as exc_info: + PaymentManager(payment_manager_arn=None, region_name="us-east-1") + + assert "payment_manager_arn is required" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_raises_error_for_empty_arn(self, mock_session_class): + """Test ValueError raised for empty payment_manager_arn.""" + with pytest.raises(ValueError) as exc_info: + PaymentManager(payment_manager_arn="", region_name="us-east-1") + + assert "payment_manager_arn is required" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_creates_payment_client(self, mock_session_class): + """Test PaymentClient is created correctly.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + assert manager._payment_client == mock_client + mock_session.client.assert_called_once() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_stores_payment_manager_arn_internally(self, mock_session_class): + """Test payment_manager_arn is stored internally.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + assert manager._payment_manager_arn == arn + assert hasattr(manager, "_payment_manager_arn") + + +# ============================================================================ +# PaymentManager Method Tests +# ============================================================================ + + +class TestCreatePaymentInstrument: + """Tests for create_payment_instrument method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_instrument_creation(self, mock_session_class): + """Test successful instrument creation.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"paymentInstrumentId": "instrument-123"} + mock_client.create_payment_instrument.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.create_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"wallet_address": "0x..."}, + ) + + assert result == response + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_payment_manager_arn_automatically_injected(self, mock_session_class): + """Test payment_manager_arn is automatically injected.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.create_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + manager.create_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"wallet_address": "0x..."}, + ) + + call_kwargs = mock_client.create_payment_instrument.call_args[1] + assert call_kwargs["paymentManagerArn"] == arn + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_client_token_parameter_passed_through(self, mock_session_class): + """Test client_token parameter is passed through.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.create_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + manager.create_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"wallet_address": "0x..."}, + client_token="token-123", + ) + + call_kwargs = mock_client.create_payment_instrument.call_args[1] + assert call_kwargs["clientToken"] == "token-123" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_payment_client_errors_propagated(self, mock_session_class): + """Test PaymentClient errors are propagated.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error_response = {"Error": {"Code": "InvalidParameter", "Message": "Invalid connector"}} + error = ClientError(error_response, "CreatePaymentInstrument") + mock_client.create_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.create_payment_instrument( + user_id="user-123", + payment_connector_id="invalid-connector", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"wallet_address": "0x..."}, + ) + + +class TestCreatePaymentSession: + """Tests for create_payment_session method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_session_creation(self, mock_session_class): + """Test successful session creation.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"paymentSessionId": "session-123"} + mock_client.create_payment_session.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.create_payment_session( + user_id="user-123", + expiry_time_in_minutes=60, + limits={"max_amount": 1000}, + ) + + assert result == response + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_payment_manager_arn_injected_in_session(self, mock_session_class): + """Test payment_manager_arn is automatically injected.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.create_payment_session.return_value = {"paymentSessionId": "session-123"} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + manager.create_payment_session( + user_id="user-123", + expiry_time_in_minutes=60, + limits={"max_amount": 1000}, + ) + + call_kwargs = mock_client.create_payment_session.call_args[1] + assert call_kwargs["paymentManagerArn"] == arn + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_session_client_errors_propagated(self, mock_session_class): + """Test PaymentClient errors are propagated.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error_response = {"Error": {"Code": "InvalidLimit", "Message": "Invalid session limit"}} + error = ClientError(error_response, "CreatePaymentSession") + mock_client.create_payment_session.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.create_payment_session( + user_id="user-123", + expiry_time_in_minutes=60, + limits={"max_amount": -100}, + ) + + +class TestGetPaymentInstrument: + """Tests for get_payment_instrument method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_get_instrument(self, mock_session_class): + """Test successful retrieval of payment instrument.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"paymentInstrumentId": "instrument-123", "status": "active"} + mock_client.get_payment_instrument.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.get_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_id="instrument-123", + ) + + assert result == response + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_get_instrument_without_connector_id(self, mock_session_class): + """Test get_payment_instrument without payment_connector_id (None case).""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"paymentInstrumentId": "instrument-123", "status": "active"} + mock_client.get_payment_instrument.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.get_payment_instrument( + user_id="user-123", + payment_instrument_id="instrument-123", + # payment_connector_id is None (default) + ) + + assert result == response + # Verify that paymentConnectorId was NOT included in the call + call_args = mock_client.get_payment_instrument.call_args + assert "paymentConnectorId" not in call_args.kwargs + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_get_instrument_not_found(self, mock_session_class): + """Test PaymentInstrumentNotFound raised when instrument not found.""" + from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Instrument not found"}}, + "GetPaymentInstrument", + ) + mock_client.get_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentInstrumentNotFound): + manager.get_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_id="instrument-123", + ) + + +class TestListPaymentInstruments: + """Tests for list_payment_instruments method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_list_instruments(self, mock_session_class): + """Test successful listing of payment instruments.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = { + "paymentInstruments": [ + {"paymentInstrumentId": "instrument-1"}, + {"paymentInstrumentId": "instrument-2"}, + ] + } + mock_client.list_payment_instruments.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.list_payment_instruments(user_id="user-123") + + assert len(result["paymentInstruments"]) == 2 + assert result["paymentInstruments"][0]["paymentInstrumentId"] == "instrument-1" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_list_instruments_with_pagination(self, mock_session_class): + """Test listing instruments with pagination.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = { + "paymentInstruments": [{"paymentInstrumentId": "instrument-1"}], + "nextToken": "token-123", + } + mock_client.list_payment_instruments.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.list_payment_instruments(user_id="user-123", next_token="token-123") + + assert "nextToken" in result + assert result["nextToken"] == "token-123" + + +class TestGetPaymentInstrumentBalance: + """Tests for get_payment_instrument_balance method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_get_balance(self, mock_session_class): + """Test successful retrieval of payment instrument balance.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = { + "paymentInstrumentId": "instrument-123", + "tokenBalance": {"value": "100.50", "currency": "USDC"}, + } + mock_client.get_payment_instrument_balance.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.get_payment_instrument_balance( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_id="instrument-123", + chain="BASE_SEPOLIA", + token="USDC", + ) + + assert result == response + assert result["paymentInstrumentId"] == "instrument-123" + assert result["tokenBalance"]["value"] == "100.50" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_get_balance_instrument_not_found(self, mock_session_class): + """Test PaymentInstrumentNotFound raised when instrument not found.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Instrument not found"}}, + "GetPaymentInstrumentBalance", + ) + mock_client.get_payment_instrument_balance.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentInstrumentNotFound): + manager.get_payment_instrument_balance( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_id="instrument-123", + chain="BASE_SEPOLIA", + token="USDC", + ) + + +class TestGetPaymentSession: + """Tests for get_payment_session method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_get_session(self, mock_session_class): + """Test successful retrieval of payment session.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"paymentSessionId": "session-123", "status": "active"} + mock_client.get_payment_session.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.get_payment_session(user_id="user-123", payment_session_id="session-123") + + assert result == response + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_get_session_not_found(self, mock_session_class): + """Test PaymentSessionNotFound raised when session not found.""" + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Session not found"}}, + "GetPaymentSession", + ) + mock_client.get_payment_session.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentSessionNotFound): + manager.get_payment_session(user_id="user-123", payment_session_id="session-123") + + +class TestListPaymentSessions: + """Tests for list_payment_sessions method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_list_sessions(self, mock_session_class): + """Test successful listing of payment sessions.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = { + "paymentSessions": [ + {"paymentSessionId": "session-1"}, + {"paymentSessionId": "session-2"}, + ] + } + mock_client.list_payment_sessions.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.list_payment_sessions(user_id="user-123") + + assert len(result["paymentSessions"]) == 2 + + +class TestDeletePaymentSession: + """Tests for delete_payment_session method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_delete_session(self, mock_session_class): + """Test successful deletion of a payment session.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"status": "DELETED"} + mock_client.delete_payment_session.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.delete_payment_session( + payment_session_id="session-123", + user_id="user-123", + ) + + assert result == {"status": "DELETED"} + mock_client.delete_payment_session.assert_called_once_with( + userId="user-123", + paymentManagerArn=arn, + paymentSessionId="session-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_delete_session_without_user_id(self, mock_session_class): + """Test deletion without user_id (bearer auth scenario).""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"status": "DELETED"} + mock_client.delete_payment_session.return_value = response + + manager = PaymentManager( + payment_manager_arn=arn, + region_name="us-east-1", + bearer_token="test-jwt-token", + ) + result = manager.delete_payment_session(payment_session_id="session-123") + + assert result == {"status": "DELETED"} + mock_client.delete_payment_session.assert_called_once_with( + paymentManagerArn=arn, + paymentSessionId="session-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_delete_session_not_found(self, mock_session_class): + """Test ResourceNotFoundException when deleting a non-existent session.""" + from bedrock_agentcore.payments.manager import PaymentSessionNotFound + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Session not found"}}, + "DeletePaymentSession", + ) + mock_client.delete_payment_session.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentSessionNotFound): + manager.delete_payment_session( + payment_session_id="session-nonexistent", + user_id="user-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_delete_session_access_denied(self, mock_session_class): + """Test AccessDeniedException when deleting a session.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Forbidden"}}, + "DeletePaymentSession", + ) + mock_client.delete_payment_session.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.delete_payment_session( + payment_session_id="session-123", + user_id="user-123", + ) + + +class TestDeletePaymentInstrument: + """Tests for delete_payment_instrument method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_delete_instrument(self, mock_session_class): + """Test successful deletion of a payment instrument.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"status": "DELETED"} + mock_client.delete_payment_instrument.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.delete_payment_instrument( + payment_instrument_id="instrument-123", + payment_connector_id="connector-456", + user_id="user-123", + ) + + assert result == {"status": "DELETED"} + mock_client.delete_payment_instrument.assert_called_once_with( + userId="user-123", + paymentManagerArn=arn, + paymentConnectorId="connector-456", + paymentInstrumentId="instrument-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_delete_instrument_without_user_id(self, mock_session_class): + """Test deletion without user_id (bearer auth scenario).""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + response = {"status": "DELETED"} + mock_client.delete_payment_instrument.return_value = response + + manager = PaymentManager( + payment_manager_arn=arn, + region_name="us-east-1", + bearer_token="test-jwt-token", + ) + result = manager.delete_payment_instrument( + payment_instrument_id="instrument-123", + payment_connector_id="connector-456", + ) + + assert result == {"status": "DELETED"} + mock_client.delete_payment_instrument.assert_called_once_with( + paymentManagerArn=arn, + paymentConnectorId="connector-456", + paymentInstrumentId="instrument-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_delete_instrument_not_found(self, mock_session_class): + """Test ResourceNotFoundException when deleting a non-existent instrument.""" + from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Instrument not found"}}, + "DeletePaymentInstrument", + ) + mock_client.delete_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentInstrumentNotFound): + manager.delete_payment_instrument( + payment_instrument_id="instrument-nonexistent", + payment_connector_id="connector-456", + user_id="user-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_delete_instrument_access_denied(self, mock_session_class): + """Test AccessDeniedException when deleting an instrument.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Forbidden"}}, + "DeletePaymentInstrument", + ) + mock_client.delete_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.delete_payment_instrument( + payment_instrument_id="instrument-123", + payment_connector_id="connector-456", + user_id="user-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_delete_instrument_already_deleted(self, mock_session_class): + """Test deleting an already-deleted instrument returns not found.""" + from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Instrument is already deleted"}}, + "DeletePaymentInstrument", + ) + mock_client.delete_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentInstrumentNotFound): + manager.delete_payment_instrument( + payment_instrument_id="instrument-123", + payment_connector_id="connector-456", + user_id="user-123", + ) + + +class TestProcessPayment: + """Tests for process_payment method.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_successful_payment_processing(self, mock_session_class): + """Test successful payment processing.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.get_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + response = {"processPaymentId": "payment-123"} + mock_client.process_payment.return_value = response + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + result = manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="debit", + payment_input={"amount": 100}, + ) + + assert result == response + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_payment_instrument_validation_performed(self, mock_session_class): + """Test payment instrument validation is performed.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.get_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + mock_client.process_payment.return_value = {"processPaymentId": "payment-123"} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="debit", + payment_input={"amount": 100}, + ) + + mock_client.process_payment.assert_called_once() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_payment_manager_arn_injected_in_payment(self, mock_session_class): + """Test payment_manager_arn is automatically injected.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.get_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + mock_client.process_payment.return_value = {"processPaymentId": "payment-123"} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="debit", + payment_input={"amount": 100}, + ) + + call_kwargs = mock_client.process_payment.call_args[1] + assert call_kwargs["paymentManagerArn"] == arn + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_process_payment_insufficient_budget(self, mock_session_class): + """Test InsufficientBudget error during payment processing.""" + from bedrock_agentcore.payments.manager import InsufficientBudget + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.get_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Insufficient budget"}}, + "ProcessPayment", + ) + mock_client.process_payment.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(InsufficientBudget): + manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="debit", + payment_input={"amount": 100}, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_process_payment_session_expired(self, mock_session_class): + """Test PaymentSessionExpired error during payment processing.""" + from bedrock_agentcore.payments.manager import PaymentSessionExpired + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.get_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Session expired"}}, + "ProcessPayment", + ) + mock_client.process_payment.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentSessionExpired): + manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="debit", + payment_input={"amount": 100}, + ) + + +class TestErrorHandling: + """Tests for error handling in PaymentManager methods.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_get_instrument_access_denied(self, mock_session_class): + """Test access denied error when getting instrument.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Unauthorized"}}, + "GetPaymentInstrument", + ) + mock_client.get_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.get_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_id="instrument-123", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_get_session_access_denied(self, mock_session_class): + """Test access denied error when getting session.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Unauthorized"}}, + "GetPaymentSession", + ) + mock_client.get_payment_session.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.get_payment_session(user_id="user-123", payment_session_id="session-123") + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_create_instrument_validation_error(self, mock_session_class): + """Test validation error when creating instrument.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid wallet address"}}, + "CreatePaymentInstrument", + ) + mock_client.create_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.create_payment_instrument( + user_id="user-123", + payment_connector_id="connector-456", + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"wallet_address": "invalid"}, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_list_instruments_error(self, mock_session_class): + """Test error when listing instruments.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "InternalServerError", "Message": "Server error"}}, + "ListPaymentInstruments", + ) + mock_client.list_payment_instruments.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.list_payment_instruments(user_id="user-123") + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_list_sessions_error(self, mock_session_class): + """Test error when listing sessions.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "InternalServerError", "Message": "Server error"}}, + "ListPaymentSessions", + ) + mock_client.list_payment_sessions.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.list_payment_sessions(user_id="user-123") + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_create_session_expiry_validation_error(self, mock_session_class): + """Test expiry validation error when creating session.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Invalid expiry duration"}}, + "CreatePaymentSession", + ) + mock_client.create_payment_session.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError): + manager.create_payment_session( + user_id="user-123", + expiry_time_in_minutes=5, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_process_payment_invalid_instrument(self, mock_session_class): + """Test InvalidPaymentInstrument error during payment processing.""" + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.get_payment_instrument.return_value = {"paymentInstrumentId": "instrument-123"} + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Instrument is inactive"}}, + "ProcessPayment", + ) + mock_client.process_payment.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(InvalidPaymentInstrument): + manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="debit", + payment_input={"amount": 100}, + ) + + +class TestMethodForwarding: + """Tests for __getattr__ method forwarding.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_getattr_forwards_to_client(self, mock_session_class): + """Test __getattr__ only forwards allowed methods to PaymentClient.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + # Add a mock method to the client that's in the allowed list + mock_method = MagicMock(return_value={"result": "success"}) + mock_client.create_payment_instrument = mock_method + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + # Verify that an allowed method can be accessed + # Since create_payment_instrument is explicitly defined, we verify the method exists + assert hasattr(manager, "create_payment_instrument") + assert callable(manager.create_payment_instrument) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_getattr_raises_attribute_error_for_undefined_method(self, mock_session_class): + """Test __getattr__ raises AttributeError for undefined methods.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock(spec=[]) # Empty spec means no methods + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(AttributeError) as exc_info: + manager.undefined_method() + + assert "undefined_method" in str(exc_info.value) + assert "PaymentManager" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_getattr_rejects_non_allowed_methods(self, mock_session_class): + """Test __getattr__ rejects methods not in the allowed list even if they exist on client.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + # Add a method that exists on the client but is NOT in the allowed list + mock_client.get_paginator = MagicMock(return_value={"paginator": "data"}) + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + # Attempting to access a method not in the allowed list should raise AttributeError + with pytest.raises(AttributeError) as exc_info: + manager.get_paginator() + + assert "get_paginator" in str(exc_info.value) + assert "PaymentManager" in str(exc_info.value) + + +# ============================================================================ +# generatePaymentHeader Tests +# ============================================================================ + + +class TestGeneratePaymentHeaderInputValidation: + """Tests for input validation in generatePaymentHeader.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_empty_user_id_raises_error(self, mock_session_class): + """Test that empty user_id raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": {}}, + ) + + assert "user_id is empty" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_none_user_id_raises_error(self, mock_session_class): + """Test that None user_id raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id=None, + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": {}}, + ) + + assert "user_id is empty" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_empty_instrument_id_raises_error(self, mock_session_class): + """Test that empty instrument_id raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": {}}, + ) + + assert "instrument_id is empty" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_empty_session_id_raises_error(self, mock_session_class): + """Test that empty session_id raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="", + payment_required_request={"statusCode": 402, "headers": {}, "body": {}}, + ) + + assert "session_id is empty" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_empty_x402_response_raises_error(self, mock_session_class): + """Test that empty x402_response raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={}, + ) + + assert "payment_required_request is invalid" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_missing_statuscode_in_x402_response_raises_error(self, mock_session_class): + """Test that missing statusCode in x402_response raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"headers": {}, "body": {}}, + ) + + assert "missing required fields" in str(exc_info.value) + + +class TestGeneratePaymentHeaderX402Extraction: + """Tests for X.402 payload extraction in generatePaymentHeader.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_v1_payload_extraction_from_body(self, mock_session_class): + """Test successful v1 payload extraction from body.""" + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [{"network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ethereum", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = { + "paymentOutput": { + "cryptoX402": { + "payload": "payment-proof", + } + } + } + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + assert isinstance(result, dict) + assert "X-PAYMENT" in result + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_v2_payload_extraction_from_header(self, mock_session_class): + """Test successful v2 payload extraction from payment-required header.""" + import base64 + import json + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v2_payload = { + "x402Version": 2, + "scheme": "exact", + "network": "ethereum", + "resource": "https://example.com", + "accepts": [{"network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + encoded_payload = base64.b64encode(json.dumps(v2_payload).encode()).decode() + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ethereum", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = { + "paymentOutput": { + "cryptoX402": { + "payload": "payment-proof", + } + } + } + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {"payment-required": encoded_payload}, "body": {}}, + ) + + assert isinstance(result, dict) + assert "PAYMENT-SIGNATURE" in result + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_invalid_base64_in_v2_header_raises_error(self, mock_session_class): + """Test that invalid base64 in v2 header raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={ + "statusCode": 402, + "headers": {"payment-required": "invalid-base64!!!"}, + "body": {}, + }, + ) + + assert "Failed to decode v2 payload" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_missing_required_fields_in_payload_raises_error(self, mock_session_class): + """Test that missing required fields in payload raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + incomplete_payload = { + "x402Version": 1, + "scheme": "exact", + # Missing: network, accepts, payload + } + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": incomplete_payload}, + ) + + assert "Missing required fields" in str(exc_info.value) + + +class TestGeneratePaymentHeaderNetworkDetection: + """Tests for network detection and accept selection.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_ethereum_network_detection(self, mock_session_class): + """Test Ethereum network detection and accept selection.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [{"network": "base-sepolia", "value": "100"}], + "payload": "proof-data", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = {"paymentOutput": {"cryptoX402": {"payload": "payment-proof"}}} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + assert result is not None + mock_client.process_payment.assert_called_once() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_solana_network_detection(self, mock_session_class): + """Test Solana network detection and accept selection.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "solana", + "accepts": [{"network": "solana-mainnet", "value": "100"}], + "payload": "proof-data", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "SOLANA", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = {"paymentOutput": {"cryptoX402": {"payload": "payment-proof"}}} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + assert result is not None + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_unsupported_network_raises_error(self, mock_session_class): + """Test that unsupported instrument network raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [{"network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "INVALID_NETWORK", + } + }, + "status": "active", + } + } + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + assert "Unsupported network" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_no_matching_accept_for_instrument_network(self, mock_session_class): + """Test error when no accept matches instrument network.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + # X.402 payload only has Solana accepts + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "solana", + "accepts": [{"network": "solana-mainnet", "value": "100"}], + "payload": "proof-data", + } + + # But instrument is Ethereum + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + } + }, + "status": "active", + } + } + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + assert "No matching accept" in str(exc_info.value) + assert "does not support the network" in str(exc_info.value) + + +class TestGeneratePaymentHeaderV1Format: + """Tests for v1 header format (X-PAYMENT).""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_v1_header_format_correctness(self, mock_session_class): + """Test v1 header format is correct.""" + import base64 + import json + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [{"scheme": "exact", "network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = {"paymentOutput": {"cryptoX402": {"payload": "payment-proof"}}} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + assert isinstance(result, dict) + assert "X-PAYMENT" in result + header_value = result["X-PAYMENT"] + decoded = base64.b64decode(header_value) + header_json = json.loads(decoded) + assert header_json["x402Version"] == 1 + assert header_json["scheme"] == "exact" + assert header_json["network"] == "ethereum" + assert header_json["payload"] == "payment-proof" + + +class TestGeneratePaymentHeaderV2Format: + """Tests for v2 header format (PAYMENT-SIGNATURE).""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_v2_header_format_correctness(self, mock_session_class): + """Test v2 header format is correct.""" + import base64 + import json + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v2_payload = { + "x402Version": 2, + "scheme": "exact", + "network": "ethereum", + "resource": "https://example.com", + "accepts": [{"network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + encoded_payload = base64.b64encode(json.dumps(v2_payload).encode()).decode() + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = {"paymentOutput": {"cryptoX402": {"payload": "payment-proof"}}} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {"payment-required": encoded_payload}, "body": {}}, + ) + + assert isinstance(result, dict) + assert "PAYMENT-SIGNATURE" in result + header_value = result["PAYMENT-SIGNATURE"] + decoded = base64.b64decode(header_value) + header_json = json.loads(decoded) + assert header_json["x402Version"] == 2 + assert header_json["resource"] == "https://example.com" + assert header_json["payload"] == "payment-proof" + assert "accepted" in header_json + + +class TestGeneratePaymentHeaderErrorHandling: + """Tests for error handling in generatePaymentHeader.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_instrument_not_found_propagated(self, mock_session_class): + """Test PaymentInstrumentNotFound is propagated.""" + from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [{"network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Instrument not found"}}, + "GetPaymentInstrument", + ) + mock_client.get_payment_instrument.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentInstrumentNotFound): + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_payment_session_expired_propagated(self, mock_session_class): + """Test PaymentSessionExpired is propagated from processPayment.""" + from bedrock_agentcore.payments.manager import PaymentSessionExpired + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [{"network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ethereum", + } + }, + "status": "active", + } + } + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Session expired"}}, + "ProcessPayment", + ) + mock_client.process_payment.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(PaymentSessionExpired): + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_insufficient_budget_propagated(self, mock_session_class): + """Test InsufficientBudget is propagated from processPayment.""" + from bedrock_agentcore.payments.manager import InsufficientBudget + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [{"network": "ethereum", "value": "100"}], + "payload": "proof-data", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ethereum", + } + }, + "status": "active", + } + } + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Insufficient budget"}}, + "ProcessPayment", + ) + mock_client.process_payment.side_effect = error + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + with pytest.raises(InsufficientBudget): + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + +# ============================================================================ +# generatePaymentHeader Client Token Tests +# ============================================================================ + + +class TestGeneratePaymentHeaderClientToken: + """Tests for client_token parameter in generatePaymentHeader.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_client_token_generated_when_not_provided(self, mock_session_class): + """Test that a client_token is generated when not provided.""" + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "ethereum", + "network": "ethereum", + "accepts": [{"network": "ethereum", "accept": "application/json"}], + "payload": "test-payload", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = {"paymentOutput": {"cryptoX402": {"payload": "proof-of-payment"}}} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + # Call without client_token + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + ) + + # Verify process_payment was called with a generated client_token + call_kwargs = mock_client.process_payment.call_args[1] + assert "clientToken" in call_kwargs + assert isinstance(call_kwargs["clientToken"], str) + assert len(call_kwargs["clientToken"]) > 0 + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_client_token_passed_through_when_provided(self, mock_session_class): + """Test that provided client_token is passed through to processPayment.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "ethereum", + "network": "ethereum", + "accepts": [{"network": "ethereum", "accept": "application/json"}], + "payload": "test-payload", + } + + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + } + }, + "status": "active", + } + } + mock_client.process_payment.return_value = {"paymentOutput": {"cryptoX402": {"payload": "proof-of-payment"}}} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + # Call with provided client_token + provided_token = "my-custom-token-123" + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + client_token=provided_token, + ) + + # Verify process_payment was called with the provided client_token + call_kwargs = mock_client.process_payment.call_args[1] + assert call_kwargs["clientToken"] == provided_token + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_empty_client_token_raises_error(self, mock_session_class): + """Test that empty client_token raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "ethereum", + "network": "ethereum", + "accepts": [{"network": "ethereum", "accept": "application/json"}], + "payload": "test-payload", + } + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + # Call with empty client_token + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + client_token="", + ) + + assert "client_token is invalid - cannot be empty" in str(exc_info.value) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_non_string_client_token_raises_error(self, mock_session_class): + """Test that non-string client_token raises PaymentError.""" + from bedrock_agentcore.payments.manager import PaymentError + + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + v1_payload = { + "x402Version": 1, + "scheme": "ethereum", + "network": "ethereum", + "accepts": [{"network": "ethereum", "accept": "application/json"}], + "payload": "test-payload", + } + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + # Call with non-string client_token + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request={"statusCode": 402, "headers": {}, "body": v1_payload}, + client_token=12345, + ) + + assert "client_token is invalid - must be a string" in str(exc_info.value) + + +# ============================================================================ +# Test Infrastructure and Fixtures for generatePaymentHeader +# ============================================================================ + +""" +Test infrastructure for generatePaymentHeader property-based testing. + +This module provides: +- Hypothesis strategies for generating valid test inputs +- Test fixtures for mocking PaymentManager methods +- Helper functions for creating test data +""" + +# ============================================================================ +# Hypothesis Strategies for Property-Based Testing +# ============================================================================ + + +# ============================================================================ +# Test Fixtures for Mocking PaymentManager Methods +# ============================================================================ + + +class PaymentManagerMockFixtures: + """Fixtures for mocking PaymentManager methods in tests.""" + + @staticmethod + def create_mock_payment_manager(mock_session_class): + """Create a mocked PaymentManager instance.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + return manager, mock_client + + @staticmethod + def setup_successful_instrument_retrieval(mock_client, network="ethereum"): + """Setup mock for successful instrument retrieval.""" + mock_client.get_payment_instrument.return_value = { + "paymentInstrument": { + "paymentInstrumentId": "instrument-123", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": network, + } + }, + "status": "active", + } + } + + @staticmethod + def setup_successful_payment_processing(mock_client, payload="payment-proof"): + """Setup mock for successful payment processing.""" + mock_client.process_payment.return_value = { + "processPaymentId": "payment-123", + "paymentOutput": { + "cryptoX402": { + "payload": payload, + } + }, + } + + @staticmethod + def setup_instrument_not_found(mock_client): + """Setup mock for instrument not found error.""" + error = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "Instrument not found"}}, + "GetPaymentInstrument", + ) + mock_client.get_payment_instrument.side_effect = error + + @staticmethod + def setup_session_expired(mock_client): + """Setup mock for session expired error.""" + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Session expired"}}, + "ProcessPayment", + ) + mock_client.process_payment.side_effect = error + + @staticmethod + def setup_insufficient_budget(mock_client): + """Setup mock for insufficient budget error.""" + error = ClientError( + {"Error": {"Code": "ValidationException", "Message": "Insufficient budget"}}, + "ProcessPayment", + ) + mock_client.process_payment.side_effect = error + + +# ============================================================================ +# Helper Functions for Test Data Creation +# ============================================================================ + + +def create_v1_x402_response(scheme="exact", network="ethereum", payload_data="proof-data"): + """Create a valid v1 X.402 response (requirement structure without payload field).""" + return { + "statusCode": 402, + "headers": {}, + "body": { + "x402Version": 1, + "scheme": scheme, + "network": network, + "accepts": [ + { + "scheme": scheme, + "network": network, + "maxAmountRequired": "5000", + "resource": "https://nickeljoke.vercel.app/api/joke", + "description": "Premium AI joke generation", + "mimeType": "application/json", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "maxTimeoutSeconds": 300, + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": {"name": "USDC", "version": "2"}, + } + ], + }, + } + + +def create_v2_x402_response( + scheme="exact", network="ethereum", resource="https://example.com", payload_data="proof-data" +): + """Create a valid v2 X.402 response (requirement structure without payload field).""" + v2_payload = { + "x402Version": 2, + "scheme": scheme, + "network": network, + "resource": resource, + "accepts": [ + { + "scheme": scheme, + "network": network, + "maxAmountRequired": "5000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "maxTimeoutSeconds": 300, + "extra": {"name": "USDC", "version": "2"}, + } + ], + } + encoded_payload = base64.b64encode(json.dumps(v2_payload).encode()).decode() + return { + "statusCode": 402, + "headers": {"payment-required": encoded_payload}, + "body": {}, + } + + +def create_payment_instrument(network="ethereum", status="active"): + """Create a valid payment instrument.""" + return { + "instrumentId": "instrument-123", + "network": network, + "status": status, + } + + +def create_payment_credentials(payload="payment-proof"): + """Create valid payment credentials.""" + return { + "processPaymentId": "payment-123", + "payload": payload, + } + + +# ============================================================================ +# Integration Tests for generatePaymentHeader +# ============================================================================ + + +class TestGeneratePaymentHeaderIntegration: + """Integration tests for generatePaymentHeader complete workflow.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_complete_v1_workflow(self, mock_session_class): + """Test complete workflow with v1 payload. + + Verifies the full workflow: + - Input validation + - X.402 payload extraction (v1) + - Instrument retrieval + - Network detection + - Accept selection + - Payment processing + - Header building (X-PAYMENT format) + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks for successful workflow + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="ethereum") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client, payload="v1-proof") + + # Create v1 X.402 response + x402_response = create_v1_x402_response(scheme="exact", network="ethereum", payload_data="v1-proof-data") + + # Execute + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + ) + + # Verify result is a valid X-PAYMENT header + assert isinstance(result, dict) + assert "X-PAYMENT" in result + header_value = result["X-PAYMENT"] + decoded = base64.b64decode(header_value) + header_json = json.loads(decoded) + + assert header_json["x402Version"] == 1 + assert header_json["scheme"] == "exact" + assert header_json["network"] == "ethereum" + assert header_json["payload"] == "v1-proof" + + # Verify mocks were called correctly + mock_client.get_payment_instrument.assert_called_once() + mock_client.process_payment.assert_called_once() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_complete_v2_workflow(self, mock_session_class): + """Test complete workflow with v2 payload. + + Verifies the full workflow: + - Input validation + - X.402 payload extraction (v2 from headers) + - Instrument retrieval + - Network detection + - Accept selection + - Payment processing + - Header building (PAYMENT-SIGNATURE format) + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks for successful workflow + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="SOLANA") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client, payload="v2-proof") + + # Create v2 X.402 response + x402_response = create_v2_x402_response( + scheme="exact", network="solana", resource="https://example.com/resource", payload_data="v2-proof-data" + ) + + # Execute + result = manager.generate_payment_header( + user_id="user-456", + payment_instrument_id="instrument-456", + payment_session_id="session-456", + payment_required_request=x402_response, + ) + + # Verify result is a valid PAYMENT-SIGNATURE header + assert isinstance(result, dict) + assert "PAYMENT-SIGNATURE" in result + header_value = result["PAYMENT-SIGNATURE"] + decoded = base64.b64decode(header_value) + header_json = json.loads(decoded) + + assert header_json["x402Version"] == 2 + assert header_json["resource"] == "https://example.com/resource" + assert header_json["payload"] == "v2-proof" + assert "accepted" in header_json + assert "extension" in header_json + + # Verify mocks were called correctly + mock_client.get_payment_instrument.assert_called_once() + mock_client.process_payment.assert_called_once() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_end_to_end_with_all_components(self, mock_session_class): + """Test end-to-end workflow with all components integrated. + + Verifies: + - All validation steps pass + - All components work together + - Final header is correctly formatted + - All mocks are called with correct parameters + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="ETHEREUM") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client, payload="integration-proof") + + # Create X.402 response + x402_response = create_v1_x402_response( + scheme="exact", network="ethereum", payload_data="integration-proof-data" + ) + + # Execute + result = manager.generate_payment_header( + user_id="integration-user", + payment_instrument_id="integration-instrument", + payment_session_id="integration-session", + payment_required_request=x402_response, + ) + + # Verify result + assert isinstance(result, dict) + assert len(result) > 0 + assert "X-PAYMENT" in result or "PAYMENT-SIGNATURE" in result + + # Verify instrument retrieval was called with correct parameters + call_args = mock_client.get_payment_instrument.call_args + assert call_args is not None + assert call_args[1]["paymentInstrumentId"] == "integration-instrument" + + # Verify payment processing was called with correct parameters + call_args = mock_client.process_payment.call_args + assert call_args is not None + assert call_args[1]["paymentSessionId"] == "integration-session" + assert call_args[1]["paymentInstrumentId"] == "integration-instrument" + assert call_args[1]["paymentType"] == "CRYPTO_X402" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_error_handling_across_components(self, mock_session_class): + """Test error handling and propagation across all components. + + Verifies: + - Errors from instrument retrieval are propagated + - Errors from payment processing are propagated + - Error messages include context + - No silent failures + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Test 1: Instrument not found error + PaymentManagerMockFixtures.setup_instrument_not_found(mock_client) + + x402_response = create_v1_x402_response() + + with pytest.raises(PaymentInstrumentNotFound): + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="nonexistent-instrument", + payment_session_id="session-123", + payment_required_request=x402_response, + ) + + # Test 2: Session expired error + mock_client.reset_mock() + mock_client.get_payment_instrument.side_effect = None + mock_client.process_payment.side_effect = None + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client) + PaymentManagerMockFixtures.setup_session_expired(mock_client) + + with pytest.raises(PaymentSessionExpired): + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="expired-session", + payment_required_request=x402_response, + ) + + # Test 3: Insufficient budget error + mock_client.reset_mock() + mock_client.get_payment_instrument.side_effect = None + mock_client.process_payment.side_effect = None + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client) + PaymentManagerMockFixtures.setup_insufficient_budget(mock_client) + + with pytest.raises(InsufficientBudget): + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_invalid_status_code_returns_early(self, mock_session_class): + """Test that non-402 status codes are handled correctly. + + Verifies: + - Invalid status codes are detected + - Error is raised before any PaymentManager methods are called + - Error message includes the invalid status code + - Fail-fast behavior is maintained + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Create X.402 response with invalid status code + x402_response = create_v1_x402_response() + x402_response["statusCode"] = 400 # Invalid status code + + # Execute and verify error + with pytest.raises(PaymentError) as exc_info: + manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + ) + + # Verify error message includes context + assert "402" in str(exc_info.value) + assert "400" in str(exc_info.value) + + # Verify no PaymentManager methods were called (fail-fast) + mock_client.get_payment_instrument.assert_not_called() + mock_client.process_payment.assert_not_called() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_client_token_generation(self, mock_session_class): + """Test that client token is generated when not provided. + + Verifies: + - Client token is generated when not provided + - Generated token is passed to processPayment + - Token is a valid UUID format + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client) + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + x402_response = create_v1_x402_response() + + # Execute without client_token + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + ) + + # Verify result + assert result is not None + + # Verify processPayment was called with a clientToken + call_args = mock_client.process_payment.call_args + assert call_args is not None + assert "clientToken" in call_args[1] + client_token = call_args[1]["clientToken"] + assert client_token is not None + assert len(client_token) > 0 + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_client_token_passed_through(self, mock_session_class): + """Test that provided client token is passed to processPayment. + + Verifies: + - Provided client token is used + - Token is passed to processPayment unchanged + - Token is not regenerated + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client) + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + x402_response = create_v1_x402_response() + provided_token = "my-custom-token-12345" + + # Execute with client_token + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + client_token=provided_token, + ) + + # Verify result + assert result is not None + + # Verify processPayment was called with the provided token + call_args = mock_client.process_payment.call_args + assert call_args is not None + assert call_args[1]["clientToken"] == provided_token + + +# ============================================================================ +# Network Preferences Tests +# ============================================================================ + + +class TestGeneratePaymentHeaderNetworkPreferences: + """Tests for network preferences in accept selection.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_network_preferences_selects_preferred_network(self, mock_session_class): + """Test that network preferences are used to select the preferred accept. + + Verifies: + - Multiple accepts are available + - Preferred network from preferences is selected + - Other accepts are ignored + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="ethereum") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + # X.402 payload with multiple Ethereum accepts + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [ + {"network": "ethereum", "value": "100"}, + {"network": "base-sepolia", "value": "50"}, + {"network": "eip155:8453", "value": "75"}, + ], + "payload": "proof-data", + } + + x402_response = {"statusCode": 402, "headers": {}, "body": v1_payload} + + # Provide network preferences with base-sepolia first + network_preferences = ["base-sepolia", "ethereum", "eip155:8453"] + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + network_preferences=network_preferences, + ) + + assert result is not None + + # Verify processPayment was called with the base-sepolia accept + call_args = mock_client.process_payment.call_args + assert call_args is not None + payment_input = call_args[1]["paymentInput"] + selected_accept = payment_input["cryptoX402"]["payload"] + assert selected_accept["network"] == "base-sepolia" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_network_preferences_fallback_to_first_available(self, mock_session_class): + """Test fallback to first available accept when no preference matches. + + Verifies: + - When no preference matches available accepts + - First available accept is selected + - No error is raised + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="ethereum") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + # X.402 payload with Ethereum accepts + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [ + {"network": "ethereum", "value": "100"}, + {"network": "eip155:8453", "value": "75"}, + ], + "payload": "proof-data", + } + + x402_response = {"statusCode": 402, "headers": {}, "body": v1_payload} + + # Provide network preferences that don't match any accepts + network_preferences = ["solana-mainnet", "base-sepolia"] + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + network_preferences=network_preferences, + ) + + assert result is not None + + # Verify processPayment was called with the first available accept + call_args = mock_client.process_payment.call_args + assert call_args is not None + payment_input = call_args[1]["paymentInput"] + selected_accept = payment_input["cryptoX402"]["payload"] + assert selected_accept["network"] == "ethereum" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_default_network_preferences_used_when_not_provided(self, mock_session_class): + """Test that default NETWORK_PREFERENCES are used when not provided. + + Verifies: + - When network_preferences is None + - Default preferences from constants are used + - Selection follows default preference order + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="solana") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + # X.402 payload with multiple Solana accepts + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "solana", + "accepts": [ + {"network": "solana-devnet", "value": "50"}, + {"network": "solana-mainnet", "value": "100"}, + ], + "payload": "proof-data", + } + + x402_response = {"statusCode": 402, "headers": {}, "body": v1_payload} + + # Don't provide network_preferences - should use defaults + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + ) + + assert result is not None + + # Verify processPayment was called + call_args = mock_client.process_payment.call_args + assert call_args is not None + payment_input = call_args[1]["paymentInput"] + selected_accept = payment_input["cryptoX402"]["payload"] + # Should select based on default preferences (solana-mainnet is preferred) + assert selected_accept["network"] in ["solana-mainnet", "solana-devnet"] + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_network_preferences_case_insensitive_matching(self, mock_session_class): + """Test that network matching is case-insensitive. + + Verifies: + - Preferences with different cases match accepts + - Case variations don't prevent selection + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="ethereum") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + # X.402 payload with mixed case network names + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [ + {"network": "Base-Sepolia", "value": "50"}, + {"network": "ethereum", "value": "100"}, + ], + "payload": "proof-data", + } + + x402_response = {"statusCode": 402, "headers": {}, "body": v1_payload} + + # Provide preferences with different case + network_preferences = ["base-sepolia", "ethereum"] + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + network_preferences=network_preferences, + ) + + assert result is not None + + # Verify processPayment was called with the base-sepolia accept + call_args = mock_client.process_payment.call_args + assert call_args is not None + payment_input = call_args[1]["paymentInput"] + selected_accept = payment_input["cryptoX402"]["payload"] + assert selected_accept["network"].lower() == "base-sepolia" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_network_preferences_with_solana_networks(self, mock_session_class): + """Test network preferences work correctly with Solana networks. + + Verifies: + - Solana network preferences are respected + - Multiple Solana variants are handled + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="solana") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + # X.402 payload with multiple Solana accepts + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "solana", + "accepts": [ + {"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "value": "100"}, + {"network": "solana-mainnet", "value": "100"}, + {"network": "solana-devnet", "value": "50"}, + ], + "payload": "proof-data", + } + + x402_response = {"statusCode": 402, "headers": {}, "body": v1_payload} + + # Provide Solana-specific preferences + network_preferences = ["solana-devnet", "solana-mainnet", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"] + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + network_preferences=network_preferences, + ) + + assert result is not None + + # Verify processPayment was called with the devnet accept + call_args = mock_client.process_payment.call_args + assert call_args is not None + payment_input = call_args[1]["paymentInput"] + selected_accept = payment_input["cryptoX402"]["payload"] + assert selected_accept["network"] == "solana-devnet" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_empty_network_preferences_uses_defaults(self, mock_session_class): + """Test that empty network preferences list falls back to defaults. + + Verifies: + - Empty list is treated as no preference + - First available accept is selected + """ + manager, mock_client = PaymentManagerMockFixtures.create_mock_payment_manager(mock_session_class) + + # Setup mocks + PaymentManagerMockFixtures.setup_successful_instrument_retrieval(mock_client, network="ethereum") + PaymentManagerMockFixtures.setup_successful_payment_processing(mock_client) + + # X.402 payload with Ethereum accepts + v1_payload = { + "x402Version": 1, + "scheme": "exact", + "network": "ethereum", + "accepts": [ + {"network": "eip155:8453", "value": "75"}, + ], + "payload": "proof-data", + } + + x402_response = {"statusCode": 402, "headers": {}, "body": v1_payload} + + # Provide empty network preferences + network_preferences = [] + + result = manager.generate_payment_header( + user_id="user-123", + payment_instrument_id="instrument-123", + payment_session_id="session-123", + payment_required_request=x402_response, + network_preferences=network_preferences, + ) + + assert result is not None + + # Verify processPayment was called with the first available accept + call_args = mock_client.process_payment.call_args + assert call_args is not None + payment_input = call_args[1]["paymentInput"] + selected_accept = payment_input["cryptoX402"]["payload"] + assert selected_accept["network"] == "eip155:8453" + + +# ============================================================================ +# PaymentManager Agent Name Header Tests +# ============================================================================ + + +class TestPaymentManagerAgentNameHeader: + """Tests for agent_name header injection via boto3 event handler.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_without_agent_name_does_not_register_event(self, mock_session_class): + """Test that no event handler is registered when agent_name is not provided.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + mock_client.meta.events.register.assert_not_called() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_none_agent_name_does_not_register_event(self, mock_session_class): + """Test that no event handler is registered when agent_name is explicitly None.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + PaymentManager(payment_manager_arn=arn, region_name="us-east-1", agent_name=None) + + mock_client.meta.events.register.assert_not_called() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_agent_name_registers_event_handler(self, mock_session_class): + """Test that event handler is registered when agent_name is provided.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", agent_name="my-agent") + + mock_client.meta.events.register.assert_called_once_with( + "before-sign.bedrock-agentcore.*", + manager._add_agent_name_header, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_stores_agent_name(self, mock_session_class): + """Test that agent_name is stored on the manager instance.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", agent_name="test-agent") + + assert manager._agent_name == "test-agent" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_stores_none_agent_name_when_not_provided(self, mock_session_class): + """Test that _agent_name is None when not provided.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + assert manager._agent_name is None + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_add_agent_name_header_injects_correct_header(self, mock_session_class): + """Test that _add_agent_name_header sets the correct HTTP header.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", agent_name="my-agent") + + mock_request = MagicMock() + mock_request.headers = {} + + manager._add_agent_name_header(mock_request) + + assert mock_request.headers["X-Amzn-Bedrock-AgentCore-Payments-Agent-Name"] == "my-agent" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_add_agent_name_header_preserves_existing_headers(self, mock_session_class): + """Test that _add_agent_name_header does not overwrite existing headers.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", agent_name="my-agent") + + mock_request = MagicMock() + mock_request.headers = {"Content-Type": "application/json", "Authorization": "Bearer token"} + + manager._add_agent_name_header(mock_request) + + assert mock_request.headers["X-Amzn-Bedrock-AgentCore-Payments-Agent-Name"] == "my-agent" + assert mock_request.headers["Content-Type"] == "application/json" + assert mock_request.headers["Authorization"] == "Bearer token" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_empty_string_agent_name_does_not_register_event(self, mock_session_class): + """Test that empty string agent_name does not register event handler.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + PaymentManager(payment_manager_arn=arn, region_name="us-east-1", agent_name="") + + mock_client.meta.events.register.assert_not_called() + + +# ============================================================================ +# PaymentManager Bearer Token Auth Tests +# ============================================================================ + + +class TestPaymentManagerBearerTokenAuth: + """Tests for bearer_token and token_provider auth in PaymentManager.""" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_without_bearer_args_no_bearer_event(self, mock_session_class): + """Test that no bearer event handler is registered by default.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + + mock_client.meta.events.register.assert_not_called() + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_bearer_token_registers_event(self, mock_session_class): + """Test that bearer_token registers the inject event handler.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", bearer_token="my-jwt") + + mock_client.meta.events.register.assert_called_once_with( + "before-send.bedrock-agentcore.*", + manager._inject_bearer_token, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_init_with_token_provider_registers_event(self, mock_session_class): + """Test that token_provider registers the inject event handler.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", token_provider=lambda: "fresh") + + mock_client.meta.events.register.assert_called_once_with( + "before-send.bedrock-agentcore.*", + manager._inject_bearer_token, + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_mutual_exclusivity_raises(self, mock_session_class): + """Test that providing both bearer_token and token_provider raises ValueError.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + with pytest.raises(ValueError, match="mutually exclusive"): + PaymentManager( + payment_manager_arn=arn, + region_name="us-east-1", + bearer_token="token", + token_provider=lambda: "token", + ) + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_inject_bearer_token_static(self, mock_session_class): + """Test that static bearer_token is injected into request headers.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", bearer_token="my-jwt") + + mock_request = MagicMock() + mock_request.headers = {} + manager._inject_bearer_token(mock_request) + + assert mock_request.headers["Authorization"] == "Bearer my-jwt" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_inject_bearer_token_provider_called_each_time(self, mock_session_class): + """Test that token_provider is called for each request.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + counter = {"n": 0} + + def provider(): + counter["n"] += 1 + return f"token-{counter['n']}" + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", token_provider=provider) + + req1 = MagicMock() + req1.headers = {} + req2 = MagicMock() + req2.headers = {} + + manager._inject_bearer_token(req1) + manager._inject_bearer_token(req2) + + assert req1.headers["Authorization"] == "Bearer token-1" + assert req2.headers["Authorization"] == "Bearer token-2" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_inject_bearer_token_preserves_other_headers(self, mock_session_class): + """Test that bearer token injection does not overwrite existing headers.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", bearer_token="jwt") + + mock_request = MagicMock() + mock_request.headers = {"Content-Type": "application/json", "X-Custom": "value"} + manager._inject_bearer_token(mock_request) + + assert mock_request.headers["Authorization"] == "Bearer jwt" + assert mock_request.headers["Content-Type"] == "application/json" + assert mock_request.headers["X-Custom"] == "value" + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_bearer_token_works_alongside_agent_name(self, mock_session_class): + """Test that bearer_token and agent_name can both be set.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + manager = PaymentManager( + payment_manager_arn=arn, + region_name="us-east-1", + agent_name="my-agent", + bearer_token="my-jwt", + ) + + assert manager._agent_name == "my-agent" + assert manager._bearer_token == "my-jwt" + assert mock_client.meta.events.register.call_count == 2 + + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_inject_bearer_token_does_not_inject_user_id_header(self, mock_session_class): + """Test that bearer mode does NOT inject user_id header — service derives it from JWT sub.""" + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_session.client.return_value = MagicMock() + mock_session_class.return_value = mock_session + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1", bearer_token="jwt") + + mock_request = MagicMock() + mock_request.headers = {} + manager._inject_bearer_token(mock_request) + + assert mock_request.headers["Authorization"] == "Bearer jwt" + assert "X-Amzn-Bedrock-AgentCore-Payments-User-Id" not in mock_request.headers diff --git a/tests/bedrock_agentcore/services/test_identity.py b/tests/bedrock_agentcore/services/test_identity.py index 58479e48..a6453aad 100644 --- a/tests/bedrock_agentcore/services/test_identity.py +++ b/tests/bedrock_agentcore/services/test_identity.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest +from botocore.exceptions import ClientError from bedrock_agentcore.services.identity import ( DEFAULT_POLLING_INTERVAL_SECONDS, @@ -24,7 +25,6 @@ def test_initialization(self): with patch("boto3.client") as mock_boto_client: client = IdentityClient(region) - assert client.region == region mock_boto_client.assert_called_with( "bedrock-agentcore", @@ -786,6 +786,435 @@ def test_complete_resource_token_auth_with_invalid_identifier(self): with pytest.raises(ValueError, match="Unexpected UserIdentifier"): identity_client.complete_resource_token_auth(session_uri, invalid_identifier) # type: ignore - unit test + def test_create_payment_credential_provider(self): + """Test create_payment_credential_provider.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + name = "test-payment-provider" + vendor = "CoinbaseCDP" + config = { + "apiKeyId": "test-api-key-id", + "apiKeySecret": "test-api-key-secret", + "walletSecret": "test-wallet-secret", + } + arn = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/test-payment-provider" + expected_response = { + "name": name, + "credentialProviderVendor": vendor, + "credentialProviderArn": arn, + } + + mock_cp_client.create_payment_credential_provider.return_value = expected_response + + result = identity_client.create_payment_credential_provider(name, vendor, config) + + assert result == expected_response + mock_cp_client.create_payment_credential_provider.assert_called_once_with( + name=name, credentialProviderVendor=vendor, providerConfigurationInput=config + ) + + def test_update_payment_credential_provider(self): + """Test update_payment_credential_provider.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + name = "test-payment-provider" + vendor = "CoinbaseCDP" + config = { + "apiKeyId": "updated-api-key-id", + "apiKeySecret": "updated-api-key-secret", + "walletSecret": "updated-wallet-secret", + } + arn = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/test-payment-provider" + expected_response = { + "name": name, + "credentialProviderVendor": vendor, + "credentialProviderArn": arn, + } + + mock_cp_client.update_payment_credential_provider.return_value = expected_response + + result = identity_client.update_payment_credential_provider(name, vendor, config) + + assert result == expected_response + mock_cp_client.update_payment_credential_provider.assert_called_once_with( + name=name, credentialProviderVendor=vendor, providerConfigurationInput=config + ) + + def test_delete_payment_credential_provider(self): + """Test delete_payment_credential_provider.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + name = "test-payment-provider" + expected_response = {} + + mock_cp_client.delete_payment_credential_provider.return_value = expected_response + + result = identity_client.delete_payment_credential_provider(name) + + assert result == expected_response + mock_cp_client.delete_payment_credential_provider.assert_called_once_with(name=name) + + def test_get_payment_credential_provider(self): + """Test get_payment_credential_provider.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + name = "test-payment-provider" + arn = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/test-payment-provider" + expected_response = { + "name": name, + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": arn, + "createdTime": "2024-01-01T00:00:00Z", + "lastUpdatedTime": "2024-01-01T00:00:00Z", + } + + mock_cp_client.get_payment_credential_provider.return_value = expected_response + + result = identity_client.get_payment_credential_provider(name) + + assert result == expected_response + mock_cp_client.get_payment_credential_provider.assert_called_once_with(name=name) + + def test_list_payment_credential_providers_without_pagination(self): + """Test list_payment_credential_providers without pagination parameters.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + arn1 = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/provider-1" + arn2 = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/provider-2" + expected_response = { + "credentialProviders": [ + { + "name": "provider-1", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": arn1, + "createdTime": "2024-01-01T00:00:00Z", + "lastUpdatedTime": "2024-01-01T00:00:00Z", + }, + { + "name": "provider-2", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": arn2, + "createdTime": "2024-01-02T00:00:00Z", + "lastUpdatedTime": "2024-01-02T00:00:00Z", + }, + ] + } + + mock_cp_client.list_payment_credential_providers.return_value = expected_response + + result = identity_client.list_payment_credential_providers() + + assert result == expected_response + mock_cp_client.list_payment_credential_providers.assert_called_once_with() + + def test_list_payment_credential_providers_with_pagination(self): + """Test list_payment_credential_providers with pagination parameters.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + next_token = "test-next-token" + max_results = 10 + arn = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/provider-1" + expected_response = { + "credentialProviders": [ + { + "name": "provider-1", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": arn, + "createdTime": "2024-01-01T00:00:00Z", + "lastUpdatedTime": "2024-01-01T00:00:00Z", + } + ], + "nextToken": "next-page-token", + } + + mock_cp_client.list_payment_credential_providers.return_value = expected_response + + result = identity_client.list_payment_credential_providers(next_token=next_token, max_results=max_results) + + assert result == expected_response + mock_cp_client.list_payment_credential_providers.assert_called_once_with( + nextToken=next_token, maxResults=max_results + ) + + def test_list_payment_credential_providers_with_next_token_only(self): + """Test list_payment_credential_providers with only next_token parameter.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + next_token = "test-next-token" + arn = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/provider-3" + expected_response = { + "credentialProviders": [ + { + "name": "provider-3", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": arn, + "createdTime": "2024-01-03T00:00:00Z", + "lastUpdatedTime": "2024-01-03T00:00:00Z", + } + ] + } + + mock_cp_client.list_payment_credential_providers.return_value = expected_response + + result = identity_client.list_payment_credential_providers(next_token=next_token) + + assert result == expected_response + mock_cp_client.list_payment_credential_providers.assert_called_once_with(nextToken=next_token) + + def test_list_payment_credential_providers_with_max_results_only(self): + """Test list_payment_credential_providers with only max_results parameter.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data + max_results = 5 + arn = "arn:aws:acps:us-west-2:123456789012:token-vault/test/paymentcredentialprovider/provider-1" + expected_response = { + "credentialProviders": [ + { + "name": "provider-1", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": arn, + "createdTime": "2024-01-01T00:00:00Z", + "lastUpdatedTime": "2024-01-01T00:00:00Z", + } + ] + } + + mock_cp_client.list_payment_credential_providers.return_value = expected_response + + result = identity_client.list_payment_credential_providers(max_results=max_results) + + assert result == expected_response + mock_cp_client.list_payment_credential_providers.assert_called_once_with(maxResults=max_results) + + def test_list_payment_credential_providers_with_zero_max_results(self): + """Test list_payment_credential_providers validates max_results=0.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # max_results=0 should raise ValueError (outside valid range 1-20) + with pytest.raises(ValueError, match="max_results must be between 1 and 20"): + identity_client.list_payment_credential_providers(max_results=0) + + def test_list_payment_credential_providers_with_empty_next_token(self): + """Test list_payment_credential_providers with next_token="" (edge case).""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Test data - next_token="" should be passed through, not treated as falsy + next_token = "" + expected_response = {"credentialProviders": []} + + mock_cp_client.list_payment_credential_providers.return_value = expected_response + + result = identity_client.list_payment_credential_providers(next_token=next_token) + + assert result == expected_response + mock_cp_client.list_payment_credential_providers.assert_called_once_with(nextToken="") + + def test_create_payment_credential_provider_client_error(self): + """Test create_payment_credential_provider propagates ClientError.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Mock boto3 to raise ClientError + error_response = {"Error": {"Code": "ProviderNotFound", "Message": "Provider not found"}} + mock_cp_client.create_payment_credential_provider.side_effect = ClientError( + error_response, "CreatePaymentCredentialProvider" + ) + + with pytest.raises(ClientError): + identity_client.create_payment_credential_provider("test-provider", "CoinbaseCDP", {}) + + def test_update_payment_credential_provider_client_error(self): + """Test update_payment_credential_provider propagates ClientError.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Mock boto3 to raise ClientError + error_response = {"Error": {"Code": "AccessDenied", "Message": "Access denied"}} + mock_cp_client.update_payment_credential_provider.side_effect = ClientError( + error_response, "UpdatePaymentCredentialProvider" + ) + + with pytest.raises(ClientError): + identity_client.update_payment_credential_provider("test-provider", "CoinbaseCDP", {}) + + def test_delete_payment_credential_provider_client_error(self): + """Test delete_payment_credential_provider propagates ClientError.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Mock boto3 to raise ClientError + error_response = {"Error": {"Code": "ProviderNotFound", "Message": "Provider not found"}} + mock_cp_client.delete_payment_credential_provider.side_effect = ClientError( + error_response, "DeletePaymentCredentialProvider" + ) + + with pytest.raises(ClientError): + identity_client.delete_payment_credential_provider("test-provider") + + def test_get_payment_credential_provider_client_error(self): + """Test get_payment_credential_provider propagates ClientError.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Mock boto3 to raise ClientError + error_response = {"Error": {"Code": "ProviderNotFound", "Message": "Provider not found"}} + mock_cp_client.get_payment_credential_provider.side_effect = ClientError( + error_response, "GetPaymentCredentialProvider" + ) + + with pytest.raises(ClientError): + identity_client.get_payment_credential_provider("test-provider") + + def test_list_payment_credential_providers_client_error(self): + """Test list_payment_credential_providers propagates ClientError.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + # Mock boto3 to raise ClientError + error_response = {"Error": {"Code": "ServiceUnavailable", "Message": "Service unavailable"}} + mock_cp_client.list_payment_credential_providers.side_effect = ClientError( + error_response, "ListPaymentCredentialProviders" + ) + + with pytest.raises(ClientError): + identity_client.list_payment_credential_providers() + + def test_list_payment_credential_providers_max_results_below_range(self): + """Test list_payment_credential_providers validates max_results < 1.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + with pytest.raises(ValueError, match="max_results must be between 1 and 20"): + identity_client.list_payment_credential_providers(max_results=0) + + def test_list_payment_credential_providers_max_results_above_range(self): + """Test list_payment_credential_providers validates max_results > 20.""" + region = "us-west-2" + + with patch("boto3.client") as mock_boto_client: + mock_cp_client = Mock() + mock_dp_client = Mock() + mock_boto_client.side_effect = [mock_cp_client, mock_dp_client] + + identity_client = IdentityClient(region) + + with pytest.raises(ValueError, match="max_results must be between 1 and 20"): + identity_client.list_payment_credential_providers(max_results=50) + class TestDefaultApiTokenPoller: """Test DefaultApiTokenPoller implementation.""" diff --git a/tests_integ/payments/README.md b/tests_integ/payments/README.md new file mode 100644 index 00000000..0c3aeea3 --- /dev/null +++ b/tests_integ/payments/README.md @@ -0,0 +1,254 @@ +# Payment Integration Tests + +This directory contains integration tests for the Bedrock AgentCore Payment Control Plane and Data Plane Clients. + +## Prerequisites + +- Python 3.10+ +- AWS credentials configured +- Access to Bedrock AgentCore Payment service +- pytest and dependencies installed + +## Environment Setup + +### Required Environment Variables + +```bash +# Bedrock AgentCore Control Plane endpoint +export BEDROCK_AGENTCORE_CONTROL_ENDPOINT="https://bedrock-agentcore-control.us-west-2.amazonaws.com" +``` + +### Optional Environment Variables + +```bash +# AWS region (default: us-west-2) +export BEDROCK_TEST_REGION="us-west-2" + +# IAM role ARN for payment manager operations +export TEST_PAYMENT_ROLE_ARN="arn:aws:iam::123456789012:role/PaymentRole" + +# Credential provider ARN for payment connector operations +export TEST_CREDENTIAL_PROVIDER_ARN="arn:aws:iam::123456789012:role/CredentialProvider" + +# Data Plane specific environment variables +export TEST_PAYMENT_MANAGER_ARN="arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123" +export TEST_PAYMENT_CONNECTOR_ID="pc-123" +export TEST_USER_ID="test-user" +``` + +### AWS Credentials + +Configure AWS credentials using one of these methods: + +1. **AWS CLI Configuration** (recommended): + ```bash + aws configure + ``` + +2. **Environment Variables**: + ```bash + export AWS_ACCESS_KEY_ID="your-access-key" + export AWS_SECRET_ACCESS_KEY="your-secret-key" + export AWS_DEFAULT_REGION="us-west-2" + ``` + +3. **IAM Role** (if running on EC2/ECS): + - Ensure the instance has an IAM role with appropriate permissions + +## Running Tests + +### Run All Integration Tests + +```bash +pytest tests_integ/payment/ -v +``` + +### Run Control Plane Tests Only + +```bash +pytest tests_integ/payment/test_payment_controlplane.py -v +``` + +### Run Data Plane Tests Only + +```bash +pytest tests_integ/payment/test_payment_client.py -v +``` + +### Run Specific Test Class + +```bash +# Payment manager tests +pytest tests_integ/payment/test_payment_controlplane.py::TestPaymentControlPlaneClient -v + +# Payment data plane tests +pytest tests_integ/payment/test_payment_client.py::TestPaymentClientDataPlane -v +``` + +### Run Specific Test + +```bash +pytest tests_integ/payment/test_payment_controlplane.py::TestPaymentControlPlaneClient::test_create_and_get_payment_manager -v +pytest tests_integ/payment/test_payment_client.py::TestPaymentClientDataPlane::test_create_and_get_payment_instrument -v +``` + +### Run with Verbose Output + +```bash +pytest tests_integ/payment/ -vv -s +``` + +### Run with Coverage + +```bash +pytest tests_integ/payment/ --cov=bedrock_agentcore.payment --cov-report=html +``` + +## Test Structure + +### TestPaymentControlPlaneClient + +Tests for payment control plane CRUD operations: +- `test_create_and_get_payment_manager`: Create and retrieve a payment manager +- `test_list_payment_managers`: List all payment managers +- `test_update_payment_manager`: Update payment manager properties +- `test_create_and_get_payment_connector`: Create and retrieve a payment connector +- `test_list_payment_connectors`: List connectors for a manager +- `test_update_payment_connector`: Update connector properties +- `test_complete_payment_setup_workflow`: Complete setup workflow with manager and connectors + +### TestPaymentClientDataPlane + +Tests for payment data plane operations: +- `test_create_and_get_payment_instrument`: Create and retrieve a payment instrument +- `test_list_payment_instruments`: List payment instruments for a user +- `test_create_and_get_payment_session`: Create and retrieve a payment session +- `test_list_payment_sessions`: List payment sessions for a user +- `test_delete_payment_session`: Delete a payment session +- `test_delete_payment_instrument`: Delete a payment instrument +- `test_process_payment`: Process a payment transaction +- `test_complete_payment_workflow`: End-to-end workflow (instrument → session → payment) +- `test_idempotency_with_client_token`: Verify idempotency using client tokens + +## Service Side Verification + +Monitor service logs to verify test execution: + +### Expected Log Patterns - Control Plane + +``` +Creating payment manager: test-manager-integration +Payment manager created: arn:aws:payments:us-west-2:123456789012:manager/pm-123 +Creating payment connector: test-connector for manager pm-123 +Payment connector created: pc-123 +Updating payment manager: pm-123 +Deleting payment connector: pc-123 for manager pm-123 +Deleting payment manager: pm-123 +``` + +### Expected Log Patterns - Data Plane + +``` +Creating payment instrument for user: test-user +Successfully created instrument for user: test-user +Retrieving payment instrument for user: test-user +Creating payment session for user: test-user +Processing payment of type CRYPTO_X402 for user: test-user +Successfully processed payment for user: test-user +``` + +### Log Levels + +- **INFO**: Normal operations (create, update, delete, list) +- **WARNING**: Retry attempts, timeouts +- **ERROR**: Failed operations, validation errors + +## Troubleshooting + +### Tests Are Skipped + +**Issue**: All tests are skipped with message "BEDROCK_AGENTCORE_CONTROL_ENDPOINT not set" + +**Solution**: Set the required environment variable: +```bash +export BEDROCK_AGENTCORE_CONTROL_ENDPOINT="https://bedrock-agentcore-control.us-west-2.amazonaws.com" +``` + +### Connection Timeout + +**Issue**: Tests fail with connection timeout + +**Solution**: +1. Verify the endpoint URL is correct +2. Check network connectivity to the endpoint +3. Verify AWS credentials are valid +4. Check security group/firewall rules + +### Authentication Errors + +**Issue**: Tests fail with "AccessDeniedException" or "UnauthorizedException" + +**Solution**: +1. Verify AWS credentials are configured correctly +2. Check IAM permissions for the credentials +3. Verify the role ARNs are correct and accessible + +### Resource Cleanup Issues + +**Issue**: Tests fail because resources weren't cleaned up from previous runs + +**Solution**: +1. Manually delete orphaned resources using AWS CLI +2. Check service logs for cleanup errors +3. Increase timeout values if cleanup is slow + +## Performance Considerations + +- Tests use `wait_for_active=True` by default, which polls for status changes +- Default timeout is 60 seconds per operation +- Adjust `max_wait` and `poll_interval` parameters if needed +- Run tests sequentially to avoid resource conflicts + +## Best Practices + +1. **Isolation**: Each test creates and cleans up its own resources +2. **Error Handling**: Tests include proper cleanup in finally blocks +3. **Logging**: Enable debug logging for troubleshooting: + ```bash + pytest tests_integ/payment/test_payment_controlplane.py -v --log-cli-level=DEBUG + ``` +4. **Monitoring**: Watch service logs during test execution +5. **Cleanup**: Ensure all resources are deleted after tests complete + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Integration Tests + +on: [push, pull_request] + +jobs: + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run integration tests + env: + BEDROCK_AGENTCORE_CONTROL_ENDPOINT: ${{ secrets.BEDROCK_ENDPOINT }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: pytest tests_integ/payment/ -v +``` + +## Additional Resources + +- [Bedrock AgentCore Documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/) +- [Payment Control Plane API Reference](https://docs.aws.amazon.com/bedrock/latest/userguide/payment-api/) +- [AWS SDK for Python (Boto3)](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) diff --git a/tests_integ/payments/__init__.py b/tests_integ/payments/__init__.py new file mode 100644 index 00000000..5bf1545f --- /dev/null +++ b/tests_integ/payments/__init__.py @@ -0,0 +1 @@ +"""Integration tests for Bedrock AgentCore Payment SDK.""" diff --git a/tests_integ/payments/integrations/__init__.py b/tests_integ/payments/integrations/__init__.py new file mode 100644 index 00000000..6d09ed3a --- /dev/null +++ b/tests_integ/payments/integrations/__init__.py @@ -0,0 +1 @@ +"""Integration tests for payment integrations.""" diff --git a/tests_integ/payments/integrations/strands/test_payment_tools_integration.py b/tests_integ/payments/integrations/strands/test_payment_tools_integration.py new file mode 100644 index 00000000..83523d81 --- /dev/null +++ b/tests_integ/payments/integrations/strands/test_payment_tools_integration.py @@ -0,0 +1,474 @@ +"""Integration tests for payment tools with real PaymentManager and Strands Agent. + +These tests require real AWS credentials and a configured payment manager. +They will be skipped if TEST_PAYMENT_MANAGER_ARN environment variable is not set. + +Run with: python -m pytest tests_integ/payment/integrations/strands/test_payment_tools_integration.py -v -s +""" + +import logging +import os +import uuid + +import pytest +from strands import Agent + +from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig +from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin +from bedrock_agentcore.payments.manager import PaymentManager + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + + +@pytest.mark.integration +class TestPaymentToolsIntegration: + """Integration tests for payment tools with real PaymentManager and Strands Agent. + + These tests use a real PaymentManager instance and Strands Agent to test + the payment tools in a realistic scenario. They require: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + - Real payment manager setup + """ + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + cls.payment_instrument_id = os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-abcdefghijklmno") + cls.payment_session_id = os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-abcdefghijklmno") + + def test_payment_tools_with_real_agent(self): + """Test payment tools with a real Strands Agent. + + This test creates a real Strands Agent with the payment plugin and verifies + that the payment tools are available and callable through the agent. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + _agent = Agent( + system_prompt="You are a helpful assistant with access to payment tools.", + plugins=[plugin], + ) + + # Verify plugin is registered with agent + assert plugin.payment_manager is not None + assert isinstance(plugin.payment_manager, PaymentManager) + + logger.info("✓ Payment tools successfully initialized with real Strands Agent") + + def test_get_payment_instrument_tool_with_agent(self): + """Test getPaymentInstrument tool execution through Strands Agent. + + This test creates an agent with the payment plugin and verifies that + the getPaymentInstrument tool is available and can be invoked. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + - Real payment instrument ID + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + _agent = Agent( + system_prompt="You are a helpful assistant with access to payment tools.", + plugins=[plugin], + ) + + # Verify the tool is available + assert callable(plugin.get_payment_instrument) + + logger.info("✓ getPaymentInstrument tool is available through Strands Agent") + + def test_list_payment_instruments_tool_with_agent(self): + """Test listPaymentInstruments tool execution through Strands Agent. + + This test creates an agent with the payment plugin and verifies that + the listPaymentInstruments tool is available and can be invoked. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + _agent = Agent( + system_prompt="You are a helpful assistant with access to payment tools.", + plugins=[plugin], + ) + + # Verify the tool is available + assert callable(plugin.list_payment_instruments) + + logger.info("✓ listPaymentInstruments tool is available through Strands Agent") + + def test_get_payment_session_tool_with_agent(self): + """Test getPaymentSession tool execution through Strands Agent. + + This test creates an agent with the payment plugin and verifies that + the getPaymentSession tool is available and can be invoked. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + - Real payment session ID + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + _agent = Agent( + system_prompt="You are a helpful assistant with access to payment tools.", + plugins=[plugin], + ) + + # Verify the tool is available + assert callable(plugin.get_payment_session) + + logger.info("✓ getPaymentSession tool is available through Strands Agent") + + +@pytest.mark.integration +class TestPaymentToolsWithAutoPaymentFlag: + """Integration tests for payment tools with auto_payment flag. + + These tests verify that payment tools work correctly with the auto_payment + configuration flag using a real PaymentManager instance. + """ + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + cls.payment_instrument_id = os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-abcdefghijklmno") + cls.payment_session_id = os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-abcdefghijklmno") + + def test_tools_available_with_auto_payment_true(self): + """Test payment tools are available when auto_payment=True. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real PaymentManager test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + auto_payment=True, + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify all tools are callable + assert callable(plugin.get_payment_instrument) + assert callable(plugin.list_payment_instruments) + assert callable(plugin.get_payment_session) + + logger.info("✓ Payment tools are available with auto_payment=True") + + def test_tools_available_with_auto_payment_false(self): + """Test payment tools are available when auto_payment=False. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real PaymentManager test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + auto_payment=False, + ) + plugin = AgentCorePaymentsPlugin(config) + + # Verify all tools are callable + assert callable(plugin.get_payment_instrument) + assert callable(plugin.list_payment_instruments) + assert callable(plugin.get_payment_session) + + logger.info("✓ Payment tools are available with auto_payment=False") + + def test_auto_payment_flag_default_value(self): + """Test that auto_payment flag defaults to True. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real PaymentManager test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + + # Verify auto_payment defaults to True + assert config.auto_payment is True + + logger.info("✓ auto_payment flag defaults to True") + + +@pytest.mark.integration +class TestPaymentToolsWithPrompts: + """Integration tests that invoke agent with prompts to trigger payment tools. + + These tests verify that the payment tools can be invoked through natural language + prompts to the Strands Agent. + """ + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + cls.payment_instrument_id = os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-abcdefghijklmno") + cls.payment_session_id = os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-abcdefghijklmno") + + def test_agent_invokes_list_payment_instruments_with_prompt(self): + """Test agent invokes listPaymentInstruments tool via natural language prompt. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + agent = Agent( + system_prompt=( + "You are a helpful assistant with access to payment tools. " + "When asked about payment instruments, use the listPaymentInstruments tool." + ), + plugins=[plugin], + ) + + # Invoke agent with prompt to trigger listPaymentInstruments + prompt = f"Please list all payment instruments for user {self.user_id}" + logger.info("\n%s", "=" * 80) + logger.info("PROMPT: %s", prompt) + logger.info("%s", "=" * 80) + + result = agent(prompt) + + # Verify agent executed successfully + assert result is not None + logger.info("\n%s", "=" * 80) + logger.info("RESULT: %s", result) + logger.info("Stop Reason: %s", result.stop_reason) + logger.info("%s", "=" * 80) + logger.info("✓ Agent successfully invoked listPaymentInstruments tool via prompt") + + def test_agent_invokes_get_payment_instrument_with_prompt(self): + """Test agent invokes getPaymentInstrument tool via natural language prompt. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + - Real payment instrument ID + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + agent = Agent( + system_prompt=( + "You are a helpful assistant with access to payment tools. " + "When asked about a specific payment instrument, use the getPaymentInstrument tool." + ), + plugins=[plugin], + ) + + # Invoke agent with prompt to trigger getPaymentInstrument + prompt = f"Please get details for payment instrument {self.payment_instrument_id} for user {self.user_id}" + logger.info("\n%s", "=" * 80) + logger.info("PROMPT: %s", prompt) + logger.info("%s", "=" * 80) + + result = agent(prompt) + + # Verify agent executed successfully + assert result is not None + logger.info("\n%s", "=" * 80) + logger.info("RESULT: %s", result) + logger.info("Stop Reason: %s", result.stop_reason) + logger.info("%s", "=" * 80) + logger.info("✓ Agent successfully invoked getPaymentInstrument tool via prompt") + + def test_agent_invokes_get_payment_session_with_prompt(self): + """Test agent invokes getPaymentSession tool via natural language prompt. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + - Real payment session ID + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + agent = Agent( + system_prompt=( + "You are a helpful assistant with access to payment tools. " + "When asked about a payment session, use the getPaymentSession tool." + ), + plugins=[plugin], + ) + + # Invoke agent with prompt to trigger getPaymentSession + prompt = f"Please get details for payment session {self.payment_session_id} for user {self.user_id}" + logger.info("\n%s", "=" * 80) + logger.info("PROMPT: %s", prompt) + logger.info("%s", "=" * 80) + + result = agent(prompt) + + # Verify agent executed successfully + assert result is not None + logger.info("\n%s", "=" * 80) + logger.info("RESULT: %s", result) + logger.info("Stop Reason: %s", result.stop_reason) + logger.info("%s", "=" * 80) + logger.info("✓ Agent successfully invoked getPaymentSession tool via prompt") + + def test_agent_invokes_multiple_tools_with_sequential_prompts(self): + """Test agent invokes multiple payment tools with sequential prompts. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + """ + if not self.payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping real agent test") + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=self.payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=self.payment_instrument_id, + payment_session_id=self.payment_session_id, + region=self.region, + ) + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the payment plugin + system_prompt = ( + "You are a helpful assistant with access to payment tools. " + "Use the appropriate tool to answer questions about payment instruments " + "and sessions." + ) + agent = Agent( + system_prompt=system_prompt, + plugins=[plugin], + ) + + # First prompt: list instruments + prompt1 = f"List all payment instruments for user {self.user_id}" + result1 = agent(prompt1) + assert result1 is not None + logger.info("✓ First prompt: listPaymentInstruments executed") + + # Second prompt: get specific instrument + prompt2 = f"Get details for payment instrument {self.payment_instrument_id}" + result2 = agent(prompt2) + assert result2 is not None + logger.info("✓ Second prompt: getPaymentInstrument executed") + + # Third prompt: get session details + prompt3 = f"Get details for payment session {self.payment_session_id}" + result3 = agent(prompt3) + assert result3 is not None + logger.info("✓ Third prompt: getPaymentSession executed") + + logger.info("✓ Agent successfully invoked multiple payment tools with sequential prompts") diff --git a/tests_integ/payments/integrations/strands/test_plugin_integration.py b/tests_integ/payments/integrations/strands/test_plugin_integration.py new file mode 100644 index 00000000..22ffdc5d --- /dev/null +++ b/tests_integ/payments/integrations/strands/test_plugin_integration.py @@ -0,0 +1,712 @@ +"""Integration tests for AgentCorePaymentsPlugin with Strands framework. + +Run with: python -m pytest tests_integ/payment/integrations/test_strands_plugin.py -v +""" + +import json +import logging +import os +import uuid +from typing import Any + +import pytest +from strands import Agent +from strands_tools import http_request + +from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig +from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin +from bedrock_agentcore.payments.manager import ( + PaymentInstrumentConfigurationRequired, + PaymentManager, + PaymentSessionConfigurationRequired, + PaymentSessionNotFound, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + + +@pytest.mark.integration +class TestAgentCorePaymentsPlugin: + """Integration tests for AgentCorePaymentsPlugin.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + def invoke_agent_with_payment_handling( + self, agent: Agent, prompt: str, plugin: AgentCorePaymentsPlugin = None, max_iterations: int = 10 + ) -> Any: + """Invoke agent and handle payment interrupts. + + This wrapper method handles payment failure interrupts by responding with + a message indicating the payment issue. For instrument/session not found + interrupts, it updates the plugin config with fallback values and retries. + + Args: + agent: The Strands Agent instance + prompt: The initial prompt to send to the agent + plugin: Optional plugin instance to update config on configuration-required interrupts + max_iterations: Maximum number of iterations to handle interrupts + + Returns: + The final agent result + """ + result = agent(prompt) + iteration = 0 + + while result.stop_reason == "interrupt" and iteration < max_iterations: + iteration += 1 + logger.info("Handling interrupt (iteration %d): %s", iteration, result.stop_reason) + + responses = [] + for interrupt in result.interrupts: + logger.info("Interrupt: %s", interrupt.name) + logger.info("Reason: %s", interrupt.reason) + + # Handle payment failure interrupts + if interrupt.name.startswith("payment-failure-"): + reason = interrupt.reason + exception_type = reason.get("exceptionType", "Unknown") + exception_message = reason.get("exceptionMessage", "Unknown error") + + logger.warning("Payment failure: %s - %s", exception_type, exception_message) + + # Handle instrument configuration required + if exception_type == PaymentInstrumentConfigurationRequired.__name__ and plugin: + instrument_id = os.environ.get( + "TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-abcdefghijklmno" + ) + logger.info("Updating payment_instrument_id to %s", instrument_id) + plugin.config.update_payment_instrument_id(instrument_id) + responses.append( + { + "interruptResponse": { + "interruptId": interrupt.id, + "response": ( + "Payment Instrument ID has been configured." + " Please retry the previous tool call." + ), + } + } + ) + continue + + # Handle session configuration required + if exception_type == PaymentSessionConfigurationRequired.__name__ and plugin: + session_id = os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-abcdefghijklmno") + logger.info("Updating payment_session_id to %s", session_id) + plugin.config.update_payment_session_id(session_id) + responses.append( + { + "interruptResponse": { + "interruptId": interrupt.id, + "response": ( + "Payment session ID has been configured. Please retry the previous tool call." + ), + } + } + ) + continue + + # Handle session not found + if exception_type == PaymentSessionNotFound.__name__ and plugin: + session_id = os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-abcdefghijklmno") + logger.info("Payment session not found, updating payment_session_id to %s", session_id) + plugin.config.update_payment_session_id(session_id) + responses.append( + { + "interruptResponse": { + "interruptId": interrupt.id, + "response": ( + "Payment session ID has been updated. Please retry the previous tool call." + ), + } + } + ) + continue + + # Default: respond with failure message + response_message = ( + f"Payment processing failed with {exception_type}: {exception_message}. " + "Unable to complete the payment transaction." + ) + + responses.append({"interruptResponse": {"interruptId": interrupt.id, "response": response_message}}) + + # Continue agent with responses + result = agent(responses) + + return result + + def test_plugin_with_real_agent(self): + """Test plugin initialization with a real Strands Agent.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=os.environ.get( + "TEST_PAYMENT_MANAGER_ARN", + "arn:aws:bedrock-agentcore:us-west-2:12345678910:payment-manager/mypaymentmanager-cyrc25gr4c", + ), + user_id=self.user_id, + payment_session_id=os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-bWOGg4z2irAGbzA"), + payment_instrument_id=os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-vnL29CKAdyESdQ7"), + region=self.region, + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the plugin + agent = Agent(system_prompt="You are a helpful assistant.", plugins=[plugin]) + + # Verify plugin is registered with agent + assert plugin.payment_manager is not None + assert isinstance(plugin.payment_manager, PaymentManager) + assert agent is not None + + logger.info("Plugin successfully initialized with real Strands Agent") + + def test_v1_happy_case_with_real_payment_manager(self): + """Test V1 happy case: Plugin processes X.402 payment requirements. + + This test requires: + - TEST_PAYMENT_MANAGER_ARN environment variable + - Valid AWS credentials + - Real payment manager setup + + The test will: + 1. Create a real plugin with real PaymentManager + 2. Test payment processing with V1 X.402 requirements + 3. Verify payment header is constructed correctly + + To run this test: + export BEDROCK_TEST_REGION="us-west-2" + export TEST_PAYMENT_MANAGER_ARN="arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123" + export TEST_PAYMENT_INSTRUMENT_ID="payment-instrument-xxx" + export TEST_PAYMENT_SESSION_ID="payment-session-xxx" + export TEST_USER_ID="test-user" + pytest tests_integ/payment/integrations/test_plugin_integration.py::\ + TestAgentCorePaymentsPlugin::test_v1_happy_case_with_real_payment_manager -v -s + """ + # Skip if payment manager ARN not configured + payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + if not payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping V1 happy case test") + + # payment_session_id="payment-session-bWOGg4z2irAGbzA", # Must match pattern: payment-session-[0-9a-zA-Z-]{15} + # payment_instrument_id="payment-instrument-vnL29CKAdyESdQ7", # Valid instrument ID + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=payment_manager_arn, + user_id=self.user_id, + region=self.region, + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the plugin + SYSTME_PROMPT = ( + "You are a helpful assistant. If you encounter 405, change the http " + "method and try again. Print the tool input/output." + ) + agent = Agent(system_prompt=SYSTME_PROMPT, tools=[http_request], plugins=[plugin]) + + # Use the wrapper method to handle payment interrupts + result = self.invoke_agent_with_payment_handling( + agent, + "Please fetch a joke from https://nickeljoke.vercel.app/api/joke and tell me what it is", + plugin=plugin, + ) + + logger.info("✓ Plugin successfully initialized with real Strands Agent") + logger.info("✓ V1 happy case test completed successfully") + logger.info("Final result stop_reason: %s", result.stop_reason) + + +@pytest.mark.integration +class TestPaymentHandlerExtraction: + """Integration tests for payment handler extraction with real handler instances.""" + + def test_spec_compliant_marker_extraction(self): + """Test that handler correctly extracts spec-compliant PAYMENT_REQUIRED marker. + + This test verifies: + 1. Handler correctly extracts payment structure from PAYMENT_REQUIRED: marker + 2. All required fields (statusCode, headers, body) are present + 3. Handler validates statusCode == 402 + """ + from bedrock_agentcore.payments.integrations.handlers import GenericPaymentHandler + + handler = GenericPaymentHandler() + + # Create spec-compliant response with PAYMENT_REQUIRED marker + payment_required = { + "statusCode": 402, + "headers": {"content-type": "application/json", "x-custom": "value"}, + "body": {"error": "Payment required", "details": "Additional info"}, + } + + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_required)}"}] + + # Test extraction + status_code = handler.extract_status_code(result) + headers = handler.extract_headers(result) + body = handler.extract_body(result) + + assert status_code == 402, "Status code should be 402" + logger.info("✓ Status code correctly extracted: %d", status_code) + + assert headers is not None, "Headers should be extracted" + assert headers.get("content-type") == "application/json", "Headers should contain content-type" + assert headers.get("x-custom") == "value", "Headers should contain custom header" + logger.info("✓ Headers correctly extracted: %s", headers) + + assert body is not None, "Body should be extracted" + assert body.get("error") == "Payment required", "Body should contain error message" + assert body.get("details") == "Additional info", "Body should contain details" + logger.info("✓ Body correctly extracted: %s", body) + + logger.info("✓ Spec-compliant marker extraction test completed successfully") + + def test_payment_header_application(self): + """Test that payment header is correctly applied to tool input. + + This test verifies: + 1. Payment header is added to tool input + 2. Existing headers are preserved + 3. Handler correctly applies header + """ + from bedrock_agentcore.payments.integrations.handlers import GenericPaymentHandler + + handler = GenericPaymentHandler() + + # Create tool input with existing headers + tool_input = {"url": "https://api.example.com/resource", "headers": {"content-type": "application/json"}} + + # Apply payment header + payment_header = {"X-PAYMENT": "base64-encoded-payment"} + success = handler.apply_payment_header(tool_input, payment_header) + + assert success is True, "Payment header application should succeed" + logger.info("✓ Payment header application succeeded") + + # Verify header was added + assert "X-PAYMENT" in tool_input["headers"], "Payment header should be in tool input" + assert tool_input["headers"]["X-PAYMENT"] == "base64-encoded-payment", "Payment header value should match" + logger.info("✓ Payment header correctly added: %s", tool_input["headers"]["X-PAYMENT"]) + + # Verify existing headers are preserved + assert tool_input["headers"]["content-type"] == "application/json", "Existing headers should be preserved" + logger.info("✓ Existing headers preserved: %s", tool_input["headers"]) + + logger.info("✓ Payment header application test completed successfully") + + def test_x402_v2_header_based_format(self): + """Test extraction of X.402 v2 (header-based) format. + + This test verifies: + 1. Handler correctly extracts v2 format with payment-required header + 2. Headers are passed through unmodified + 3. Body is passed through unmodified + """ + from bedrock_agentcore.payments.integrations.handlers import GenericPaymentHandler + + handler = GenericPaymentHandler() + + # Create X.402 v2 response (header-based) + v2_payment_info = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "5000", + "resource": "https://api.example.com/resource", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + } + ], + } + + payment_required = { + "statusCode": 402, + "headers": { + "content-type": "application/json", + "payment-required": json.dumps(v2_payment_info), + }, + "body": {"error": "Payment required to access this resource"}, + } + + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_required)}"}] + + # Test extraction + status_code = handler.extract_status_code(result) + headers = handler.extract_headers(result) + body = handler.extract_body(result) + + assert status_code == 402, "Status code should be 402" + logger.info("✓ X.402 v2 status code extracted: %d", status_code) + + assert headers is not None, "Headers should be extracted" + assert "payment-required" in headers, "Headers should contain payment-required" + logger.info("✓ X.402 v2 headers extracted with payment-required: %s", headers.get("payment-required")[:50]) + + assert body is not None, "Body should be extracted" + assert body.get("error") == "Payment required to access this resource" + logger.info("✓ X.402 v2 body extracted: %s", body) + + logger.info("✓ X.402 v2 header-based format test completed successfully") + + def test_x402_v1_body_based_format(self): + """Test extraction of X.402 v1 (body-based) format. + + This test verifies: + 1. Handler correctly extracts v1 format with x402Version in body + 2. Headers are passed through unmodified + 3. Body is passed through unmodified + """ + from bedrock_agentcore.payments.integrations.handlers import GenericPaymentHandler + + handler = GenericPaymentHandler() + + # Create X.402 v1 response (body-based) + v1_body = { + "x402Version": 1, + "error": "X-PAYMENT header is required", + "accepts": [ + { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "5000", + "resource": "https://api.example.com/resource", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + } + ], + } + + payment_required = { + "statusCode": 402, + "headers": {"content-type": "application/json"}, + "body": v1_body, + } + + result = [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_required)}"}] + + # Test extraction + status_code = handler.extract_status_code(result) + headers = handler.extract_headers(result) + body = handler.extract_body(result) + + assert status_code == 402, "Status code should be 402" + logger.info("✓ X.402 v1 status code extracted: %d", status_code) + + assert headers is not None, "Headers should be extracted" + assert headers.get("content-type") == "application/json" + logger.info("✓ X.402 v1 headers extracted: %s", headers) + + assert body is not None, "Body should be extracted" + assert body.get("x402Version") == 1, "Body should contain x402Version" + assert body.get("error") == "X-PAYMENT header is required" + logger.info("✓ X.402 v1 body extracted with version: %d", body.get("x402Version")) + + logger.info("✓ X.402 v1 body-based format test completed successfully") + + +@pytest.mark.integration +class TestAgentWithPaymentHandlingFlow: + """Integration tests for agent with payment handling using spec-compliant tool responses.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + def test_invoke_agent_with_payment_handling(self): + """Test agent invocation with payment handling using spec-compliant tool. + + This test demonstrates: + 1. Agent uses a custom tool that returns spec-compliant 402 responses + 2. Plugin intercepts the 402 response + 3. Plugin extracts payment requirements using the marker format + 4. Plugin processes payment and retries the tool + 5. Tool succeeds on retry with payment header + + The flow: + 1. Agent calls http_request_with_payment tool + 2. Tool detects 402 and returns PAYMENT_REQUIRED: marker with structure + 3. Plugin extracts payment requirements from marker + 4. Plugin calls PaymentManager to generate payment header + 5. Plugin applies payment header to tool input + 6. Plugin sets retry flag + 7. Agent retries tool with payment header + 8. Tool succeeds and returns 200 response + + To run this test with real payment manager: + export BEDROCK_TEST_REGION="us-west-2" + export TEST_PAYMENT_MANAGER_ARN="arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123" + export TEST_PAYMENT_INSTRUMENT_ID="payment-instrument-xxx" + export TEST_PAYMENT_SESSION_ID="payment-session-xxx" + export TEST_USER_ID="test-user" + pytest tests_integ/payment/integrations/strands/test_plugin_integration.py::\ + TestAgentWithPaymentHandlingFlow::test_invoke_agent_with_payment_handling -v -s + """ + # Skip if payment manager ARN not configured + payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + if not payment_manager_arn: + pytest.skip("TEST_PAYMENT_MANAGER_ARN not configured - skipping agent payment handling test") + + from strands import Agent + from strands.tools import tool + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=payment_manager_arn, + user_id=self.user_id, + payment_instrument_id=os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-test123"), + payment_session_id=os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-test456"), + region=self.region, + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + # Define a custom tool that returns spec-compliant 402 responses + @tool + def http_request_with_payment(url: str, method: str = "GET", headers: dict = None, body: str = None) -> dict: + """Make an HTTP request with payment support. + + Returns spec-compliant 402 response when payment is required. + + Args: + url: The URL to request + method: HTTP method (GET, POST, PUT, DELETE, etc.) + headers: Optional HTTP headers as a dictionary + body: Optional request body as a string + + Returns: + dict: Response with status_code, headers, and body + """ + logger.info("🔵 http_request_with_payment tool called") + logger.info(" URL: %s", url) + logger.info(" Method: %s", method) + logger.info(" Headers: %s", headers) + + # Simulate payment requirement on first call (no X-PAYMENT header) + if headers is None or "X-PAYMENT" not in headers: + logger.info(" 💳 Payment required - returning 402 response") + + # Build spec-compliant 402 response + payment_required = { + "statusCode": 402, + "headers": { + "content-type": "application/json", + "x-payment-required": "true", + }, + "body": { + "error": "Payment required", + "message": "X-PAYMENT header is required to access this resource", + "x402Version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "5000", + "resource": url, + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + } + ], + }, + } + + # Return ToolResult with PAYMENT_REQUIRED marker (spec-compliant) + return { + "status": "error", + "content": [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_required, indent=2)}"}], + } + + # Simulate successful response on retry (with X-PAYMENT header) + logger.info(" ✅ Payment header present - returning 200 response") + return { + "status": "success", + "content": [ + { + "text": json.dumps( + { + "status_code": 200, + "headers": {"content-type": "application/json"}, + "body": {"message": "Success", "data": "Resource accessed with payment"}, + } + ) + } + ], + } + + # Create agent with plugin and custom tool + agent = Agent( + system_prompt="You are a helpful assistant that can make HTTP requests.", + tools=[http_request_with_payment], + plugins=[plugin], + ) + + logger.info("✓ Agent created with payment plugin and custom tool") + + # Invoke agent with a request that will trigger payment requirement + prompt = "Please make a GET request to https://api.example.com/protected-resource" + + logger.info("📤 Invoking agent with prompt: %s", prompt) + result = agent(prompt) + + logger.info("✓ Agent invocation completed") + logger.info(" Stop reason: %s", result.stop_reason) + + logger.info("✓ Agent with payment handling flow test completed successfully") + + def test_spec_compliant_tool_response_structure(self): + """Test that tool response follows spec-compliant structure. + + This test verifies: + 1. Tool returns ToolResult with error status + 2. Content contains PAYMENT_REQUIRED: marker + 3. Marker is followed by valid JSON + 4. JSON contains required fields: statusCode, headers, body + 5. statusCode is exactly 402 + 6. headers and body are dictionaries + """ + from strands.tools import tool + + @tool + def spec_compliant_tool() -> dict: + """Tool that returns spec-compliant 402 response.""" + payment_required = { + "statusCode": 402, + "headers": { + "content-type": "application/json", + "x-payment-required": "true", + }, + "body": { + "error": "Payment required", + "x402Version": 1, + }, + } + + return { + "status": "error", + "content": [{"text": f"PAYMENT_REQUIRED: {json.dumps(payment_required)}"}], + } + + # Call the tool + result = spec_compliant_tool() + + logger.info("Tool result: %s", result) + + # Verify structure + assert result["status"] == "error", "Status should be error" + logger.info("✓ Status is 'error'") + + assert "content" in result, "Result should have content" + assert len(result["content"]) > 0, "Content should not be empty" + logger.info("✓ Content is present") + + content_text = result["content"][0]["text"] + assert content_text.startswith("PAYMENT_REQUIRED: "), "Content should start with PAYMENT_REQUIRED: marker" + logger.info("✓ Content starts with PAYMENT_REQUIRED: marker") + + # Extract and parse JSON + payment_json = content_text[len("PAYMENT_REQUIRED: ") :] + payment_data = json.loads(payment_json) + logger.info("✓ JSON parsed successfully") + + # Verify required fields + assert "statusCode" in payment_data, "statusCode field required" + assert payment_data["statusCode"] == 402, "statusCode must be 402" + logger.info("✓ statusCode is 402") + + assert "headers" in payment_data, "headers field required" + assert isinstance(payment_data["headers"], dict), "headers must be dict" + logger.info("✓ headers is dict: %s", payment_data["headers"]) + + assert "body" in payment_data, "body field required" + assert isinstance(payment_data["body"], dict), "body must be dict" + logger.info("✓ body is dict: %s", payment_data["body"]) + + logger.info("✓ Spec-compliant tool response structure test completed successfully") + + +@pytest.mark.integration +class TestAgentCorePaymentsPluginAgentName: + """Integration tests for agent_name propagation through the plugin to PaymentManager.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + def test_plugin_config_with_agent_name(self): + """Test plugin config accepts agent_name and passes it through to PaymentManager.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=os.environ.get( + "TEST_PAYMENT_MANAGER_ARN", + "arn:aws:bedrock-agentcore:us-west-2:12345678910:payment-manager/mypaymentmanager-cyrc25gr4c", + ), + user_id=self.user_id, + payment_session_id=os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-bWOGg4z2irAGbzA"), + payment_instrument_id=os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-vnL29CKAdyESdQ7"), + region=self.region, + agent_name="integ-test-agent", + ) + + assert config.agent_name == "integ-test-agent" + + plugin = AgentCorePaymentsPlugin(config=config) + assert plugin.config.agent_name == "integ-test-agent" + + def test_plugin_config_without_agent_name_backward_compatible(self): + """Test plugin config works without agent_name (backward compatible).""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=os.environ.get( + "TEST_PAYMENT_MANAGER_ARN", + "arn:aws:bedrock-agentcore:us-west-2:12345678910:payment-manager/mypaymentmanager-cyrc25gr4c", + ), + user_id=self.user_id, + payment_session_id=os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-bWOGg4z2irAGbzA"), + payment_instrument_id=os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-vnL29CKAdyESdQ7"), + region=self.region, + ) + + assert config.agent_name is None + + plugin = AgentCorePaymentsPlugin(config=config) + assert plugin.config.agent_name is None + + def test_plugin_with_agent_name_initializes_payment_manager(self): + """Test plugin with agent_name initializes PaymentManager with agent_name set. + + This test creates a real Strands Agent with the plugin and verifies + that PaymentManager is initialized with the agent_name from config. + """ + config = AgentCorePaymentsPluginConfig( + payment_manager_arn=os.environ.get( + "TEST_PAYMENT_MANAGER_ARN", + "arn:aws:bedrock-agentcore:us-west-2:12345678910:payment-manager/mypaymentmanager-cyrc25gr4c", + ), + user_id=self.user_id, + payment_session_id=os.environ.get("TEST_PAYMENT_SESSION_ID", "payment-session-bWOGg4z2irAGbzA"), + payment_instrument_id=os.environ.get("TEST_PAYMENT_INSTRUMENT_ID", "payment-instrument-vnL29CKAdyESdQ7"), + region=self.region, + agent_name="strands-payment-agent", + ) + + plugin = AgentCorePaymentsPlugin(config=config) + + # Create a real Strands Agent with the plugin + agent = Agent(system_prompt="You are a helpful assistant.", plugins=[plugin]) + + # Verify plugin initialized PaymentManager with agent_name + assert plugin.payment_manager is not None + assert isinstance(plugin.payment_manager, PaymentManager) + assert plugin.payment_manager._agent_name == "strands-payment-agent" + assert agent is not None + + logger.info("Plugin with agent_name successfully initialized with real Strands Agent") diff --git a/tests_integ/payments/test_payment_client.py b/tests_integ/payments/test_payment_client.py new file mode 100644 index 00000000..eec12c53 --- /dev/null +++ b/tests_integ/payments/test_payment_client.py @@ -0,0 +1,540 @@ +"""Integration tests for Payment Control Plane Client. + +This module contains tests for the Bedrock AgentCore Payment control plane operations. + +SETUP INSTRUCTIONS: +=================== + +1. Set the following environment variables before running tests: + + # Required: AWS region + export BEDROCK_TEST_REGION="us-west-2" + + # For create_payment_manager_with_connector tests (optional): + # Base64-encoded Ed25519 private key for API key secret + export PAYMENT_TEST_API_KEY_SECRET="" + # Wallet secret (can be any valid format expected by the service) + export PAYMENT_TEST_WALLET_SECRET="" + +2. Ensure AWS credentials are configured: + - Via ~/.aws/credentials + - Via environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) + - Via IAM role (if running on EC2/ECS) + +3. Run the tests: + pytest tests_integ/payment/test_payment_client.py -v + +4. To run specific test class: + pytest tests_integ/payment/test_payment_client.py::TestPaymentClientControlPlane -v + pytest tests_integ/payment/test_payment_client.py::TestCreatePaymentManagerWithConnectorIntegration -v + +5. To run with detailed output: + pytest tests_integ/payment/test_payment_client.py -vv -s + +SERVICE SIDE VERIFICATION: +========================== + +Monitor service logs to verify: +- Payment manager creation/retrieval events +- Payment connector creation/retrieval events +- Error handling and validation +""" + +import os +import time +import uuid + +import pytest + +from bedrock_agentcore.payments import PaymentClient + + +@pytest.fixture(scope="function", autouse=True) +def cleanup_resources(): + """Fixture to ensure resources are cleaned up after each test.""" + yield + # Cleanup happens after test execution + + +@pytest.mark.integration +class TestPaymentClientControlPlane: + """Integration tests for PaymentClient control plane operations.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.role_arn = os.environ.get( + "TEST_PAYMENT_ROLE_ARN", + "arn:aws:iam::123456789012:role/bedrock-payment-role", + ) + cls.client = PaymentClient(region_name=cls.region) + # Use timestamp with microseconds for uniqueness + cls.test_prefix = f"t{int(time.time() * 1000000)}" + cls.created_managers = [] + cls.created_connectors = [] + cls.role_arn = os.environ.get( + "TEST_PAYMENT_ROLE_ARN", + "arn:aws:iam::123456789012:role/bedrock-payment-role", + ) + + @classmethod + @classmethod + def teardown_class(cls): + """Clean up test resources.""" + # Clean up created connectors first + for payment_manager_id, payment_connector_id in cls.created_connectors: + try: + cls.client.delete_payment_connector( + payment_manager_id=payment_manager_id, + payment_connector_id=payment_connector_id, + ) + except Exception: + pass + + # Clean up created managers + for payment_manager_id in cls.created_managers: + try: + cls.client.delete_payment_manager(payment_manager_id=payment_manager_id) + except Exception: + pass + + def test_create_and_get_payment_manager(self): + """Test creating and retrieving a payment manager.""" + manager_name = f"testManager{int(time.time())}" + + # Create a payment manager + result = self.client.create_payment_manager( + name=manager_name, + role_arn=self.role_arn, + description="Test payment manager", + ) + + payment_manager_id = result.get("paymentManagerId") + assert payment_manager_id is not None + self.__class__.created_managers.append(payment_manager_id) + + # Retrieve the manager + retrieved = self.client.get_payment_manager(payment_manager_id=payment_manager_id) + + assert retrieved.get("paymentManagerId") == payment_manager_id + assert retrieved.get("name") == manager_name + + def test_list_payment_managers(self): + """Test listing payment managers.""" + result = self.client.list_payment_managers(max_results=10) + + assert isinstance(result, dict) + assert "paymentManagers" in result + managers = result.get("paymentManagers", []) + assert isinstance(managers, list) + + # Verify structure of returned managers + for manager in managers: + assert "paymentManagerId" in manager + assert "paymentManagerArn" in manager + assert "name" in manager + assert "status" in manager + + def test_update_payment_manager(self): + """Test updating a payment manager.""" + manager_name = f"testManagerUpdate{int(time.time())}" + + # Create a payment manager + create_result = self.client.create_payment_manager( + name=manager_name, + role_arn=self.role_arn, + ) + + payment_manager_id = create_result.get("paymentManagerId") + self.__class__.created_managers.append(payment_manager_id) + + # Update the manager (only description can be updated) + update_result = self.client.update_payment_manager( + payment_manager_id=payment_manager_id, + description="Updated description", + ) + + assert update_result.get("paymentManagerId") == payment_manager_id + + def test_create_and_get_payment_connector(self): + """Test creating and retrieving a payment connector.""" + # First create a payment manager + manager_name = f"testManagerConnector{int(time.time())}" + + manager_result = self.client.create_payment_manager( + name=manager_name, + role_arn=self.role_arn, + ) + + payment_manager_id = manager_result.get("paymentManagerId") + self.__class__.created_managers.append(payment_manager_id) + + # Create a payment connector + connector_name = f"testConnector{int(time.time())}" + credential_provider_arn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:test" + + connector_result = self.client.create_payment_connector( + payment_manager_id=payment_manager_id, + name=connector_name, + connector_type="CoinbaseCDP", + credential_provider_configurations=[{"coinbaseCDP": {"credentialProviderArn": credential_provider_arn}}], + description="Test connector", + ) + + payment_connector_id = connector_result.get("paymentConnectorId") + assert payment_connector_id is not None + self.__class__.created_connectors.append((payment_manager_id, payment_connector_id)) + + # Retrieve the connector + retrieved = self.client.get_payment_connector( + payment_manager_id=payment_manager_id, + payment_connector_id=payment_connector_id, + ) + + assert retrieved.get("paymentConnectorId") == payment_connector_id + assert retrieved.get("name") == connector_name + + def test_list_payment_connectors(self): + """Test listing payment connectors.""" + # First create a payment manager + manager_name = f"testManagerListConnectors{int(time.time())}" + + manager_result = self.client.create_payment_manager( + name=manager_name, + role_arn=self.role_arn, + ) + + payment_manager_id = manager_result.get("paymentManagerId") + self.__class__.created_managers.append(payment_manager_id) + + # List connectors + result = self.client.list_payment_connectors( + payment_manager_id=payment_manager_id, + max_results=10, + ) + + assert isinstance(result, dict) + assert "paymentConnectors" in result + connectors = result.get("paymentConnectors", []) + assert isinstance(connectors, list) + + # Verify structure of returned connectors + for connector in connectors: + assert "paymentConnectorId" in connector + assert "paymentManagerId" in connector + assert "name" in connector + assert "status" in connector + + def test_update_payment_connector(self): + """Test updating a payment connector.""" + # First create a payment manager + manager_name = f"testManagerUpdateConnector{int(time.time())}" + + manager_result = self.client.create_payment_manager( + name=manager_name, + role_arn=self.role_arn, + ) + + payment_manager_id = manager_result.get("paymentManagerId") + self.__class__.created_managers.append(payment_manager_id) + + # Create a connector + connector_name = f"testConnectorUpdate{int(time.time())}" + credential_provider_arn = "arn:aws:secretsmanager:us-west-2:123456789012:secret:test" + + connector_result = self.client.create_payment_connector( + payment_manager_id=payment_manager_id, + name=connector_name, + connector_type="CoinbaseCDP", + credential_provider_configurations=[{"coinbaseCDP": {"credentialProviderArn": credential_provider_arn}}], + ) + + payment_connector_id = connector_result.get("paymentConnectorId") + self.__class__.created_connectors.append((payment_manager_id, payment_connector_id)) + + # Update the connector + updated_description = "Updated connector description" + update_result = self.client.update_payment_connector( + payment_manager_id=payment_manager_id, + payment_connector_id=payment_connector_id, + description=updated_description, + ) + + assert update_result.get("paymentConnectorId") == payment_connector_id + # Note: The API may not return the description in the update response + # so we just verify the update succeeded + + +@pytest.mark.integration +class TestCreatePaymentManagerWithConnectorIntegration: + """Integration tests for create_payment_manager_with_connector method.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.client = PaymentClient(region_name=cls.region) + # Use timestamp with microseconds for uniqueness + cls.test_prefix = f"t{int(time.time() * 1000000)}" + cls.created_managers = [] + cls.created_connectors = [] # List of (payment_manager_id, payment_connector_id) tuples + cls.created_providers = [] # List of provider names + + # Read credentials from environment variables + cls.api_key_secret = os.environ.get("PAYMENT_TEST_API_KEY_SECRET") + cls.wallet_secret = os.environ.get("PAYMENT_TEST_WALLET_SECRET") + cls.skip_tests = not (cls.api_key_secret and cls.wallet_secret) + cls.role_arn = os.environ.get( + "TEST_PAYMENT_ROLE_ARN", + "arn:aws:iam::123456789012:role/bedrock-payment-role", + ) + + @classmethod + def teardown_class(cls): + """Clean up test resources.""" + # Clean up connectors first + for manager_id, connector_id in cls.created_connectors: + try: + cls.client.delete_payment_connector( + payment_manager_id=manager_id, + payment_connector_id=connector_id, + ) + except Exception as e: + print(f"Failed to delete connector {connector_id}: {e}") + + # Clean up credential providers + for provider_name in cls.created_providers: + try: + cls.client.identity_client.delete_payment_credential_provider(name=provider_name) + except Exception as e: + print(f"Failed to delete provider {provider_name}: {e}") + + # Clean up payment managers + for manager_id in cls.created_managers: + try: + cls.client.delete_payment_manager(payment_manager_id=manager_id) + except Exception as e: + print(f"Failed to delete manager {manager_id}: {e}") + + def test_create_payment_manager_with_connector_success(self): + """Test successful creation of payment manager with connector and credential provider.""" + if self.skip_tests: + pytest.skip("PAYMENT_TEST_API_KEY_SECRET and PAYMENT_TEST_WALLET_SECRET environment variables not set") + + manager_name = f"{self.test_prefix}MgrWC" + connector_name = f"{self.test_prefix}ConnWC" + # Use UUID for provider name to ensure uniqueness - include full UUID + provider_name = f"prov-{uuid.uuid4()}" + + payment_connector_config = { + "name": connector_name, + "description": "Test connector for integration", + "payment_credential_provider_config": { + "name": provider_name, + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": self.api_key_secret, + "wallet_secret": self.wallet_secret, + }, + }, + } + + # Create payment manager with connector + response = self.client.create_payment_manager_with_connector( + payment_manager_name=manager_name, + payment_manager_description="Test payment manager with connector", + authorizer_type="AWS_IAM", + role_arn=self.role_arn, + payment_connector_config=payment_connector_config, + ) + + # Verify response structure + assert "paymentManager" in response + assert "paymentConnector" in response + assert "credentialProvider" in response + + # Verify payment manager details + payment_manager = response["paymentManager"] + assert payment_manager["name"] == manager_name + assert "paymentManagerId" in payment_manager + assert "paymentManagerArn" in payment_manager + assert "status" in payment_manager + + manager_id = payment_manager["paymentManagerId"] + self.__class__.created_managers.append(manager_id) + + # Verify payment connector details + payment_connector = response["paymentConnector"] + assert payment_connector["name"] == connector_name + assert "paymentConnectorId" in payment_connector + assert payment_connector["paymentManagerId"] == manager_id + assert "status" in payment_connector + + connector_id = payment_connector["paymentConnectorId"] + self.__class__.created_connectors.append((manager_id, connector_id)) + + # Verify credential provider details + credential_provider = response["credentialProvider"] + assert credential_provider["name"] == provider_name + assert credential_provider["credentialProviderVendor"] == "CoinbaseCDP" + assert "credentialProviderArn" in credential_provider + + self.__class__.created_providers.append(provider_name) + + def test_create_payment_manager_with_connector_with_wait_for_ready(self): + """Test creation with wait_for_ready to ensure resources reach READY status.""" + if self.skip_tests: + pytest.skip("PAYMENT_TEST_API_KEY_SECRET and PAYMENT_TEST_WALLET_SECRET environment variables not set") + + manager_name = f"{self.test_prefix}MgrWR" + connector_name = f"{self.test_prefix}ConnWR" + # Use UUID for provider name to ensure uniqueness - include full UUID + provider_name = f"prov-{uuid.uuid4()}" + + payment_connector_config = { + "name": connector_name, + "description": "Test connector with wait for ready", + "payment_credential_provider_config": { + "name": provider_name, + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": self.api_key_secret, + "wallet_secret": self.wallet_secret, + }, + }, + } + + # Create payment manager with connector and wait for ready + response = self.client.create_payment_manager_with_connector( + payment_manager_name=manager_name, + payment_manager_description="Test payment manager with wait for ready", + authorizer_type="AWS_IAM", + role_arn=self.role_arn, + payment_connector_config=payment_connector_config, + wait_for_ready=True, + max_wait=300, + poll_interval=10, + ) + + # Verify resources reached READY status + payment_manager = response["paymentManager"] + assert payment_manager["status"] == "READY" + + payment_connector = response["paymentConnector"] + assert payment_connector["status"] == "READY" + + manager_id = payment_manager["paymentManagerId"] + connector_id = payment_connector["paymentConnectorId"] + self.__class__.created_managers.append(manager_id) + self.__class__.created_connectors.append((manager_id, connector_id)) + self.__class__.created_providers.append(provider_name) + + def test_create_payment_manager_with_connector_minimal_description(self): + """Test creation with minimal/empty descriptions.""" + if self.skip_tests: + pytest.skip("PAYMENT_TEST_API_KEY_SECRET and PAYMENT_TEST_WALLET_SECRET environment variables not set") + + manager_name = f"{self.test_prefix}MgrMin" + connector_name = f"{self.test_prefix}ConnMin" + # Use UUID for provider name to ensure uniqueness - include full UUID + provider_name = f"prov-{uuid.uuid4()}" + + connector_config = { + "name": connector_name, + "description": "", # Empty description + "payment_credential_provider_config": { + "name": provider_name, + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": self.api_key_secret, + "wallet_secret": self.wallet_secret, + }, + }, + } + + # Create with minimal descriptions + response = self.client.create_payment_manager_with_connector( + payment_manager_name=manager_name, + payment_manager_description="", # Empty description + authorizer_type="AWS_IAM", + role_arn=self.role_arn, + payment_connector_config=connector_config, + ) + + # Verify creation succeeded + assert "paymentManager" in response + assert "paymentConnector" in response + assert "credentialProvider" in response + + manager_id = response["paymentManager"]["paymentManagerId"] + connector_id = response["paymentConnector"]["paymentConnectorId"] + self.__class__.created_managers.append(manager_id) + self.__class__.created_connectors.append((manager_id, connector_id)) + self.__class__.created_providers.append(provider_name) + + # Verify we can retrieve the created resources + manager = self.client.get_payment_manager(payment_manager_id=manager_id) + assert manager["paymentManagerId"] == manager_id + assert manager["name"] == manager_name + + def test_create_payment_manager_with_connector_retrieve_resources(self): + """Test that created resources can be retrieved individually.""" + if self.skip_tests: + pytest.skip("PAYMENT_TEST_API_KEY_SECRET and PAYMENT_TEST_WALLET_SECRET environment variables not set") + + manager_name = f"{self.test_prefix}MgrRet" + connector_name = f"{self.test_prefix}ConnRet" + # Use UUID for provider name to ensure uniqueness - include full UUID + provider_name = f"prov-{uuid.uuid4()}" + + connector_config = { + "name": connector_name, + "description": "Test connector for retrieval", + "payment_credential_provider_config": { + "name": provider_name, + "credential_provider_vendor": "CoinbaseCDP", + "credentials": { + "api_key_id": "test-api-key-id", + "api_key_secret": self.api_key_secret, + "wallet_secret": self.wallet_secret, + }, + }, + } + + # Create payment manager with connector + response = self.client.create_payment_manager_with_connector( + payment_manager_name=manager_name, + payment_manager_description="Test retrieval", + authorizer_type="AWS_IAM", + role_arn=self.role_arn, + payment_connector_config=connector_config, + ) + + manager_id = response["paymentManager"]["paymentManagerId"] + connector_id = response["paymentConnector"]["paymentConnectorId"] + self.__class__.created_managers.append(manager_id) + self.__class__.created_connectors.append((manager_id, connector_id)) + self.__class__.created_providers.append(provider_name) + + # Retrieve payment manager + retrieved_manager = self.client.get_payment_manager(payment_manager_id=manager_id) + assert retrieved_manager["paymentManagerId"] == manager_id + assert retrieved_manager["name"] == manager_name + + # Retrieve payment connector + retrieved_connector = self.client.get_payment_connector( + payment_manager_id=manager_id, + payment_connector_id=connector_id, + ) + assert retrieved_connector["paymentConnectorId"] == connector_id + assert retrieved_connector["name"] == connector_name + + # List connectors for the manager + connectors_list = self.client.list_payment_connectors(payment_manager_id=manager_id) + assert "paymentConnectors" in connectors_list + connector_ids = [c["paymentConnectorId"] for c in connectors_list["paymentConnectors"]] + assert connector_id in connector_ids diff --git a/tests_integ/payments/test_payment_manager.py b/tests_integ/payments/test_payment_manager.py new file mode 100644 index 00000000..b4fd5e95 --- /dev/null +++ b/tests_integ/payments/test_payment_manager.py @@ -0,0 +1,1171 @@ +"""Integration tests for PaymentManager. + +This module contains integration tests for the PaymentManager class, which provides +a high-level wrapper around PaymentClient for simplified payment operations. + +SETUP INSTRUCTIONS: +=================== + +1. Set the following environment variables before running tests: + + # Required: AWS region + export BEDROCK_TEST_REGION="us-west-2" + + # Required: Payment manager ARN (created via control plane) + export TEST_PAYMENT_MANAGER_ARN="arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-123" + + # Required: Payment connector ID (created via control plane) + export TEST_PAYMENT_CONNECTOR_ID="pc-123" + + # Optional: User ID for testing (default: test-user) + export TEST_USER_ID="test-user" + +2. Ensure AWS credentials are configured: + - Via ~/.aws/credentials + - Via environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) + - Via IAM role (if running on EC2/ECS) + +3. Run the tests: + pytest tests_integ/payment/test_payment_manager.py -v + +4. To run specific test class: + pytest tests_integ/payment/test_payment_manager.py::TestPaymentManagerWorkflow -v + +5. To run with detailed output: + pytest tests_integ/payment/test_payment_manager.py -vv -s + +SERVICE SIDE VERIFICATION: +========================== + +Monitor service logs to verify: +- Payment manager initialization +- Payment instrument creation/retrieval events +- Payment session creation/update events +- Payment processing events +- Error handling and validation +""" + +import os +import uuid + +import boto3 +import pytest + +from bedrock_agentcore.payments.manager import PaymentManager + + +@pytest.mark.integration +class TestPaymentManagerInitialization: + """Tests for PaymentManager initialization with real AWS client.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + default_arn = "arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-test" + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN", default_arn) + cls.payment_connector_id = os.environ.get("TEST_PAYMENT_CONNECTOR_ID", "pc-test") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + def test_manager_initialization_with_config(self): + """Test PaymentManager initialization with payment_manager_arn.""" + manager = PaymentManager(payment_manager_arn=self.payment_manager_arn, region_name=self.region) + + assert manager._payment_manager_arn == self.payment_manager_arn + assert manager.region_name == self.region + assert manager._payment_client is not None + + def test_manager_initialization_with_region(self): + """Test PaymentManager initialization with region_name parameter.""" + manager = PaymentManager(payment_manager_arn=self.payment_manager_arn, region_name=self.region) + + assert manager.region_name == self.region + + def test_manager_initialization_with_session(self): + """Test PaymentManager initialization with boto3_session parameter.""" + session = boto3.Session(region_name=self.region) + manager = PaymentManager(payment_manager_arn=self.payment_manager_arn, boto3_session=session) + + assert manager.region_name == self.region + + +@pytest.mark.integration +class TestPaymentManagerWorkflow: + """Tests for complete PaymentManager workflows with real AWS service.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + cls.payment_connector_id = os.environ.get("TEST_PAYMENT_CONNECTOR_ID") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + # Initialize PaymentManager if env vars are set + if cls.payment_manager_arn: + manager = PaymentManager(payment_manager_arn=cls.payment_manager_arn, region_name=cls.region) + cls.manager = manager + else: + cls.manager = None + + # Store created resource IDs for cleanup + cls.instrument_ids = [] + cls.session_ids = [] + + @classmethod + def teardown_class(cls): + """Clean up test resources. + + Payment instruments and sessions do not currently support delete operations. + Sessions expire naturally via expiryDuration. Instruments persist per-user. + Once delete APIs are GA, add cleanup here using cls.session_ids and cls.instrument_ids. + """ + pass + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_end_to_end_workflow(self): + """Test end-to-end workflow: create instrument → create session → process payment.""" + # Step 1: Create payment instrument + instrument_response = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=str(uuid.uuid4()), + ) + # Response is already unwrapped by PaymentManager + assert "paymentInstrumentId" in instrument_response + instrument_id = instrument_response["paymentInstrumentId"] + self.__class__.instrument_ids.append(instrument_id) + + # Step 2: Create payment session + session_response = self.manager.create_payment_session( + user_id=self.user_id, + expiry_time_in_minutes=60, + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, + client_token=str(uuid.uuid4()), + ) + # Response is already unwrapped by PaymentManager + assert "paymentSessionId" in session_response + session_id = session_response["paymentSessionId"] + self.__class__.session_ids.append(session_id) + + # Step 3: Process payment + payment_input = { + "cryptoX402": { + "version": "1", + "payload": { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "5000", + "resource": "https://nickeljoke.vercel.app/api/joke", + "description": "Premium AI joke generation", + "mimeType": "application/json", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "maxTimeoutSeconds": 300, + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "outputSchema": {"input": {"type": "http", "method": "GET", "discoverable": True}}, + "extra": {"name": "USDC", "version": "2"}, + }, + } + } + payment_response = self.manager.process_payment( + user_id=self.user_id, + payment_session_id=session_id, + payment_instrument_id=instrument_id, + payment_type="CRYPTO_X402", + payment_input=payment_input, + client_token=str(uuid.uuid4()), + ) + # Verify response contains payment processing result + assert payment_response is not None + assert isinstance(payment_response, dict) + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_idempotency_with_client_token(self): + """Test idempotency with client_token.""" + client_token = str(uuid.uuid4()) + + # First call with client_token + response1 = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=client_token, + ) + # Response is already unwrapped by PaymentManager + assert "paymentInstrumentId" in response1 + instrument_id_1 = response1["paymentInstrumentId"] + self.__class__.instrument_ids.append(instrument_id_1) + + # Second call with same client_token should return same result (idempotent) + response2 = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=client_token, + ) + instrument_id_2 = response2["paymentInstrumentId"] + assert instrument_id_1 == instrument_id_2 + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_region_and_session_configuration(self): + """Test region and session configuration.""" + session = boto3.Session(region_name=self.region) + manager = PaymentManager(payment_manager_arn=self.payment_manager_arn, boto3_session=session) + + assert manager.region_name == self.region + + +@pytest.mark.integration +class TestPaymentManagerMethodForwarding: + """Tests for PaymentManager method forwarding to real PaymentClient.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + cls.payment_connector_id = os.environ.get("TEST_PAYMENT_CONNECTOR_ID") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + # Initialize PaymentManager if env vars are set + if cls.payment_manager_arn: + manager = PaymentManager(payment_manager_arn=cls.payment_manager_arn, region_name=cls.region) + cls.manager = manager + else: + cls.manager = None + + # Store created resource IDs for cleanup + cls.instrument_ids = [] + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_forwarding_to_payment_client(self): + """Test method forwarding to PaymentClient.""" + # This tests that unmapped methods are properly forwarded + # list_payment_instruments is wrapped by PaymentManager, so use it directly + result = self.manager.list_payment_instruments( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + ) + + # Should return a valid response from PaymentClient + assert isinstance(result, dict) + assert "paymentInstruments" in result + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_forwarding_with_arguments(self): + """Test method forwarding with arguments.""" + # Create an instrument first + instrument_response = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=str(uuid.uuid4()), + ) + # Response is already unwrapped by PaymentManager + instrument_id = instrument_response["paymentInstrumentId"] + self.__class__.instrument_ids.append(instrument_id) + + # Now retrieve it using the wrapped method + result = self.manager.get_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_id=instrument_id, + ) + + assert result["paymentInstrumentId"] == instrument_id + + +@pytest.mark.integration +class TestGeneratePaymentHeaderWorkflow: + """Tests for generatePaymentHeader method with real AWS service.""" + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN") + cls.payment_connector_id = os.environ.get("TEST_PAYMENT_CONNECTOR_ID") + cls.user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + # Initialize PaymentManager if env vars are set + if cls.payment_manager_arn: + manager = PaymentManager(payment_manager_arn=cls.payment_manager_arn, region_name=cls.region) + cls.manager = manager + else: + cls.manager = None + + # Store created resource IDs for cleanup + cls.instrument_ids = [] + cls.session_ids = [] + + @classmethod + def teardown_class(cls): + """Clean up test resources.""" + # Note: In a real scenario, you might want to clean up created resources + # However, payment instruments and sessions may have retention policies + pass + + @staticmethod + def create_v1_x402_response(scheme="exact", network="eip155:84532"): + """Create a v1 X.402 response for testing (requirement structure without payload field).""" + return { + "statusCode": 402, + "headers": {}, + "body": { + "x402Version": 1, + "error": "X-PAYMENT header is required", + "accepts": [ + { + "scheme": scheme, + "network": network, + "maxAmountRequired": "5000", + "resource": "https://nickeljoke.vercel.app/api/joke", + "description": "Premium AI joke generation", + "mimeType": "application/json", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "maxTimeoutSeconds": 300, + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "extra": {"name": "USDC", "version": "2"}, + } + ], + }, + } + + @staticmethod + def create_v2_x402_response(scheme="exact", network="ethereum"): + """Create a v2 X.402 response for testing (requirement structure without payload field).""" + import base64 + import json + + v2_payload = { + "x402Version": 2, + "resource": { + "url": "https://api.example.com/premium-data", + "description": "Access to premium market data", + "mimeType": "application/json", + }, + "accepts": [ + { + "scheme": scheme, + "network": network, + "maxAmountRequired": "5000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x6813749E1eB9E0001A44C2684695FE8AD676cdD0", + "maxTimeoutSeconds": 300, + "extra": {"name": "USDC", "version": "2"}, + } + ], + } + encoded_payload = base64.b64encode(json.dumps(v2_payload).encode()).decode() + + return { + "statusCode": 402, + "headers": { + "payment-required": encoded_payload, + }, + "body": {}, + } + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_generate_payment_header_v1_workflow(self): + """Test complete v1 workflow for generatePaymentHeader. + + Verifies: + - Payment instrument created with Ethereum network + - Payment session created successfully + - generatePaymentHeader called with v1 X.402 response + - Header starts with "X-PAYMENT:" + - Header is base64 encoded + - Decoded header contains required fields + """ + # Step 1: Create payment instrument with Ethereum network + instrument_response = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=str(uuid.uuid4()), + ) + instrument_id = instrument_response["paymentInstrumentId"] + self.__class__.instrument_ids.append(instrument_id) + + # Step 2: Create payment session + session_response = self.manager.create_payment_session( + user_id=self.user_id, + expiry_time_in_minutes=60, + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, + client_token=str(uuid.uuid4()), + ) + session_id = session_response["paymentSessionId"] + self.__class__.session_ids.append(session_id) + + # Step 3: Create v1 X.402 response with Base Mainnet network + x402_response = self.create_v1_x402_response( + scheme="exact", + network="eip155:8453", + ) + + # Step 4: Call generatePaymentHeader + header = self.manager.generate_payment_header( + user_id=self.user_id, + payment_instrument_id=instrument_id, + payment_session_id=session_id, + payment_required_request=x402_response, + ) + + # Step 5: Verify header format + assert header is not None + assert isinstance(header, dict) + assert "X-PAYMENT" in header + assert header["X-PAYMENT"] is not None + assert header["X-PAYMENT"] != "" + + # Step 6: Verify header is base64 encoded + import base64 + import json + + header_value = header["X-PAYMENT"] + decoded_header = base64.b64decode(header_value).decode() + header_json = json.loads(decoded_header) + + # Step 7: Verify decoded header contains required fields + assert "x402Version" in header_json + assert header_json["x402Version"] == 1 + assert "scheme" in header_json + assert header_json["scheme"] is not None + assert "network" in header_json + assert header_json["network"] is not None + assert "payload" in header_json + assert header_json["payload"] is not None + + # Step 8: Verify scheme and network match the x402_response + assert header_json["scheme"] == x402_response["body"]["accepts"][0]["scheme"] + assert header_json["network"] == x402_response["body"]["accepts"][0]["network"] + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_generate_payment_header_v2_workflow(self): + """Test complete v2 workflow for generatePaymentHeader. + + Verifies: + - Payment instrument created with Ethereum network + - Payment session created successfully + - generatePaymentHeader called with v2 X.402 response (base64-encoded header) + - Header starts with "PAYMENT-SIGNATURE:" + - Header is base64 encoded + - Decoded header contains required fields + """ + # Step 1: Create payment instrument with Ethereum network + instrument_response = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=str(uuid.uuid4()), + ) + instrument_id = instrument_response["paymentInstrumentId"] + self.__class__.instrument_ids.append(instrument_id) + + # Step 2: Create payment session + session_response = self.manager.create_payment_session( + user_id=self.user_id, + expiry_time_in_minutes=60, + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, + client_token=str(uuid.uuid4()), + ) + session_id = session_response["paymentSessionId"] + self.__class__.session_ids.append(session_id) + + # Step 3: Create v2 X.402 response with Base Mainnet network + x402_response = self.create_v2_x402_response( + scheme="exact", + network="eip155:8453", + ) + + # Step 4: Call generatePaymentHeader + header = self.manager.generate_payment_header( + user_id=self.user_id, + payment_instrument_id=instrument_id, + payment_session_id=session_id, + payment_required_request=x402_response, + ) + + # Step 5: Verify header format + assert header is not None + assert isinstance(header, dict) + assert "PAYMENT-SIGNATURE" in header + assert header["PAYMENT-SIGNATURE"] is not None + assert header["PAYMENT-SIGNATURE"] != "" + + # Step 6: Verify header is base64 encoded + import base64 + import json + + header_value = header["PAYMENT-SIGNATURE"] + decoded_header = base64.b64decode(header_value).decode() + header_json = json.loads(decoded_header) + + # Step 7: Verify decoded header contains required fields + assert "x402Version" in header_json + assert header_json["x402Version"] == 2 + assert "resource" in header_json + assert header_json["resource"] is not None + assert "accepted" in header_json + assert header_json["accepted"] is not None + assert "payload" in header_json + assert header_json["payload"] is not None + assert "extension" in header_json + + # Step 8: Verify scheme and network match the x402_response + import json as json_module + + v2_payload_from_response = json_module.loads( + base64.b64decode(x402_response["headers"]["payment-required"]).decode() + ) + assert header_json["accepted"]["scheme"] == v2_payload_from_response["accepts"][0]["scheme"] + assert header_json["accepted"]["network"] == v2_payload_from_response["accepts"][0]["network"] + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_generate_payment_header_with_client_token(self): + """Test generatePaymentHeader with provided client_token. + + Verifies: + - Payment instrument created + - Payment session created + - generatePaymentHeader called with explicit client_token + - Call succeeds with provided client_token + """ + # Step 1: Create payment instrument + instrument_response = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=str(uuid.uuid4()), + ) + instrument_id = instrument_response["paymentInstrumentId"] + self.__class__.instrument_ids.append(instrument_id) + + # Step 2: Create payment session + session_response = self.manager.create_payment_session( + user_id=self.user_id, + expiry_time_in_minutes=60, + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, + client_token=str(uuid.uuid4()), + ) + session_id = session_response["paymentSessionId"] + self.__class__.session_ids.append(session_id) + + # Step 3: Create v1 X.402 response + x402_response = self.create_v1_x402_response() + + # Step 4: Call generatePaymentHeader with explicit client_token + client_token = str(uuid.uuid4()) + header = self.manager.generate_payment_header( + user_id=self.user_id, + payment_instrument_id=instrument_id, + payment_session_id=session_id, + payment_required_request=x402_response, + client_token=client_token, + ) + + # Step 5: Verify the call succeeds + assert header is not None + assert isinstance(header, dict) + assert "X-PAYMENT" in header + assert header["X-PAYMENT"] is not None + assert header["X-PAYMENT"] != "" + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_generate_payment_header_invalid_status_code(self): + """Test generatePaymentHeader with non-402 status code. + + Verifies: + - Payment instrument created + - Payment session created + - generatePaymentHeader called with x402_response statusCode != 402 + - Returns empty string or raises error without processing + """ + from bedrock_agentcore.payments.manager import PaymentError + + # Step 1: Create payment instrument + instrument_response = self.manager.create_payment_instrument( + user_id=self.user_id, + payment_connector_id=self.payment_connector_id, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": {"network": "ETHEREUM"}}, + client_token=str(uuid.uuid4()), + ) + instrument_id = instrument_response["paymentInstrumentId"] + self.__class__.instrument_ids.append(instrument_id) + + # Step 2: Create payment session + session_response = self.manager.create_payment_session( + user_id=self.user_id, + expiry_time_in_minutes=60, + limits={"maxSpendAmount": {"value": "100.00", "currency": "USD"}}, + client_token=str(uuid.uuid4()), + ) + session_id = session_response["paymentSessionId"] + self.__class__.session_ids.append(session_id) + + # Step 3: Create X.402 response with invalid status code + x402_response = self.create_v1_x402_response() + x402_response["statusCode"] = 400 # Invalid status code + + # Step 4: Call generatePaymentHeader and expect error + with pytest.raises(PaymentError) as exc_info: + self.manager.generate_payment_header( + user_id=self.user_id, + payment_instrument_id=instrument_id, + payment_session_id=session_id, + payment_required_request=x402_response, + ) + + # Step 5: Verify error message includes context + assert "402" in str(exc_info.value) + assert "400" in str(exc_info.value) + + +@pytest.mark.integration +class TestPaymentManagerAgentNameHeader: + """Integration tests for agent_name header propagation in PaymentManager. + + These tests verify that the X-Amzn-Bedrock-AgentCore-Payments-Agent-Name + header is correctly registered and injected into data-plane API calls. + """ + + @classmethod + def setup_class(cls): + """Set up test environment.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2") + default_arn = "arn:aws:bedrock:us-west-2:123456789012:payment-manager/pm-test" + cls.payment_manager_arn = os.environ.get("TEST_PAYMENT_MANAGER_ARN", default_arn) + + def test_manager_initialization_with_agent_name(self): + """Test PaymentManager stores agent_name and registers event handler.""" + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + agent_name="integ-test-agent", + ) + + assert manager._agent_name == "integ-test-agent" + assert manager._payment_client is not None + + def test_manager_initialization_without_agent_name(self): + """Test PaymentManager works without agent_name (backward compatible).""" + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + ) + + assert manager._agent_name is None + assert manager._payment_client is not None + + def test_agent_name_header_injected_into_request(self): + """Test that _add_agent_name_header injects the correct header value.""" + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + agent_name="my-payment-agent", + ) + + # Simulate a request object with a real dict for headers + class MockRequest: + def __init__(self): + self.headers = {} + + request = MockRequest() + manager._add_agent_name_header(request) + + assert "X-Amzn-Bedrock-AgentCore-Payments-Agent-Name" in request.headers + assert request.headers["X-Amzn-Bedrock-AgentCore-Payments-Agent-Name"] == "my-payment-agent" + + def test_agent_name_header_does_not_overwrite_existing_headers(self): + """Test that injecting agent_name header preserves other headers.""" + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + agent_name="my-agent", + ) + + class MockRequest: + def __init__(self): + self.headers = { + "Content-Type": "application/json", + "Authorization": "Bearer test-token", + } + + request = MockRequest() + manager._add_agent_name_header(request) + + assert request.headers["X-Amzn-Bedrock-AgentCore-Payments-Agent-Name"] == "my-agent" + assert request.headers["Content-Type"] == "application/json" + assert request.headers["Authorization"] == "Bearer test-token" + + def test_agent_name_with_boto3_session(self): + """Test agent_name works alongside boto3_session parameter.""" + session = boto3.Session(region_name=self.region) + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + boto3_session=session, + agent_name="session-agent", + ) + + assert manager._agent_name == "session-agent" + assert manager.region_name == self.region + + @pytest.mark.skipif( + not os.environ.get("TEST_PAYMENT_MANAGER_ARN"), + reason="TEST_PAYMENT_MANAGER_ARN environment variable not set", + ) + def test_agent_name_header_in_real_api_call(self): + """Test that agent_name header is sent in a real API call. + + This test makes a real list_payment_instruments call with agent_name set + and verifies the call succeeds (the service accepts the header). + """ + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + agent_name="integ-test-agent", + ) + + user_id = os.environ.get("TEST_USER_ID", f"test-user-{uuid.uuid4().hex[:8]}") + + # This call should succeed with the agent_name header injected + result = manager.list_payment_instruments(user_id=user_id) + + assert isinstance(result, dict) + assert "paymentInstruments" in result + + +# ============================================================================ +# PaymentManager Bearer Token Auth Integration Tests +# ============================================================================ + + +@pytest.mark.integration +class TestPaymentManagerBearerTokenAuth: + """Integration tests for bearer token authentication in PaymentManager. + + These tests verify that bearer_token and token_provider correctly + configure the PaymentManager for CUSTOM_JWT auth flows. + + The test class creates its own Cognito User Pool, Resource Server, App Client, + and CUSTOM_JWT Payment Manager during setup, and tears them all down after. + No external configuration required beyond AWS credentials and BEDROCK_TEST_REGION. + """ + + SCOPE_NAME = "payments-api/invoke" + RESOURCE_SERVER_ID = "payments-api" + + @classmethod + def setup_class(cls): + """Create Cognito + CUSTOM_JWT Payment Manager infrastructure for testing.""" + cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-east-1") + cls._cognito_client = boto3.client("cognito-idp", region_name=cls.region) + cls._created_resources = {} + + try: + cls._create_test_infrastructure() + except Exception as e: + cls._cleanup_resources() + raise RuntimeError(f"Failed to create bearer token test infrastructure: {e}") from e + + @classmethod + def _create_test_infrastructure(cls): + """Create Cognito User Pool, Resource Server, App Client, and Payment Manager.""" + import json + import time + + from bedrock_agentcore.payments.client import PaymentClient + + run_id = uuid.uuid4().hex[:8] + + # 1. Create Cognito User Pool + pool_resp = cls._cognito_client.create_user_pool( + PoolName=f"payments-integ-test-{run_id}", + Policies={"PasswordPolicy": {"MinimumLength": 8}}, + ) + cls._created_resources["user_pool_id"] = pool_resp["UserPool"]["Id"] + + # 2. Create Resource Server (defines the OAuth scope) + cls._cognito_client.create_resource_server( + UserPoolId=cls._created_resources["user_pool_id"], + Identifier=cls.RESOURCE_SERVER_ID, + Name="Payments API", + Scopes=[{"ScopeName": "invoke", "ScopeDescription": "Invoke payment operations"}], + ) + + # 3. Create App Client with client_credentials grant + client_resp = cls._cognito_client.create_user_pool_client( + UserPoolId=cls._created_resources["user_pool_id"], + ClientName=f"payments-test-client-{run_id}", + GenerateSecret=True, + AllowedOAuthFlows=["client_credentials"], + AllowedOAuthScopes=[f"{cls.RESOURCE_SERVER_ID}/invoke"], + AllowedOAuthFlowsUserPoolClient=True, + ) + cls.client_id = client_resp["UserPoolClient"]["ClientId"] + cls.client_secret = client_resp["UserPoolClient"]["ClientSecret"] + cls._created_resources["client_id"] = cls.client_id + + # 4. Create/get domain for token endpoint + domain_prefix = f"payments-test-{run_id}" + cls._cognito_client.create_user_pool_domain( + Domain=domain_prefix, + UserPoolId=cls._created_resources["user_pool_id"], + ) + cls._created_resources["domain_prefix"] = domain_prefix + cls.token_url = f"https://{domain_prefix}.auth.{cls.region}.amazoncognito.com/oauth2/token" + cls.scope = f"{cls.RESOURCE_SERVER_ID}/invoke" + + # 5. Build the discovery URL (OIDC issuer) for CUSTOM_JWT authorizer + discovery_url = ( + f"https://cognito-idp.{cls.region}.amazonaws.com/{cls._created_resources['user_pool_id']}" + "/.well-known/openid-configuration" + ) + + # 6. Create CUSTOM_JWT Payment Manager via PaymentClient + payment_client = PaymentClient(region_name=cls.region) + + # Create IAM role for the payment manager with required trust + permissions + iam = boto3.client("iam", region_name=cls.region) + sts = boto3.client("sts", region_name=cls.region) + account_id = sts.get_caller_identity()["Account"] + role_name = f"bearertest{run_id}Role" + + trust_policy = json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "bedrock-agentcore.amazonaws.com", + "preprod.genesis-service.aws.internal", + "developer.genesis-service.aws.internal", + ] + }, + "Action": "sts:AssumeRole", + } + ], + } + ) + + try: + role_resp = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=trust_policy, + Description="Temp role for bearer token integ test", + ) + role_arn = role_resp["Role"]["Arn"] + except iam.exceptions.EntityAlreadyExistsException: + role_arn = f"arn:aws:iam::{account_id}:role/{role_name}" + + cls._created_resources["iam_role_name"] = role_name + + # Attach required permissions for resource retrieval + iam.put_role_policy( + RoleName=role_name, + PolicyName="AllowResourceRetrieval", + PolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:*", + "Resource": [ + f"arn:aws:bedrock-agentcore:*:{account_id}:token-vault/default", + f"arn:aws:bedrock-agentcore:*:{account_id}:token-vault/default/*", + f"arn:aws:bedrock-agentcore:*:{account_id}:workload-identity-directory/default", + f"arn:aws:bedrock-agentcore:*:{account_id}:workload-identity-directory/default/workload-identity/*", + ], + }, + { + "Effect": "Allow", + "Action": "secretsmanager:GetSecretValue", + "Resource": f"arn:aws:secretsmanager:*:{account_id}:secret:*", + }, + { + "Effect": "Allow", + "Action": "sts:SetContext", + "Resource": f"arn:aws:sts::{account_id}:self", + }, + ], + } + ), + ) + + # Wait for Cognito domain + IAM role to propagate + time.sleep(15) + + manager_resp = payment_client.create_payment_manager( + name=f"bearertest{run_id}", + role_arn=role_arn, + authorizer_type="CUSTOM_JWT", + authorizer_configuration={ + "customJWTAuthorizer": { + "discoveryUrl": discovery_url, + "allowedClients": [cls.client_id], + } + }, + description="Created for bearer token integ tests", + wait_for_ready=True, + max_wait=120, + ) + cls.payment_manager_arn = manager_resp["paymentManagerArn"] + cls._created_resources["payment_manager_id"] = manager_resp["paymentManagerId"] + + @classmethod + def teardown_class(cls): + """Tear down all created test infrastructure.""" + cls._cleanup_resources() + + @classmethod + def _cleanup_resources(cls): + """Best-effort cleanup of all created resources in reverse order.""" + + from bedrock_agentcore.payments.client import PaymentClient + + # Delete payment manager + if "payment_manager_id" in cls._created_resources: + try: + payment_client = PaymentClient(region_name=cls.region) + payment_client.delete_payment_manager(cls._created_resources["payment_manager_id"]) + except Exception as e: + print(f"Warning: failed to delete payment manager: {e}") + + # Delete Cognito domain + if "domain_prefix" in cls._created_resources and "user_pool_id" in cls._created_resources: + try: + cls._cognito_client.delete_user_pool_domain( + Domain=cls._created_resources["domain_prefix"], + UserPoolId=cls._created_resources["user_pool_id"], + ) + except Exception as e: + print(f"Warning: failed to delete Cognito domain: {e}") + + # Delete Cognito User Pool (cascades resource server + app client) + if "user_pool_id" in cls._created_resources: + try: + cls._cognito_client.delete_user_pool( + UserPoolId=cls._created_resources["user_pool_id"], + ) + except Exception as e: + print(f"Warning: failed to delete Cognito user pool: {e}") + + # Delete IAM role (must remove inline policy first) + if "iam_role_name" in cls._created_resources: + try: + iam = boto3.client("iam", region_name=cls.region) + iam.delete_role_policy( + RoleName=cls._created_resources["iam_role_name"], + PolicyName="AllowResourceRetrieval", + ) + iam.delete_role(RoleName=cls._created_resources["iam_role_name"]) + except Exception as e: + print(f"Warning: failed to delete IAM role: {e}") + + @staticmethod + def _fetch_cognito_token(token_url: str, client_id: str, client_secret: str, scope: str) -> str: + """Fetch a JWT from Cognito using client_credentials grant.""" + import base64 + import urllib.request + + credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + data = f"grant_type=client_credentials&scope={scope}".encode() + req = urllib.request.Request( + token_url, + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {credentials}", + }, + ) + import json + + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read())["access_token"] + + def test_manager_with_static_bearer_token(self): + """Test PaymentManager initialization with static bearer_token.""" + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + bearer_token="test-static-token", + ) + assert manager._bearer_token == "test-static-token" + assert manager._token_provider is None + + def test_manager_with_token_provider(self): + """Test PaymentManager initialization with token_provider callable.""" + call_count = {"n": 0} + + def provider(): + call_count["n"] += 1 + return f"token-{call_count['n']}" + + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + token_provider=provider, + ) + assert manager._token_provider is not None + assert manager._bearer_token is None + + def test_mutual_exclusivity(self): + """Test that bearer_token and token_provider cannot both be set.""" + with pytest.raises(ValueError, match="mutually exclusive"): + PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + bearer_token="token", + token_provider=lambda: "token", + ) + + def test_bearer_with_agent_name(self): + """Test bearer_token works alongside agent_name.""" + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + bearer_token="my-jwt", + agent_name="test-agent", + ) + assert manager._bearer_token == "my-jwt" + assert manager._agent_name == "test-agent" + + def test_bearer_does_not_inject_user_id_header(self): + """Test that bearer mode does NOT inject user_id header — service derives it from JWT sub.""" + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + bearer_token="my-jwt", + ) + + class MockRequest: + def __init__(self): + self.headers = {} + + request = MockRequest() + manager._inject_bearer_token(request) + assert request.headers["Authorization"] == "Bearer my-jwt" + assert "X-Amzn-Bedrock-AgentCore-Payments-User-Id" not in request.headers + + @staticmethod + def _retry_with_backoff(fn, max_attempts=5, initial_delay=5): + """Retry a callable with exponential backoff for propagation delays.""" + import time + + for attempt in range(max_attempts): + try: + return fn() + except Exception: + if attempt == max_attempts - 1: + raise + time.sleep(initial_delay * (2**attempt)) + + def test_bearer_token_real_api_call(self): + """Test a real API call using bearer token from Cognito. + + This test fetches a JWT from Cognito and uses it to call the payments + data-plane API. Requires a CUSTOM_JWT payment manager and Cognito credentials. + """ + token = self._fetch_cognito_token(self.token_url, self.client_id, self.client_secret, self.scope) + + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + bearer_token=token, + ) + + # Retry with backoff — OIDC discovery propagation may lag behind READY status + result = self._retry_with_backoff(lambda: manager.list_payment_instruments(user_id="ignored")) + + assert isinstance(result, dict) + assert "paymentInstruments" in result + + def test_token_provider_real_api_call(self): + """Test a real API call using token_provider with Cognito. + + Verifies that the token_provider callable is invoked and the resulting + token is used for authentication. + """ + call_count = {"n": 0} + + def cognito_provider(): + call_count["n"] += 1 + return self._fetch_cognito_token(self.token_url, self.client_id, self.client_secret, self.scope) + + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + token_provider=cognito_provider, + ) + + result = self._retry_with_backoff(lambda: manager.list_payment_instruments(user_id="ignored")) + + assert isinstance(result, dict) + assert "paymentInstruments" in result + assert call_count["n"] >= 1 + + def test_bearer_create_session_real_api_call(self): + """Test creating a payment session using bearer token auth. + + Verifies that createPaymentSession succeeds with bearer auth and + the service derives userId from the JWT sub claim. + """ + token = self._fetch_cognito_token(self.token_url, self.client_id, self.client_secret, self.scope) + + manager = PaymentManager( + payment_manager_arn=self.payment_manager_arn, + region_name=self.region, + bearer_token=token, + ) + + result = self._retry_with_backoff( + lambda: manager.create_payment_session( + user_id="ignored", + expiry_time_in_minutes=15, + limits={"maxSpendAmount": {"value": "1000", "currency": "USD"}}, + ) + ) + + assert isinstance(result, dict) + assert "paymentSessionId" in result + assert result.get("userId") == self.client_id diff --git a/uv.lock b/uv.lock index 02ea8b90..0480241c 100644 --- a/uv.lock +++ b/uv.lock @@ -330,7 +330,7 @@ requires-dist = [ { name = "jinja2", marker = "extra == 'simulation'", specifier = ">=3.1.0" }, { name = "pydantic", specifier = ">=2.0.0,<2.41.3" }, { name = "starlette", specifier = ">=0.46.2" }, - { name = "strands-agents", marker = "extra == 'strands-agents'", specifier = ">=1.1.0" }, + { name = "strands-agents", marker = "extra == 'strands-agents'", specifier = ">=1.20.0" }, { name = "strands-agents-evals", marker = "extra == 'simulation'", specifier = ">=0.1.0" }, { name = "strands-agents-evals", marker = "extra == 'strands-agents-evals'", specifier = ">=0.1.0" }, { name = "typing-extensions", specifier = ">=4.13.2,<5.0.0" }, @@ -354,7 +354,7 @@ dev = [ { name = "pytest-order", specifier = ">=1.3.0" }, { name = "pytest-rerunfailures", specifier = ">=15.0" }, { name = "ruff", specifier = ">=0.12.0" }, - { name = "strands-agents", specifier = ">=1.18.0" }, + { name = "strands-agents", specifier = ">=1.20.0" }, { name = "strands-agents-evals", specifier = ">=0.1.0" }, { name = "websockets", specifier = ">=14.1" }, { name = "wheel", specifier = ">=0.45.1" }, @@ -362,30 +362,30 @@ dev = [ [[package]] name = "boto3" -version = "1.43.1" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/36/028c12ed6ed85009a21b5472eb76c27f9b0341c6986f06f83475b40aaf51/boto3-1.43.1.tar.gz", hash = "sha256:9e4f85a7884797ff0f52c257094730ed228aaa07fa8134775ff8f86909cf4f2a", size = 113175, upload-time = "2026-04-30T20:27:04.569Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/d1/b8b2d5420c51cd8f7ec044ceecbf24b060156680b26519e1d482e160c3c8/boto3-1.43.1-py3-none-any.whl", hash = "sha256:3840bf0345b9aefcc5915176a19d227f63cfba7778c65e6e52d61c6ea0a10fdc", size = 140498, upload-time = "2026-04-30T20:27:01.791Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" }, ] [[package]] name = "botocore" -version = "1.43.1" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/b7/416ae6f1461d6fec3b3aaffc4759371319c71a21f7ab4c3106ee574fda8d/botocore-1.43.1.tar.gz", hash = "sha256:270d6357d662550fdb84973ec247e02bece0b6283d90bf37319c7753515336e4", size = 15296915, upload-time = "2026-04-30T20:26:50.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/48/dc2290d2af8b1dc3a44d210555a90f0cb76ef913c52b0c4f31a43cce27b8/botocore-1.43.1-py3-none-any.whl", hash = "sha256:955edc6a398b9c4100cf0d5a31433fdba3835500bf38c1ef171e6e75f4b477d2", size = 14979119, upload-time = "2026-04-30T20:26:46.031Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, ] [[package]] @@ -2400,7 +2400,7 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.18.0" +version = "1.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -2412,12 +2412,13 @@ dependencies = [ { name = "opentelemetry-instrumentation-threading" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, + { name = "pyyaml" }, { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/58/08665fc8d5330fc32727793c39ad019f6cc3f75272ede3629dd67b4fe6a5/strands_agents-1.18.0.tar.gz", hash = "sha256:85bd251d50de5d441cc2578068b4656ca86bdbaa1764e292d56b0edf0cf54c52", size = 521216, upload-time = "2025-11-21T21:30:26.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/89/3e722f4b5bd913531bc32a23bf88aaa77a434774f294bba5bfa88690ec46/strands_agents-1.38.0.tar.gz", hash = "sha256:02a68ec321ad457f9137dfd6a99cf72cf0e86081fee35de85fbe29b9ac0af2b2", size = 858950, upload-time = "2026-04-30T16:57:43.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/37/79c63bf7ac194754748fc42b5c9fb8f964dc742d92c19052e6cc74fede39/strands_agents-1.18.0-py3-none-any.whl", hash = "sha256:c9cf9662b24344413d204a7ee60d3e3ab88791568e9f572020901eb3df9b6b5e", size = 255594, upload-time = "2025-11-21T21:30:24.662Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/de8d8ab14a2e92dcb0fa82db0a4cb102418a1eda139412bbe5b5725e28df/strands_agents-1.38.0-py3-none-any.whl", hash = "sha256:9dc3de17e25d70e367d37f9151f2a4c7b3ac8fc9f6237e9e1f34d00bfbfd001b", size = 422354, upload-time = "2026-04-30T16:57:41.094Z" }, ] [[package]]