From 1ee3983cb698561bfe895cb3d3a817ea603c2a6a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 20:02:51 +0000 Subject: [PATCH 01/10] feat(payment): add mutation core and owned IAM provisioning --- src/core/harness.tsx | 23 +- src/core/index.tsx | 4 +- src/core/payment.read.test.ts | 16 +- src/core/payment.test.ts | 664 ++++++++++++++++++++++++++++ src/core/payment.tsx | 336 +++++++++++++- src/core/paymentServiceRole.test.ts | 210 +++++++++ src/core/paymentServiceRole.ts | 155 +++++++ src/core/roleRetry.ts | 27 ++ src/handlers/payment/types.tsx | 101 +++++ src/testing/TestCoreClient.tsx | 181 ++++++++ 10 files changed, 1691 insertions(+), 26 deletions(-) create mode 100644 src/core/payment.test.ts create mode 100644 src/core/paymentServiceRole.test.ts create mode 100644 src/core/paymentServiceRole.ts create mode 100644 src/core/roleRetry.ts diff --git a/src/core/harness.tsx b/src/core/harness.tsx index 0d88822ec..5356ec0d3 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -44,6 +44,7 @@ import { InputValidationError } from "../errors"; import type { AwsClients, CoreOptions } from "./types"; import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; +import { retryWhileRoleUnassumable } from "./roleRetry"; import { toClientConfig } from "./utils"; // HarnessClient implements the harness-facing operations on top of the shared AWS @@ -243,25 +244,3 @@ export function harnessRuntimeFromResponse( runtimeName: runtime.agentRuntimeName, }; } - -// retryWhileRoleUnassumable retries `operation` while it fails with the -// validation error AgentCore raises for an execution role it cannot yet assume -// (fresh IAM roles propagate over several seconds). Any other failure — or -// exhausting the attempts — rethrows. -async function retryWhileRoleUnassumable( - operation: () => Promise, - attempts = 8, - delayMs = 2000, -): Promise { - for (let attempt = 1; ; attempt++) { - try { - return await operation(); - } catch (error) { - const retryable = - (error as Error).name === "ValidationException" && - /role|assume|trust/i.test((error as Error).message ?? ""); - if (!retryable || attempt >= attempts) throw error; - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } -} diff --git a/src/core/index.tsx b/src/core/index.tsx index bff250bd0..f9f6c21c8 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -103,7 +103,9 @@ export class CoreClient implements AwsClients { ); this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" })); this.policy = new PolicyClient(this, this.logger.child({ module: "policy" })); - this.payment = new PaymentClient(this); + // Payment connectors resolve their credential provider through identity, so + // PaymentClient borrows the identity sub-client alongside the shared AWS clients. + this.payment = new PaymentClient(this, this.identity); // EvalClient shares the injected fetch: dataset content is served from a // presigned S3 URL, outside the SDK seam the other operations use. The logger // is used for batch-evaluation result-log diagnostics. diff --git a/src/core/payment.read.test.ts b/src/core/payment.read.test.ts index b9b44be8f..830ca0dbc 100644 --- a/src/core/payment.read.test.ts +++ b/src/core/payment.read.test.ts @@ -73,7 +73,21 @@ function paymentClient(sends: { control?: Send; data?: Send } = {}) { const data = mock( (_config: ClientConfig) => ({ send: dataSend }) as unknown as ReturnType, ); - return { client: new PaymentClient({ control, data }), control, data, controlSend, dataSend }; + const client = new PaymentClient( + { + control, + data, + iam: () => { + throw new Error("unexpected IAM client"); + }, + }, + { + getPaymentCredentialProvider: async () => { + throw new Error("unexpected provider lookup"); + }, + }, + ); + return { client, control, data, controlSend, dataSend }; } function serviceError(name: string, message: string): Error { diff --git a/src/core/payment.test.ts b/src/core/payment.test.ts new file mode 100644 index 000000000..e71be905e --- /dev/null +++ b/src/core/payment.test.ts @@ -0,0 +1,664 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + CreatePaymentConnectorCommand, + CreatePaymentManagerCommand, + DeletePaymentManagerCommand, + GetPaymentConnectorCommand, + GetPaymentManagerCommand, + UpdatePaymentConnectorCommand, + type GetPaymentCredentialProviderResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreatePaymentInstrumentCommand, + CreatePaymentSessionCommand, + DeletePaymentInstrumentCommand, + DeletePaymentSessionCommand, + GetPaymentInstrumentBalanceCommand, + GetPaymentInstrumentCommand, + GetPaymentSessionCommand, + ListPaymentInstrumentsCommand, + ListPaymentSessionsCommand, +} from "@aws-sdk/client-bedrock-agentcore"; +import { CreateRoleCommand, GetRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam"; +import { ERROR_SOURCE, InputValidationError } from "../errors"; +import type { CoreIdentityClient } from "../handlers/identity/types"; +import { PaymentClient } from "./payment"; +import type { AwsClients } from "./types"; + +const options = { region: "us-west-2" }; +const ACCOUNT = "123456789012"; +const DEFAULT_ROLE_ARN = `arn:aws:iam::${ACCOUNT}:role/AgentCorePayments-us-west-2-Checkout`; +const MANAGER_ID = "checkout-abc1234567"; +const MANAGER_ARN = `arn:aws:bedrock-agentcore:us-west-2:${ACCOUNT}:payment-manager/${MANAGER_ID}`; +const PROVIDER_ARN = `arn:aws:bedrock-agentcore:us-west-2:${ACCOUNT}:token-vault/default/paymentcredentialprovider/cdp-creds`; + +interface SdkCommand { + constructor: { name: string }; + input: unknown; +} +type Send = (command: SdkCommand) => Promise; + +const unexpected: Send = async (command) => { + throw new Error(`unexpected ${command.constructor.name}`); +}; + +// paymentClient wires a PaymentClient over fake SDK clients whose `.send()` is +// the supplied function, plus a partial identity client for name resolution. +function paymentClient( + sends: { control?: Send; data?: Send; iam?: Send }, + identity: Partial = {}, +): PaymentClient { + const client = + (send: Send = unexpected) => + () => + ({ send: mock(send) }) as never; + return new PaymentClient( + { + control: client(sends.control), + data: client(sends.data), + iam: client(sends.iam), + } as unknown as AwsClients, + identity as CoreIdentityClient, + ); +} + +function serviceError(name: string, message: string, extra: Record = {}): Error { + const error = new Error(message); + error.name = name; + Object.assign(error, extra); + return error; +} + +function coinbaseProvider(name: string): GetPaymentCredentialProviderResponse { + return { + name, + credentialProviderArn: PROVIDER_ARN, + credentialProviderVendor: "CoinbaseCDP", + } as GetPaymentCredentialProviderResponse; +} + +describe("PaymentClient manager", () => { + test("createPaymentManager passes an explicit role through without touching IAM", async () => { + const client = paymentClient({ + control: async (command) => { + expect(command).toBeInstanceOf(CreatePaymentManagerCommand); + expect(command.input).toEqual({ + name: "Checkout", + authorizerType: "AWS_IAM", + roleArn: "arn:aws:iam::123456789012:role/MyRole", + }); + return { paymentManagerId: MANAGER_ID }; + }, + }); + + await expect( + client.createPaymentManager( + { + name: "Checkout", + authorizerType: "AWS_IAM", + roleArn: "arn:aws:iam::123456789012:role/MyRole", + }, + options, + ), + ).resolves.toMatchObject({ paymentManagerId: MANAGER_ID }); + }); + + test("createPaymentManager provisions the default service role when none is given", async () => { + const iamCalls: string[] = []; + const client = paymentClient({ + iam: async (command) => { + iamCalls.push(command.constructor.name); + if (command instanceof GetRoleCommand) { + throw serviceError("NoSuchEntityException", "role does not exist"); + } + if (command instanceof CreateRoleCommand) { + expect(command.input).toMatchObject({ RoleName: "AgentCorePayments-us-west-2-Checkout" }); + return { Role: { Arn: DEFAULT_ROLE_ARN } }; + } + if (command instanceof PutRolePolicyCommand) return {}; + throw new Error(`unexpected ${command.constructor.name}`); + }, + control: async (command) => { + expect(command).toBeInstanceOf(CreatePaymentManagerCommand); + expect(command.input).toEqual({ + name: "Checkout", + authorizerType: "AWS_IAM", + roleArn: DEFAULT_ROLE_ARN, + }); + return { paymentManagerId: MANAGER_ID }; + }, + }); + + await client.createPaymentManager({ name: "Checkout", authorizerType: "AWS_IAM" }, options); + expect(iamCalls).toEqual(["GetRoleCommand", "CreateRoleCommand", "PutRolePolicyCommand"]); + }); + + // IAM's own denial message ("assumed-role/... is not authorized") mentions a role + // too, so the propagation retry must key on the provisioned role, not on the + // word. An under-privileged caller gets the real error on the first attempt. + test("createPaymentManager does not retry a caller's own access denial", async () => { + let creates = 0; + const client = paymentClient({ + iam: async (command) => { + if (command instanceof GetRoleCommand) { + return { + Role: { + Arn: DEFAULT_ROLE_ARN, + Tags: [ + { Key: "agentcore:managed-by", Value: "agentcore-cli" }, + { Key: "agentcore:payment-manager", Value: "Checkout" }, + { Key: "agentcore:region", Value: options.region }, + ], + }, + }; + } + return {}; + }, + control: async () => { + creates++; + throw serviceError( + "AccessDeniedException", + `User: arn:aws:sts::${ACCOUNT}:assumed-role/Admin/session is not authorized to perform: bedrock-agentcore:CreatePaymentManager`, + ); + }, + }); + + await expect( + client.createPaymentManager({ name: "Checkout", authorizerType: "AWS_IAM" }, options), + ).rejects.toMatchObject({ name: "AccessDeniedException" }); + expect(creates).toBe(1); + }); + + test("default role provisioning retains explicit credentials but not the AgentCore endpoint", async () => { + const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; + const iam = mock(() => { + throw new Error("captured IAM configuration"); + }); + const control = mock(() => ({ send: unexpected })); + const client = new PaymentClient({ iam, control } as unknown as AwsClients, { + getPaymentCredentialProvider: async () => coinbaseProvider("unused"), + }); + await expect( + client.createPaymentManager( + { name: "Checkout", authorizerType: "AWS_IAM" }, + { region: options.region, endpointUrl: "https://example.test/control", credentials }, + ), + ).rejects.toThrow("captured IAM configuration"); + expect(iam).toHaveBeenCalledWith({ region: options.region, credentials }); + expect(control).toHaveBeenCalledWith({ + region: options.region, + endpoint: "https://example.test/control", + credentials, + }); + }); + + test("deletePaymentManager forwards the id and client token", async () => { + const client = paymentClient({ + control: async (command) => { + expect(command).toBeInstanceOf(DeletePaymentManagerCommand); + expect(command.input).toEqual({ paymentManagerId: MANAGER_ID, clientToken: "tok" }); + return { status: "DELETING", paymentManagerId: MANAGER_ID }; + }, + }); + + await expect( + client.deletePaymentManager({ paymentManagerId: MANAGER_ID, clientToken: "tok" }, options), + ).resolves.toEqual({ status: "DELETING", paymentManagerId: MANAGER_ID }); + }); +}); + +describe("PaymentClient connector create", () => { + test("Quick Create sends an empty credential list and the QUICK_CREATE provision mode", async () => { + const client = paymentClient({ + control: async (command) => { + expect(command).toBeInstanceOf(CreatePaymentConnectorCommand); + expect(command.input).toEqual({ + paymentManagerId: MANAGER_ID, + name: "Coinbase", + type: "CoinbaseCDP", + credentialProviderConfigurations: [], + provisionMode: "QUICK_CREATE", + }); + return { paymentConnectorId: "coinbase-xyz", status: "PENDING_AUTHENTICATION" }; + }, + }); + + await expect( + client.createPaymentConnector( + { managerId: MANAGER_ID, name: "Coinbase", quickCreate: true }, + options, + ), + ).resolves.toMatchObject({ status: "PENDING_AUTHENTICATION" }); + }); + + test("Quick Create rejects any type other than CoinbaseCDP before calling the service", async () => { + const client = paymentClient({}); + await expect( + client.createPaymentConnector( + { managerId: MANAGER_ID, name: "Privy", quickCreate: true, type: "StripePrivy" }, + options, + ), + ).rejects.toBeInstanceOf(InputValidationError); + }); + + test("a credential provider named by name is resolved through identity and sets the type from its vendor", async () => { + const identity = { + getPaymentCredentialProvider: mock(async (name: string) => coinbaseProvider(name)), + }; + const client = paymentClient( + { + control: async (command) => { + expect(command).toBeInstanceOf(CreatePaymentConnectorCommand); + expect(command.input).toEqual({ + paymentManagerId: MANAGER_ID, + name: "Coinbase", + description: "manual", + type: "CoinbaseCDP", + credentialProviderConfigurations: [ + { coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }, + ], + provisionMode: undefined, + }); + return { paymentConnectorId: "coinbase-xyz", status: "CREATING" }; + }, + }, + identity, + ); + + await client.createPaymentConnector( + { + managerId: MANAGER_ID, + name: "Coinbase", + description: "manual", + credentialProvider: "cdp-creds", + }, + options, + ); + expect(identity.getPaymentCredentialProvider).toHaveBeenCalledWith("cdp-creds", options); + }); + + test("a credential provider ARN requires an explicit type and selects the matching union member", async () => { + const client = paymentClient({ + control: async (command) => { + expect(command.input).toMatchObject({ + type: "StripePrivy", + credentialProviderConfigurations: [ + { stripePrivy: { credentialProviderArn: PROVIDER_ARN } }, + ], + }); + return { status: "CREATING" }; + }, + }); + + await expect( + client.createPaymentConnector( + { managerId: MANAGER_ID, name: "Privy", credentialProvider: PROVIDER_ARN }, + options, + ), + ).rejects.toThrow(/--type/); + + await client.createPaymentConnector( + { + managerId: MANAGER_ID, + name: "Privy", + credentialProvider: PROVIDER_ARN, + type: "StripePrivy", + }, + options, + ); + }); + + test("an explicit type that contradicts the provider's vendor is rejected", async () => { + const client = paymentClient( + {}, + { getPaymentCredentialProvider: async (name: string) => coinbaseProvider(name) }, + ); + await expect( + client.createPaymentConnector( + { + managerId: MANAGER_ID, + name: "Mismatch", + credentialProvider: "cdp-creds", + type: "StripePrivy", + }, + options, + ), + ).rejects.toThrow(/CoinbaseCDP/); + }); + + test("neither Quick Create nor a credential provider is an input error", async () => { + const client = paymentClient({}); + await expect( + client.createPaymentConnector({ managerId: MANAGER_ID, name: "Nothing" }, options), + ).rejects.toBeInstanceOf(InputValidationError); + }); + + test("a Marketplace subscription failure surfaces the product and subscription URL", async () => { + const client = paymentClient({ + control: async () => { + throw serviceError("SubscriptionRequiredException", "Subscription required", { + subscriptionUrl: "https://aws.amazon.com/marketplace/pp/prodview-example", + productName: "Coinbase Wallets for AgentCore Payments", + }); + }, + }); + + const failure = client.createPaymentConnector( + { managerId: MANAGER_ID, name: "Coinbase", quickCreate: true }, + options, + ); + await expect(failure).rejects.toThrow(/Coinbase Wallets for AgentCore Payments/); + await expect(failure).rejects.toThrow(/prodview-example/); + await expect(failure).rejects.toMatchObject({ + name: "SubscriptionRequiredException", + source: ERROR_SOURCE.USER, + }); + }); +}); + +describe("PaymentClient connector update", () => { + test("replacing the credential provider reads the connector type to pick the union member", async () => { + const sent: string[] = []; + const client = paymentClient( + { + control: async (command) => { + sent.push(command.constructor.name); + if (command instanceof GetPaymentConnectorCommand) { + expect(command.input).toEqual({ + paymentManagerId: MANAGER_ID, + paymentConnectorId: "coinbase-xyz", + }); + return { type: "CoinbaseCDP" }; + } + expect(command).toBeInstanceOf(UpdatePaymentConnectorCommand); + expect(command.input).toEqual({ + paymentManagerId: MANAGER_ID, + paymentConnectorId: "coinbase-xyz", + description: "rotated", + credentialProviderConfigurations: [ + { coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }, + ], + clientToken: undefined, + }); + return { status: "UPDATING" }; + }, + }, + { getPaymentCredentialProvider: async (name: string) => coinbaseProvider(name) }, + ); + + await client.updatePaymentConnector( + { + managerId: MANAGER_ID, + connectorId: "coinbase-xyz", + description: "rotated", + credentialProvider: "cdp-creds", + }, + options, + ); + expect(sent).toEqual(["GetPaymentConnectorCommand", "UpdatePaymentConnectorCommand"]); + }); + + test("a description-only update sends no credential configuration and skips the lookup", async () => { + const client = paymentClient({ + control: async (command) => { + expect(command).toBeInstanceOf(UpdatePaymentConnectorCommand); + expect(command.input).toEqual({ + paymentManagerId: MANAGER_ID, + paymentConnectorId: "coinbase-xyz", + description: "renamed", + credentialProviderConfigurations: undefined, + clientToken: undefined, + }); + return { status: "UPDATING" }; + }, + }); + + await client.updatePaymentConnector( + { managerId: MANAGER_ID, connectorId: "coinbase-xyz", description: "renamed" }, + options, + ); + }); +}); + +describe("PaymentClient data plane", () => { + test("rejects a manager ARN used as an ID before any SDK call", async () => { + const client = paymentClient({}); + await expect( + client.listPaymentSessions({ managerId: MANAGER_ARN, userId: "alice" }, options), + ).rejects.toThrow(/manager ID, not an ARN/); + }); + + test("createPaymentSession resolves the manager ID before sending the request", async () => { + const request = { + managerId: MANAGER_ID, + userId: "alice", + expiryTimeInMinutes: 60, + limits: { maxSpendAmount: { value: "10.00", currency: "USD" as const } }, + }; + const sent: string[] = []; + const client = paymentClient({ + control: async (command) => { + sent.push(command.constructor.name); + expect(command).toBeInstanceOf(GetPaymentManagerCommand); + expect(command.input).toEqual({ paymentManagerId: MANAGER_ID }); + return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; + }, + data: async (command) => { + sent.push(command.constructor.name); + expect(command).toBeInstanceOf(CreatePaymentSessionCommand); + const { managerId: _managerId, ...rest } = request; + expect(command.input).toEqual({ ...rest, paymentManagerArn: MANAGER_ARN }); + return { paymentSession: { paymentSessionId: "session-1" } }; + }, + }); + + await expect(client.createPaymentSession(request, options)).resolves.toMatchObject({ + paymentSession: { paymentSessionId: "session-1" }, + }); + expect(sent).toEqual(["GetPaymentManagerCommand", "CreatePaymentSessionCommand"]); + }); + + test("a CUSTOM_JWT manager is rejected before contacting the data plane", async () => { + let dataCalls = 0; + const client = paymentClient({ + data: async () => { + dataCalls++; + return {}; + }, + control: async (command) => { + expect(command).toBeInstanceOf(GetPaymentManagerCommand); + expect(command.input).toEqual({ paymentManagerId: MANAGER_ID }); + return { paymentManagerArn: MANAGER_ARN, authorizerType: "CUSTOM_JWT" }; + }, + }); + + const failure = client.listPaymentSessions({ managerId: MANAGER_ID, userId: "alice" }, options); + await expect(failure).rejects.toThrow(new RegExp(`${MANAGER_ID}.*CUSTOM_JWT`)); + await expect(failure).rejects.toThrow(/bearer token/); + await expect(failure).rejects.toMatchObject({ source: ERROR_SOURCE.USER }); + expect(dataCalls).toBe(0); + }); + + test.each(["ResourceNotFoundException", "AccessDeniedException"])( + "a manager lookup %s is preserved and prevents the data-plane call", + async (name) => { + const error = serviceError(name, "GetPaymentManager failed"); + let dataCalls = 0; + const client = paymentClient({ + control: async () => { + throw error; + }, + data: async () => { + dataCalls++; + return {}; + }, + }); + await expect( + client.listPaymentSessions({ managerId: MANAGER_ID, userId: "alice" }, options), + ).rejects.toBe(error); + expect(dataCalls).toBe(0); + }, + ); + + test.each(["AccessDeniedException", "ValidationException", "ThrottlingException"])( + "a data-plane %s is preserved without a second manager lookup", + async (name) => { + const error = serviceError(name, "data-plane failure"); + let lookups = 0; + const client = paymentClient({ + control: async () => { + lookups++; + return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; + }, + data: async () => { + throw error; + }, + }); + await expect( + client.listPaymentSessions({ managerId: MANAGER_ID, userId: "alice" }, options), + ).rejects.toBe(error); + expect(lookups).toBe(1); + }, + ); + + test("a manager response without an ARN fails before data-plane access", async () => { + let dataCalls = 0; + const client = paymentClient({ + control: async () => ({ authorizerType: "AWS_IAM" }), + data: async () => { + dataCalls++; + return {}; + }, + }); + await expect(client.listPaymentSessions({ managerId: MANAGER_ID }, options)).rejects.toThrow( + /ARN/, + ); + expect(dataCalls).toBe(0); + }); + + test("list requests reach the data plane with pagination intact", async () => { + const client = paymentClient({ + control: async () => ({ paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }), + data: async (command) => { + expect(command).toBeInstanceOf(ListPaymentSessionsCommand); + expect(command.input).toEqual({ + paymentManagerArn: MANAGER_ARN, + userId: "alice", + nextToken: "page-2", + maxResults: 5, + }); + return { paymentSessions: [], nextToken: undefined }; + }, + }); + + await client.listPaymentSessions( + { managerId: MANAGER_ID, userId: "alice", nextToken: "page-2", maxResults: 5 }, + options, + ); + }); + + const scoped = { managerId: MANAGER_ID, userId: "alice" }; + const session = { ...scoped, paymentSessionId: "session-1" }; + const instrument = { + ...scoped, + paymentConnectorId: "connector-1", + paymentInstrumentId: "instrument-1", + }; + const wallet = { + ...scoped, + paymentConnectorId: "connector-1", + paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET" as const, + paymentInstrumentDetails: { + embeddedCryptoWallet: { + network: "ETHEREUM" as const, + linkedAccounts: [{ email: { emailAddress: "alice@example.test" } }], + }, + }, + }; + const balance = { ...instrument, chain: "BASE_SEPOLIA" as const, token: "USDC" as const }; + const instrumentList = { + ...scoped, + paymentConnectorId: "connector-1", + nextToken: "page-2", + maxResults: 2, + }; + test.each([ + { + command: GetPaymentSessionCommand, + input: session, + run: (c: PaymentClient) => c.getPaymentSession(session, options), + }, + { + command: DeletePaymentSessionCommand, + input: session, + run: (c: PaymentClient) => c.deletePaymentSession(session, options), + }, + { + command: CreatePaymentInstrumentCommand, + input: wallet, + run: (c: PaymentClient) => c.createPaymentInstrument(wallet, options), + }, + { + command: GetPaymentInstrumentCommand, + input: instrument, + run: (c: PaymentClient) => c.getPaymentInstrument(instrument, options), + }, + { + command: DeletePaymentInstrumentCommand, + input: instrument, + run: (c: PaymentClient) => c.deletePaymentInstrument(instrument, options), + }, + { + command: GetPaymentInstrumentBalanceCommand, + input: balance, + run: (c: PaymentClient) => c.getPaymentInstrumentBalance(balance, options), + }, + { + command: ListPaymentInstrumentsCommand, + input: instrumentList, + run: (c: PaymentClient) => c.listPaymentInstruments(instrumentList, options), + }, + ])( + "$command.name resolves the manager once and preserves the request", + async ({ command, input, run }) => { + const calls: string[] = []; + const client = paymentClient({ + control: async (sent) => { + calls.push(sent.constructor.name); + expect(sent.input).toEqual({ paymentManagerId: MANAGER_ID }); + return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; + }, + data: async (sent) => { + calls.push(sent.constructor.name); + expect(sent).toBeInstanceOf(command); + const { managerId: _id, ...request } = input; + expect(sent.input).toEqual({ ...request, paymentManagerArn: MANAGER_ARN }); + expect(input).toHaveProperty("managerId", MANAGER_ID); + expect(input).not.toHaveProperty("paymentManagerArn"); + return {}; + }, + }); + await run(client); + expect(calls).toEqual(["GetPaymentManagerCommand", command.name]); + }, + ); + + test("manager lookup and data call retain the same region, endpoint, and credentials", async () => { + const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; + const config = { region: "us-east-1", endpoint: "https://example.test/payments", credentials }; + const control = mock(() => ({ + send: async () => ({ paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }), + })); + const data = mock(() => ({ send: async () => ({}) })); + const client = new PaymentClient({ control, data } as unknown as AwsClients, { + getPaymentCredentialProvider: async () => coinbaseProvider("unused"), + }); + await client.getPaymentInstrumentBalance(balance, { + region: config.region, + endpointUrl: config.endpoint, + credentials, + }); + expect(control).toHaveBeenCalledWith(config); + expect(data).toHaveBeenCalledWith(config); + }); +}); diff --git a/src/core/payment.tsx b/src/core/payment.tsx index e9ac0affe..1460ac674 100644 --- a/src/core/payment.tsx +++ b/src/core/payment.tsx @@ -1,46 +1,124 @@ import { + CreatePaymentConnectorCommand, + CreatePaymentManagerCommand, + DeletePaymentConnectorCommand, + DeletePaymentManagerCommand, GetPaymentConnectorCommand, GetPaymentManagerCommand, ListPaymentConnectorsCommand, ListPaymentManagersCommand, + UpdatePaymentConnectorCommand, + UpdatePaymentManagerCommand, + type CreatePaymentConnectorResponse, + type CreatePaymentManagerResponse, + type CredentialsProviderConfiguration, + type DeletePaymentConnectorRequest, + type DeletePaymentConnectorResponse, + type DeletePaymentManagerRequest, + type DeletePaymentManagerResponse, type GetPaymentConnectorResponse, type GetPaymentManagerResponse, type ListPaymentConnectorsResponse, type ListPaymentManagersResponse, + type PaymentConnectorType, + type UpdatePaymentConnectorResponse, + type UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { + CreatePaymentInstrumentCommand, + CreatePaymentSessionCommand, + DeletePaymentInstrumentCommand, + DeletePaymentSessionCommand, GetPaymentInstrumentBalanceCommand, GetPaymentInstrumentCommand, GetPaymentSessionCommand, ListPaymentInstrumentsCommand, ListPaymentSessionsCommand, type BedrockAgentCoreClient, + type CreatePaymentInstrumentResponse, + type CreatePaymentSessionResponse, + type DeletePaymentInstrumentResponse, + type DeletePaymentSessionResponse, type GetPaymentInstrumentResponse, type GetPaymentInstrumentBalanceResponse, type GetPaymentSessionResponse, type ListPaymentInstrumentsResponse, type ListPaymentSessionsResponse, } from "@aws-sdk/client-bedrock-agentcore"; -import { InputValidationError, MalformedServiceResponseError } from "../errors"; +import { + AgentCoreCLIError, + ERROR_SOURCE, + InputValidationError, + MalformedServiceResponseError, +} from "../errors"; +import type { CoreIdentityClient } from "../handlers/identity/types"; import type { CorePaymentClient, + CreatePaymentConnectorInput, + CreatePaymentManagerInput, + CreatePaymentSessionInput, GetPaymentSessionInput, ListPaymentSessionsInput, + DeletePaymentSessionInput, + CreatePaymentInstrumentInput, GetPaymentInstrumentInput, GetPaymentInstrumentBalanceInput, ListPaymentInstrumentsInput, + DeletePaymentInstrumentInput, + UpdatePaymentConnectorInput, + UpdatePaymentManagerInput, } from "../handlers/payment/types"; +import { ensurePaymentServiceRole } from "./paymentServiceRole"; +import { isRoleUnassumableValidation, retryWhileRoleUnassumable } from "./roleRetry"; import type { AwsClients, CoreOptions } from "./types"; import { toClientConfig } from "./utils"; +const QUICK_CREATE_TYPE: PaymentConnectorType = "CoinbaseCDP"; + // PaymentClient implements the payment-facing operations on top of the shared // AWS clients provided by CoreClient. Managers and connectors live on the control // plane; sessions and instruments on the data plane. export class PaymentClient implements CorePaymentClient { - constructor(private readonly clients: Pick) {} + constructor( + private readonly clients: Pick, + // Payment credential providers live in AgentCore Identity. Connector create + // and update resolve a provider name to its ARN and vendor through the + // identity client rather than re-implementing that lookup here. + private readonly identity: Pick, + ) {} // ─── payment managers ─────────────────────────────────────────────────────── + async createPaymentManager( + input: CreatePaymentManagerInput, + options: CoreOptions, + ): Promise { + const control = this.clients.control(toClientConfig(options)); + const { roleArn, ...request } = input; + if (roleArn) { + return control.send(new CreatePaymentManagerCommand({ ...request, roleArn })); + } + + // No role supplied: provision (or reuse) the default service role, then + // create the manager with it. IAM is eventually consistent — a role created + // moments ago may not yet be assumable by the service principal — so retry + // the create while the service reports the role as unusable. + const defaultRoleArn = await ensurePaymentServiceRole( + // IAM is a global service; the region only selects the endpoint, and the + // agentcore endpoint override must not leak onto it. + this.clients.iam({ + region: options.region, + ...(options.credentials ? { credentials: options.credentials } : {}), + }), + input.name!, + options.region, + ); + return retryWhileRoleUnassumable( + () => control.send(new CreatePaymentManagerCommand({ ...request, roleArn: defaultRoleArn })), + isServiceRoleUnusable(defaultRoleArn), + ); + } + async getPaymentManager(id: string, options: CoreOptions): Promise { return this.clients .control(toClientConfig(options)) @@ -57,8 +135,51 @@ export class PaymentClient implements CorePaymentClient { .send(new ListPaymentManagersCommand({ nextToken, maxResults })); } + async updatePaymentManager( + input: UpdatePaymentManagerInput, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new UpdatePaymentManagerCommand({ ...input })); + } + + async deletePaymentManager( + request: DeletePaymentManagerRequest, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new DeletePaymentManagerCommand({ ...request })); + } + // ─── payment connectors ───────────────────────────────────────────────────── + async createPaymentConnector( + input: CreatePaymentConnectorInput, + options: CoreOptions, + ): Promise { + const { type, credentialProviderConfigurations } = await this.resolveConnectorCredentials( + input, + options, + ); + try { + return await this.clients.control(toClientConfig(options)).send( + new CreatePaymentConnectorCommand({ + paymentManagerId: input.managerId, + name: input.name, + ...(input.description !== undefined ? { description: input.description } : {}), + type, + credentialProviderConfigurations, + provisionMode: input.quickCreate ? "QUICK_CREATE" : undefined, + ...(input.clientToken !== undefined ? { clientToken: input.clientToken } : {}), + }), + ); + } catch (error) { + throw subscriptionRequired(error); + } + } + async getPaymentConnector( managerId: string, connectorId: string, @@ -85,8 +206,72 @@ export class PaymentClient implements CorePaymentClient { ); } + async updatePaymentConnector( + input: UpdatePaymentConnectorInput, + options: CoreOptions, + ): Promise { + const control = this.clients.control(toClientConfig(options)); + + // A replacement credential provider has to land in the union member matching + // the connector's type, and the type is not changeable, so read it first. + let credentialProviderConfigurations: CredentialsProviderConfiguration[] | undefined; + if (input.credentialProvider !== undefined) { + const current = await control.send( + new GetPaymentConnectorCommand({ + paymentManagerId: input.managerId, + paymentConnectorId: input.connectorId, + }), + ); + if (!current.type) { + throw new AgentCoreCLIError( + `payment connector "${input.connectorId}" returned no type; cannot choose a credential configuration`, + { source: ERROR_SOURCE.SERVICE }, + ); + } + const resolved = await this.resolveCredentialProvider( + input.credentialProvider, + current.type, + options, + ); + credentialProviderConfigurations = [credentialConfiguration(current.type, resolved.arn)]; + } + + try { + return await control.send( + new UpdatePaymentConnectorCommand({ + paymentManagerId: input.managerId, + paymentConnectorId: input.connectorId, + description: input.description, + credentialProviderConfigurations, + clientToken: input.clientToken, + }), + ); + } catch (error) { + throw subscriptionRequired(error); + } + } + + async deletePaymentConnector( + request: DeletePaymentConnectorRequest, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new DeletePaymentConnectorCommand({ ...request })); + } + // ─── payment sessions (data plane) ────────────────────────────────────────── + async createPaymentSession( + input: CreatePaymentSessionInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new CreatePaymentSessionCommand({ paymentManagerArn, ...request })), + ); + } + async getPaymentSession( input: GetPaymentSessionInput, options: CoreOptions, @@ -107,8 +292,28 @@ export class PaymentClient implements CorePaymentClient { ); } + async deletePaymentSession( + input: DeletePaymentSessionInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new DeletePaymentSessionCommand({ paymentManagerArn, ...request })), + ); + } + // ─── payment instruments (data plane) ─────────────────────────────────────── + async createPaymentInstrument( + input: CreatePaymentInstrumentInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new CreatePaymentInstrumentCommand({ paymentManagerArn, ...request })), + ); + } + async getPaymentInstrument( input: GetPaymentInstrumentInput, options: CoreOptions, @@ -139,8 +344,90 @@ export class PaymentClient implements CorePaymentClient { ); } + async deletePaymentInstrument( + input: DeletePaymentInstrumentInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new DeletePaymentInstrumentCommand({ paymentManagerArn, ...request })), + ); + } + // ─── helpers ──────────────────────────────────────────────────────────────── + private async resolveConnectorCredentials( + input: Pick, + options: CoreOptions, + ): Promise<{ + type: PaymentConnectorType; + credentialProviderConfigurations: CredentialsProviderConfiguration[]; + }> { + if (input.quickCreate && input.credentialProvider !== undefined) { + throw new InputValidationError( + "Quick Create and a credential provider are mutually exclusive; specify one", + ); + } + if (input.quickCreate) { + const type = input.type ?? QUICK_CREATE_TYPE; + if (type !== QUICK_CREATE_TYPE) { + throw new InputValidationError( + `Quick Create is available only for ${QUICK_CREATE_TYPE} connectors, not ${type}`, + ); + } + return { type, credentialProviderConfigurations: [] }; + } + if (input.credentialProvider === undefined) { + throw new InputValidationError( + "a payment connector needs a credential provider, or Quick Create for CoinbaseCDP", + ); + } + const resolved = await this.resolveCredentialProvider( + input.credentialProvider, + input.type, + options, + ); + return { + type: resolved.type, + credentialProviderConfigurations: [credentialConfiguration(resolved.type, resolved.arn)], + }; + } + + // resolveCredentialProvider turns a provider reference into an ARN plus the + // connector type it backs. An ARN carries no vendor, so the type must come from + // the caller; a name is looked up in identity and its vendor is the type, + // which an explicit type must agree with. + private async resolveCredentialProvider( + reference: string, + type: PaymentConnectorType | undefined, + options: CoreOptions, + ): Promise<{ arn: string; type: PaymentConnectorType }> { + if (reference.startsWith("arn:")) { + if (!type) { + throw new InputValidationError( + "--type is required when --credential-provider is an ARN (the ARN does not name the vendor)", + ); + } + return { arn: reference, type }; + } + + const provider = await this.identity.getPaymentCredentialProvider(reference, options); + const arn = provider.credentialProviderArn; + const vendor = provider.credentialProviderVendor as PaymentConnectorType | undefined; + if (!arn || !vendor) { + throw new AgentCoreCLIError( + `payment credential provider "${reference}" returned no ARN or vendor`, + { source: ERROR_SOURCE.SERVICE }, + ); + } + if (type && type !== vendor) { + throw new InputValidationError( + `credential provider "${reference}" is a ${vendor} provider and cannot back a ${type} connector`, + ); + } + return { arn, type: vendor }; + } + private async sendData( managerId: string, options: CoreOptions, @@ -163,3 +450,48 @@ export class PaymentClient implements CorePaymentClient { return send(this.clients.data(toClientConfig(options)), manager.paymentManagerArn); } } + +function credentialConfiguration( + type: PaymentConnectorType, + credentialProviderArn: string, +): CredentialsProviderConfiguration { + return type === "CoinbaseCDP" + ? { coinbaseCDP: { credentialProviderArn } } + : { stripePrivy: { credentialProviderArn } }; +} + +// isServiceRoleUnusable widens the harness predicate: the payments control plane +// assumes the role during the create itself, so a not-yet-propagated role can +// also surface as an access-denied failure. Only an access denial that names the +// provisioned role counts; a caller's own permission denial also says +// "assumed-role/... is not authorized" and must surface immediately. +function isServiceRoleUnusable(roleArn: string): (error: Error) => boolean { + const roleName = roleArn.split("/").pop() ?? roleArn; + return (error) => + isRoleUnassumableValidation(error) || + (error.name === "AccessDeniedException" && + ((error.message ?? "").includes(roleArn) || (error.message ?? "").includes(roleName))); +} + +// Connector creation and updates fail with SubscriptionRequiredException when the +// account has not subscribed to the provider's AWS Marketplace listing. The SDK +// error carries the listing URL and product name; surface both so the fix is one +// click away instead of a support search. +function subscriptionRequired(error: unknown): unknown { + if (!(error instanceof Error) || error.name !== "SubscriptionRequiredException") return error; + const { subscriptionUrl, productName } = error as Error & { + subscriptionUrl?: string; + productName?: string; + }; + const product = productName ? ` to "${productName}"` : ""; + const where = subscriptionUrl ? ` Subscribe at ${subscriptionUrl}, then retry.` : ""; + return new AgentCoreCLIError( + `${error.message} An active AWS Marketplace subscription${product} is required.${where}`, + { + cause: error, + source: ERROR_SOURCE.USER, + name: error.name, + meta: { subscriptionUrl, productName }, + }, + ); +} diff --git a/src/core/paymentServiceRole.test.ts b/src/core/paymentServiceRole.test.ts new file mode 100644 index 000000000..6469e83f9 --- /dev/null +++ b/src/core/paymentServiceRole.test.ts @@ -0,0 +1,210 @@ +import { expect, mock, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { + ensurePaymentServiceRole, + paymentServiceRoleName, + servicePolicy, + trustPolicy, +} from "./paymentServiceRole"; + +const REGION = "us-west-2"; +const ACCOUNT = "123456789012"; + +function statements(policy: string): { Sid?: string; Action?: unknown; Resource?: unknown }[] { + return JSON.parse(policy).Statement; +} + +test("prefixes the manager name and stays within IAM's 64-character cap", () => { + expect(paymentServiceRoleName("Checkout", REGION)).toBe("AgentCorePayments-us-west-2-Checkout"); + + const longest = paymentServiceRoleName("a".repeat(48), REGION); + expect(longest.length).toBe(64); + expect(longest.startsWith("AgentCorePayments-")).toBe(true); +}); + +// Truncating alone would let two long names share one role, and provisioning is +// idempotent by name, so the second create would silently reuse the first's. +test("keeps overflowing role names distinct", () => { + const a = paymentServiceRoleName("x".repeat(44) + "AAAA", REGION); + const b = paymentServiceRoleName("x".repeat(44) + "BBBB", REGION); + expect(a.length).toBeLessThanOrEqual(64); + expect(b.length).toBeLessThanOrEqual(64); + expect(a).not.toBe(b); +}); + +test("uses distinct role names for the same manager in different regions", () => { + for (const name of ["Checkout", "x".repeat(48)]) { + expect(paymentServiceRoleName(name, "us-east-1")).not.toBe( + paymentServiceRoleName(name, "us-west-2"), + ); + } +}); + +test("long role names work in the Node distribution", async () => { + const directory = await mkdtemp(join(tmpdir(), "payment-role-node-")); + try { + await Bun.build({ + entrypoints: [join(import.meta.dir, "paymentServiceRole.ts")], + target: "node", + outdir: directory, + naming: "role.mjs", + }); + const source = [ + `import { paymentServiceRoleName } from ${JSON.stringify(pathToFileURL(join(directory, "role.mjs")).href)};`, + `console.log(paymentServiceRoleName("x".repeat(48), "${REGION}"));`, + ].join("\n"); + const name = execFileSync("node", ["--input-type=module", "--eval", source], { + encoding: "utf8", + }).trim(); + expect(name).toHaveLength(64); + expect(name).toBe(paymentServiceRoleName("x".repeat(48), REGION)); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +const ownershipTags = (region: string) => [ + { Key: "agentcore:managed-by", Value: "agentcore-cli" }, + { Key: "agentcore:payment-manager", Value: "Checkout" }, + { Key: "agentcore:region", Value: region }, +]; + +test("creates a tagged default role and grants its regional policy", async () => { + const send = mock(async (command: unknown) => { + if (command instanceof GetRoleCommand) { + throw Object.assign(new Error("not found"), { name: "NoSuchEntityException" }); + } + if (command instanceof CreateRoleCommand) { + expect(command.input.Tags).toEqual(ownershipTags(REGION)); + expect(command.input.RoleName).toBe("AgentCorePayments-us-west-2-Checkout"); + return { Role: { Arn: `arn:aws:iam::${ACCOUNT}:role/${command.input.RoleName}` } }; + } + expect(command).toBeInstanceOf(PutRolePolicyCommand); + return {}; + }); + const arn = await ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION); + expect(arn).toBe(`arn:aws:iam::${ACCOUNT}:role/AgentCorePayments-us-west-2-Checkout`); + expect(send).toHaveBeenCalledTimes(3); +}); + +test("reusing an owned role in another region cannot overwrite the first region's policy", async () => { + const policies = new Map(); + let region = "us-east-1"; + const send = mock(async (command: unknown) => { + if (command instanceof GetRoleCommand) { + return { + Role: { + Arn: `arn:aws:iam::${ACCOUNT}:role/${command.input.RoleName}`, + Tags: ownershipTags(region), + }, + }; + } + expect(command).toBeInstanceOf(PutRolePolicyCommand); + const { RoleName, PolicyName, PolicyDocument } = (command as PutRolePolicyCommand).input; + policies.set(`${RoleName}/${PolicyName}`, PolicyDocument!); + return {}; + }); + const iam = { send } as unknown as IAMClient; + await ensurePaymentServiceRole(iam, "Checkout", region); + region = "us-west-2"; + await ensurePaymentServiceRole(iam, "Checkout", region); + expect(policies.size).toBe(2); + expect([...policies.values()]).toEqual([ + servicePolicy("us-east-1", ACCOUNT), + servicePolicy("us-west-2", ACCOUNT), + ]); +}); + +test.each([ + { Tags: undefined }, + { Tags: [] }, + { Tags: [{ Key: "agentcore:managed-by", Value: "another-tool" }] }, + { Tags: ownershipTags("us-east-1") }, + { + Tags: ownershipTags(REGION).map((tag) => + tag.Key === "agentcore:payment-manager" ? { ...tag, Value: "OtherManager" } : tag, + ), + }, +])("refuses a role without matching ownership tags: %j", async ({ Tags }) => { + const send = mock(async (command: unknown) => { + if (command instanceof GetRoleCommand) { + return { + Role: { Arn: `arn:aws:iam::${ACCOUNT}:role/default-role`, Tags }, + }; + } + throw new Error("must not mutate an unrelated role"); + }); + await expect( + ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION), + ).rejects.toThrow(/--role-arn/); + expect(send).toHaveBeenCalledTimes(1); +}); + +test("a caller's GetRole denial is surfaced without attempting creation", async () => { + const error = Object.assign(new Error("denied"), { name: "AccessDeniedException" }); + const send = mock(async () => { + throw error; + }); + await expect( + ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION), + ).rejects.toBe(error); + expect(send).toHaveBeenCalledTimes(1); +}); + +test("trusts the AgentCore service principal", () => { + const statement = JSON.parse(trustPolicy()).Statement[0]; + expect(statement.Effect).toBe("Allow"); + expect(statement.Principal).toEqual({ Service: "bedrock-agentcore.amazonaws.com" }); + expect(statement.Action).toBe("sts:AssumeRole"); +}); + +// The action list mirrors the ResourceRetrievalRole the L3 CDK construct grants: +// the service assumes this role to mint workload tokens, read the connector's +// credential provider, and fetch payment tokens for every data-plane call. +test("grants the identity, workload token, and payment token actions", () => { + const identity = statements(servicePolicy(REGION, ACCOUNT)).find( + (s) => s.Sid === "AgentCoreIdentityAndTokens", + ); + expect(identity?.Action).toEqual([ + "bedrock-agentcore:RetrieveToken", + "bedrock-agentcore:GetWorkloadIdentity", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetPaymentCredentialProvider", + "bedrock-agentcore:TagResource", + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetResourcePaymentToken", + ]); + expect(identity?.Resource).toBe("*"); +}); + +// Every AgentCore-managed credential secret lives under the +// `bedrock-agentcore-identity!` prefix, so scoping to it covers the connector +// secrets without exposing unrelated account secrets. Granting the prefix up +// front also means adding a connector never has to mutate the role. +test("scopes secret reads to AgentCore Identity managed secrets in the region and account", () => { + const secrets = statements(servicePolicy(REGION, ACCOUNT)).find( + (s) => s.Sid === "IdentityManagedSecrets", + ); + expect(secrets?.Action).toEqual(["secretsmanager:GetSecretValue"]); + expect(secrets?.Resource).toBe( + `arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:bedrock-agentcore-identity!*`, + ); +}); + +test("allows sts:SetContext for workload identity tagging", () => { + const sts = statements(servicePolicy(REGION, ACCOUNT)).find((s) => s.Sid === "StsSetContext"); + expect(sts?.Action).toEqual(["sts:SetContext"]); + expect(sts?.Resource).toBe("*"); +}); diff --git a/src/core/paymentServiceRole.ts b/src/core/paymentServiceRole.ts new file mode 100644 index 000000000..86b9a1634 --- /dev/null +++ b/src/core/paymentServiceRole.ts @@ -0,0 +1,155 @@ +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { createHash } from "node:crypto"; +import { InputValidationError } from "../errors"; +import { parseArn } from "./arn"; + +// Default payment service role provisioning. +// +// CreatePaymentManager requires an IAM role the AgentCore Payments service assumes +// at runtime to mint workload tokens, read the connector's credential provider, +// and fetch payment tokens. When the caller doesn't bring one, PaymentClient +// provisions a per-manager default here, mirroring core/executionRole.ts for +// harnesses: a role trusting bedrock-agentcore.amazonaws.com with one inline +// policy carrying the actions the AgentCore L3 CDK construct grants its +// ResourceRetrievalRole. Only CLI-owned roles for the same manager and region +// are reused and have their inline policy refreshed. + +const POLICY_NAME = "AgentCorePaymentsServicePolicy"; + +const ROLE_NAME_PREFIX = "AgentCorePayments-"; +const ROLE_NAME_MAX = 64; +const NAME_HASH_LENGTH = 12; + +// IAM names are account-global; the policy is regional. Hash the full identity +// before truncation so long manager names cannot collapse onto the same role. +export function paymentServiceRoleName(managerName: string, region: string): string { + const full = `${ROLE_NAME_PREFIX}${region}-${managerName}`; + if (full.length <= ROLE_NAME_MAX) return full; + + const hash = createHash("sha256").update(full).digest("hex").slice(0, NAME_HASH_LENGTH); + return `${full.slice(0, ROLE_NAME_MAX - NAME_HASH_LENGTH - 1)}-${hash}`; +} + +// trustPolicy allows the AgentCore service principal to assume the role. +export function trustPolicy(): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }); +} + +// servicePolicy is the permissions document, parameterized on the caller's +// region and account so the secret grant stays inside them. Every +// AgentCore-managed credential secret is stored under the +// `bedrock-agentcore-identity!` prefix, so granting the prefix covers each +// connector's credentials up front and adding a connector never has to mutate +// the role. +export function servicePolicy(region: string, accountId: string): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "AgentCoreIdentityAndTokens", + Effect: "Allow", + Action: [ + "bedrock-agentcore:RetrieveToken", + "bedrock-agentcore:GetWorkloadIdentity", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetPaymentCredentialProvider", + "bedrock-agentcore:TagResource", + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetResourcePaymentToken", + ], + Resource: "*", + }, + { + Sid: "IdentityManagedSecrets", + Effect: "Allow", + Action: ["secretsmanager:GetSecretValue"], + Resource: `arn:aws:secretsmanager:${region}:${accountId}:secret:bedrock-agentcore-identity!*`, + }, + { + Sid: "StsSetContext", + Effect: "Allow", + Action: ["sts:SetContext"], + Resource: "*", + }, + ], + }); +} + +// accountIdFromRoleArn extracts the account id from a role ARN +// (arn:aws:iam:::role/), which saves an STS lookup. +function accountIdFromRoleArn(arn: string): string { + const accountId = parseArn(arn)?.account; + if (!accountId) { + throw new Error(`Cannot extract an account id from role ARN "${arn}"`); + } + return accountId; +} + +// ensurePaymentServiceRole returns the ARN of the default service role for +// `managerName`, creating the role if it doesn't exist and (re)attaching the +// inline policy either way. +export async function ensurePaymentServiceRole( + iam: IAMClient, + managerName: string, + region: string, +): Promise { + const roleName = paymentServiceRoleName(managerName, region); + const tags = [ + { Key: "agentcore:managed-by", Value: "agentcore-cli" }, + { Key: "agentcore:payment-manager", Value: managerName }, + { Key: "agentcore:region", Value: region }, + ]; + + let roleArn: string; + try { + const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); + if ( + !tags.every(({ Key, Value }) => + existing.Role?.Tags?.some((tag) => tag.Key === Key && tag.Value === Value), + ) + ) { + throw new InputValidationError( + `IAM role "${roleName}" already exists but is not owned by this CLI payment manager in ${region}. ` + + "Use --role-arn to supply a role explicitly, or choose a different manager name; the existing role was not changed.", + ); + } + roleArn = existing.Role!.Arn!; + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + const created = await iam.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: trustPolicy(), + Tags: tags, + Description: `Default service role for the AgentCore payment manager "${managerName}" (created by the agentcore CLI)`, + }), + ); + roleArn = created.Role!.Arn!; + } + + await iam.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: POLICY_NAME, + PolicyDocument: servicePolicy(region, accountIdFromRoleArn(roleArn)), + }), + ); + + return roleArn; +} diff --git a/src/core/roleRetry.ts b/src/core/roleRetry.ts new file mode 100644 index 000000000..adc087af0 --- /dev/null +++ b/src/core/roleRetry.ts @@ -0,0 +1,27 @@ +// isRoleUnassumableValidation is the harness predicate: AgentCore rejects a +// freshly created execution role with a ValidationException whose message names +// the role, the assume, or the trust relationship. +export function isRoleUnassumableValidation(error: Error): boolean { + return error.name === "ValidationException" && /role|assume|trust/i.test(error.message ?? ""); +} + +// retryWhileRoleUnassumable retries `operation` while it fails with the error +// AgentCore raises for a role it cannot yet assume (fresh IAM roles propagate +// over several seconds). Any other failure — or exhausting the attempts — +// rethrows. `isRetryable` decides which errors count; it defaults to the +// harness ValidationException shape. +export async function retryWhileRoleUnassumable( + operation: () => Promise, + isRetryable: (error: Error) => boolean = isRoleUnassumableValidation, + attempts = 8, + delayMs = 2000, +): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await operation(); + } catch (error) { + if (!isRetryable(error as Error) || attempt >= attempts) throw error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +} diff --git a/src/handlers/payment/types.tsx b/src/handlers/payment/types.tsx index 7456a5389..42e669d38 100644 --- a/src/handlers/payment/types.tsx +++ b/src/handlers/payment/types.tsx @@ -1,10 +1,30 @@ import type { + CreatePaymentConnectorResponse, + CreatePaymentManagerRequest, + CreatePaymentManagerResponse, + DeletePaymentConnectorRequest, + DeletePaymentConnectorResponse, + DeletePaymentManagerRequest, + DeletePaymentManagerResponse, GetPaymentConnectorResponse, GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, + PaymentConnectorType, + UpdatePaymentConnectorRequest, + UpdatePaymentConnectorResponse, + UpdatePaymentManagerRequest, + UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { + CreatePaymentInstrumentRequest, + CreatePaymentInstrumentResponse, + CreatePaymentSessionRequest, + CreatePaymentSessionResponse, + DeletePaymentInstrumentRequest, + DeletePaymentInstrumentResponse, + DeletePaymentSessionRequest, + DeletePaymentSessionResponse, GetPaymentInstrumentRequest, GetPaymentInstrumentResponse, GetPaymentInstrumentBalanceRequest, @@ -18,22 +38,78 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { CoreOptions } from "../../core/types"; +// CreatePaymentManagerInput is CreatePaymentManagerRequest with the service role +// made optional: when omitted, Core provisions the default service role in IAM and +// creates the manager with it. +export type CreatePaymentManagerInput = Omit & { + roleArn?: string; +}; + +export type UpdatePaymentManagerInput = UpdatePaymentManagerRequest; + +// CreatePaymentConnectorInput names a credential provider instead of carrying the +// SDK's configuration list. Core resolves a provider name to its ARN and vendor +// through identity, derives the connector type from that vendor when the caller +// omits it, and builds the single-entry union the service expects. Quick Create +// sends no credentials; the service provisions them after OAuth consent. +export type CreatePaymentConnectorInput = { + managerId: string; + name: string; + description?: string; + type?: PaymentConnectorType; + // A payment credential provider name or ARN. Required unless quickCreate is set. + credentialProvider?: string; + quickCreate?: boolean; + clientToken?: string; +}; + +// UpdatePaymentConnectorInput omits `type`: the service rejects any change to a +// connector's type after creation, so the CLI does not offer it. +export type UpdatePaymentConnectorInput = { + managerId: string; + connectorId: string; + description?: UpdatePaymentConnectorRequest["description"]; + credentialProvider?: string; + clientToken?: string; +}; + type WithPaymentManagerId = Omit & { managerId: string }; +export type CreatePaymentSessionInput = WithPaymentManagerId; export type GetPaymentSessionInput = WithPaymentManagerId; export type ListPaymentSessionsInput = WithPaymentManagerId; +export type DeletePaymentSessionInput = WithPaymentManagerId; +export type CreatePaymentInstrumentInput = WithPaymentManagerId; export type GetPaymentInstrumentInput = WithPaymentManagerId; export type GetPaymentInstrumentBalanceInput = WithPaymentManagerId; export type ListPaymentInstrumentsInput = WithPaymentManagerId; +export type DeletePaymentInstrumentInput = WithPaymentManagerId; export interface CorePaymentClient { + createPaymentManager( + input: CreatePaymentManagerInput, + options: CoreOptions, + ): Promise; getPaymentManager(id: string, options: CoreOptions): Promise; listPaymentManagers( nextToken: string | undefined, maxResults: number | undefined, options: CoreOptions, ): Promise; + updatePaymentManager( + input: UpdatePaymentManagerInput, + options: CoreOptions, + ): Promise; + deletePaymentManager( + request: DeletePaymentManagerRequest, + options: CoreOptions, + ): Promise; + + createPaymentConnector( + input: CreatePaymentConnectorInput, + options: CoreOptions, + ): Promise; getPaymentConnector( managerId: string, connectorId: string, @@ -45,8 +121,20 @@ export interface CorePaymentClient { maxResults: number | undefined, options: CoreOptions, ): Promise; + updatePaymentConnector( + input: UpdatePaymentConnectorInput, + options: CoreOptions, + ): Promise; + deletePaymentConnector( + request: DeletePaymentConnectorRequest, + options: CoreOptions, + ): Promise; // Core resolves the selected manager ID to the ARN required by the data plane. + createPaymentSession( + request: CreatePaymentSessionInput, + options: CoreOptions, + ): Promise; getPaymentSession( request: GetPaymentSessionInput, options: CoreOptions, @@ -55,6 +143,15 @@ export interface CorePaymentClient { request: ListPaymentSessionsInput, options: CoreOptions, ): Promise; + deletePaymentSession( + request: DeletePaymentSessionInput, + options: CoreOptions, + ): Promise; + + createPaymentInstrument( + request: CreatePaymentInstrumentInput, + options: CoreOptions, + ): Promise; getPaymentInstrument( request: GetPaymentInstrumentInput, options: CoreOptions, @@ -67,4 +164,8 @@ export interface CorePaymentClient { request: ListPaymentInstrumentsInput, options: CoreOptions, ): Promise; + deletePaymentInstrument( + request: DeletePaymentInstrumentInput, + options: CoreOptions, + ): Promise; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 78abdd9c2..5eb922307 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -29,10 +29,18 @@ import type { ListApiKeyCredentialProvidersResponse, ListOauth2CredentialProvidersResponse, ListPaymentCredentialProvidersResponse, + CreatePaymentConnectorResponse, + CreatePaymentManagerResponse, + DeletePaymentConnectorRequest, + DeletePaymentConnectorResponse, + DeletePaymentManagerRequest, + DeletePaymentManagerResponse, GetPaymentConnectorResponse, GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, + UpdatePaymentConnectorResponse, + UpdatePaymentManagerResponse, ListAgentRuntimeEndpointsResponse, ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, @@ -142,6 +150,10 @@ import type { UpdateOauth2CredentialProviderInput, } from "../handlers/identity/types"; import type { + CreatePaymentInstrumentResponse, + CreatePaymentSessionResponse, + DeletePaymentInstrumentResponse, + DeletePaymentSessionResponse, GetPaymentInstrumentResponse, GetPaymentInstrumentBalanceResponse, GetPaymentSessionResponse, @@ -150,11 +162,19 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { CorePaymentClient, + CreatePaymentConnectorInput, + CreatePaymentManagerInput, + CreatePaymentSessionInput, GetPaymentSessionInput, ListPaymentSessionsInput, + DeletePaymentSessionInput, + CreatePaymentInstrumentInput, GetPaymentInstrumentInput, GetPaymentInstrumentBalanceInput, ListPaymentInstrumentsInput, + DeletePaymentInstrumentInput, + UpdatePaymentConnectorInput, + UpdatePaymentManagerInput, } from "../handlers/payment/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; import type { @@ -283,23 +303,33 @@ const DEFAULT_DELETE_PAYMENT_RESPONSE = {} as DeletePaymentCredentialProviderRes const DEFAULT_LIST_PAYMENT_PROVIDERS_RESPONSE: ListPaymentCredentialProvidersResponse = { credentialProviders: [], }; +const DEFAULT_CREATE_PAYMENT_MANAGER_RESPONSE = {} as CreatePaymentManagerResponse; const DEFAULT_GET_PAYMENT_MANAGER_RESPONSE = {} as GetPaymentManagerResponse; const DEFAULT_LIST_PAYMENT_MANAGERS_RESPONSE: ListPaymentManagersResponse = { paymentManagers: [], }; +const DEFAULT_UPDATE_PAYMENT_MANAGER_RESPONSE = {} as UpdatePaymentManagerResponse; +const DEFAULT_DELETE_PAYMENT_MANAGER_RESPONSE = {} as DeletePaymentManagerResponse; +const DEFAULT_CREATE_PAYMENT_CONNECTOR_RESPONSE = {} as CreatePaymentConnectorResponse; const DEFAULT_GET_PAYMENT_CONNECTOR_RESPONSE = {} as GetPaymentConnectorResponse; const DEFAULT_LIST_PAYMENT_CONNECTORS_RESPONSE: ListPaymentConnectorsResponse = { paymentConnectors: [], }; +const DEFAULT_UPDATE_PAYMENT_CONNECTOR_RESPONSE = {} as UpdatePaymentConnectorResponse; +const DEFAULT_DELETE_PAYMENT_CONNECTOR_RESPONSE = {} as DeletePaymentConnectorResponse; +const DEFAULT_CREATE_PAYMENT_SESSION_RESPONSE = {} as CreatePaymentSessionResponse; const DEFAULT_GET_PAYMENT_SESSION_RESPONSE = {} as GetPaymentSessionResponse; const DEFAULT_LIST_PAYMENT_SESSIONS_RESPONSE: ListPaymentSessionsResponse = { paymentSessions: [], }; +const DEFAULT_DELETE_PAYMENT_SESSION_RESPONSE = {} as DeletePaymentSessionResponse; +const DEFAULT_CREATE_PAYMENT_INSTRUMENT_RESPONSE = {} as CreatePaymentInstrumentResponse; const DEFAULT_GET_PAYMENT_INSTRUMENT_RESPONSE = {} as GetPaymentInstrumentResponse; const DEFAULT_GET_PAYMENT_INSTRUMENT_BALANCE_RESPONSE = {} as GetPaymentInstrumentBalanceResponse; const DEFAULT_LIST_PAYMENT_INSTRUMENTS_RESPONSE: ListPaymentInstrumentsResponse = { paymentInstruments: [], }; +const DEFAULT_DELETE_PAYMENT_INSTRUMENT_RESPONSE = {} as DeletePaymentInstrumentResponse; const DEFAULT_GET_MEMORY_RESPONSE = {} as GetMemoryOutput; const DEFAULT_LIST_MEMORIES_RESPONSE: ListMemoriesOutput = { memories: [] }; const DEFAULT_GET_EVENT_RESPONSE: GetEventOutput = { event: undefined }; @@ -1574,17 +1604,33 @@ export class TestIdentityClient implements CoreIdentityClient { // list responses by the request's nextToken. export class TestPaymentClient implements CorePaymentClient { readonly calls: RecordedCall[] = []; + + private createManagerResponse = DEFAULT_CREATE_PAYMENT_MANAGER_RESPONSE; private getManagerResponse = DEFAULT_GET_PAYMENT_MANAGER_RESPONSE; private listManagersResponses = new Map(); + private updateManagerResponse = DEFAULT_UPDATE_PAYMENT_MANAGER_RESPONSE; + private deleteManagerResponse = DEFAULT_DELETE_PAYMENT_MANAGER_RESPONSE; + private createConnectorResponse = DEFAULT_CREATE_PAYMENT_CONNECTOR_RESPONSE; private getConnectorResponse = DEFAULT_GET_PAYMENT_CONNECTOR_RESPONSE; private listConnectorsResponses = new Map(); + private updateConnectorResponse = DEFAULT_UPDATE_PAYMENT_CONNECTOR_RESPONSE; + private deleteConnectorResponse = DEFAULT_DELETE_PAYMENT_CONNECTOR_RESPONSE; + private createSessionResponse = DEFAULT_CREATE_PAYMENT_SESSION_RESPONSE; private getSessionResponse = DEFAULT_GET_PAYMENT_SESSION_RESPONSE; private listSessionsResponses = new Map(); + private deleteSessionResponse = DEFAULT_DELETE_PAYMENT_SESSION_RESPONSE; + private createInstrumentResponse = DEFAULT_CREATE_PAYMENT_INSTRUMENT_RESPONSE; private getInstrumentResponse = DEFAULT_GET_PAYMENT_INSTRUMENT_RESPONSE; private getInstrumentBalanceResponse = DEFAULT_GET_PAYMENT_INSTRUMENT_BALANCE_RESPONSE; private listInstrumentsResponses = new Map(); + private deleteInstrumentResponse = DEFAULT_DELETE_PAYMENT_INSTRUMENT_RESPONSE; private error?: Error; + setCreateManagerResponse(response: CreatePaymentManagerResponse): this { + this.createManagerResponse = response; + return this; + } + setGetManagerResponse(response: GetPaymentManagerResponse): this { this.getManagerResponse = response; return this; @@ -1595,6 +1641,21 @@ export class TestPaymentClient implements CorePaymentClient { return this; } + setUpdateManagerResponse(response: UpdatePaymentManagerResponse): this { + this.updateManagerResponse = response; + return this; + } + + setDeleteManagerResponse(response: DeletePaymentManagerResponse): this { + this.deleteManagerResponse = response; + return this; + } + + setCreateConnectorResponse(response: CreatePaymentConnectorResponse): this { + this.createConnectorResponse = response; + return this; + } + setGetConnectorResponse(response: GetPaymentConnectorResponse): this { this.getConnectorResponse = response; return this; @@ -1605,6 +1666,21 @@ export class TestPaymentClient implements CorePaymentClient { return this; } + setUpdateConnectorResponse(response: UpdatePaymentConnectorResponse): this { + this.updateConnectorResponse = response; + return this; + } + + setDeleteConnectorResponse(response: DeletePaymentConnectorResponse): this { + this.deleteConnectorResponse = response; + return this; + } + + setCreateSessionResponse(response: CreatePaymentSessionResponse): this { + this.createSessionResponse = response; + return this; + } + setGetSessionResponse(response: GetPaymentSessionResponse): this { this.getSessionResponse = response; return this; @@ -1615,6 +1691,16 @@ export class TestPaymentClient implements CorePaymentClient { return this; } + setDeleteSessionResponse(response: DeletePaymentSessionResponse): this { + this.deleteSessionResponse = response; + return this; + } + + setCreateInstrumentResponse(response: CreatePaymentInstrumentResponse): this { + this.createInstrumentResponse = response; + return this; + } + setGetInstrumentResponse(response: GetPaymentInstrumentResponse): this { this.getInstrumentResponse = response; return this; @@ -1633,12 +1719,26 @@ export class TestPaymentClient implements CorePaymentClient { return this; } + setDeleteInstrumentResponse(response: DeletePaymentInstrumentResponse): this { + this.deleteInstrumentResponse = response; + return this; + } + // setError makes every subsequent call reject with `error` (undefined clears). setError(error: Error | undefined): this { this.error = error; return this; } + async createPaymentManager( + input: CreatePaymentManagerInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createPaymentManager", args: [input, options] }); + if (this.error) throw this.error; + return this.createManagerResponse; + } + async getPaymentManager(id: string, options: CoreOptions): Promise { this.calls.push({ method: "getPaymentManager", args: [id, options] }); if (this.error) throw this.error; @@ -1655,6 +1755,33 @@ export class TestPaymentClient implements CorePaymentClient { return this.listManagersResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_MANAGERS_RESPONSE; } + async updatePaymentManager( + input: UpdatePaymentManagerInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "updatePaymentManager", args: [input, options] }); + if (this.error) throw this.error; + return this.updateManagerResponse; + } + + async deletePaymentManager( + request: DeletePaymentManagerRequest, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deletePaymentManager", args: [request, options] }); + if (this.error) throw this.error; + return this.deleteManagerResponse; + } + + async createPaymentConnector( + input: CreatePaymentConnectorInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createPaymentConnector", args: [input, options] }); + if (this.error) throw this.error; + return this.createConnectorResponse; + } + async getPaymentConnector( managerId: string, connectorId: string, @@ -1679,6 +1806,33 @@ export class TestPaymentClient implements CorePaymentClient { return this.listConnectorsResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_CONNECTORS_RESPONSE; } + async updatePaymentConnector( + input: UpdatePaymentConnectorInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "updatePaymentConnector", args: [input, options] }); + if (this.error) throw this.error; + return this.updateConnectorResponse; + } + + async deletePaymentConnector( + request: DeletePaymentConnectorRequest, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deletePaymentConnector", args: [request, options] }); + if (this.error) throw this.error; + return this.deleteConnectorResponse; + } + + async createPaymentSession( + request: CreatePaymentSessionInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createPaymentSession", args: [request, options] }); + if (this.error) throw this.error; + return this.createSessionResponse; + } + async getPaymentSession( request: GetPaymentSessionInput, options: CoreOptions, @@ -1699,6 +1853,24 @@ export class TestPaymentClient implements CorePaymentClient { ); } + async deletePaymentSession( + request: DeletePaymentSessionInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deletePaymentSession", args: [request, options] }); + if (this.error) throw this.error; + return this.deleteSessionResponse; + } + + async createPaymentInstrument( + request: CreatePaymentInstrumentInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createPaymentInstrument", args: [request, options] }); + if (this.error) throw this.error; + return this.createInstrumentResponse; + } + async getPaymentInstrument( request: GetPaymentInstrumentInput, options: CoreOptions, @@ -1728,6 +1900,15 @@ export class TestPaymentClient implements CorePaymentClient { DEFAULT_LIST_PAYMENT_INSTRUMENTS_RESPONSE ); } + + async deletePaymentInstrument( + request: DeletePaymentInstrumentInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deletePaymentInstrument", args: [request, options] }); + if (this.error) throw this.error; + return this.deleteInstrumentResponse; + } } // TestEvalClient is the eval sub-client of TestCoreClient. From 2cc6cb10b28b9a4e14260ba1d97ba1e82f3d3853 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 20:03:41 +0000 Subject: [PATCH 02/10] feat(payment): add manager lifecycle commands --- ...aymentManagerCommand.46791fae9fbbe940.json | 17 + .../CreateRoleCommand.4deb88176aa9ec28.json | 26 ++ ...aymentManagerCommand.894895e0c24c9098.json | 4 + .../GetRoleCommand.6894a19eac9ccc52.json | 6 + ...PutRolePolicyCommand.5b1970701f13b039.json | 1 + ...aymentManagerCommand.d2e5471084d393e8.json | 14 + ...aymentManagerCommand.894895e0c24c9098.json | 6 + .../__fixtures__/manager-create.golden.json | 15 + .../__fixtures__/manager-delete.golden.json | 4 + .../__fixtures__/manager-update.golden.json | 12 + src/handlers/payment/manager/create/index.tsx | 86 ++++ src/handlers/payment/manager/delete/index.tsx | 31 ++ src/handlers/payment/manager/index.tsx | 8 +- src/handlers/payment/manager/update/index.tsx | 70 +++ src/handlers/payment/payment.test.tsx | 413 ++++++++++++++++++ 15 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json create mode 100644 src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json create mode 100644 src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json create mode 100644 src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json create mode 100644 src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json create mode 100644 src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json create mode 100644 src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json create mode 100644 src/handlers/payment/__fixtures__/manager-create.golden.json create mode 100644 src/handlers/payment/__fixtures__/manager-delete.golden.json create mode 100644 src/handlers/payment/__fixtures__/manager-update.golden.json create mode 100644 src/handlers/payment/manager/create/index.tsx create mode 100644 src/handlers/payment/manager/delete/index.tsx create mode 100644 src/handlers/payment/manager/update/index.tsx create mode 100644 src/handlers/payment/payment.test.tsx diff --git a/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json b/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json new file mode 100644 index 000000000..6ca49e4fa --- /dev/null +++ b/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json @@ -0,0 +1,17 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "createdAt": { + "$date": "2026-09-09T00:05:18.383Z" + }, + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + }, + "tags": { + "created-by": "agentcore-cli-e2e" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json b/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json new file mode 100644 index 000000000..5c8c84e63 --- /dev/null +++ b/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json @@ -0,0 +1,26 @@ +{ + "Role": { + "Path": "/", + "RoleName": "AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "RoleId": "AROAYY3QB54NRWRDQPR7D", + "Arn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "CreateDate": { + "$date": "2026-09-09T00:05:06.000Z" + }, + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22bedrock-agentcore.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Tags": [ + { + "Key": "agentcore:managed-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:payment-manager", + "Value": "AgentCoreCliPaymentE2E" + }, + { + "Key": "agentcore:region", + "Value": "us-east-1" + } + ] + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json new file mode 100644 index 000000000..302c0f350 --- /dev/null +++ b/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json b/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json new file mode 100644 index 000000000..87a87aa39 --- /dev/null +++ b/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "NoSuchEntityException", + "message": "The role with name AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E cannot be found." + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json b/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json b/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json new file mode 100644 index 000000000..afcc5c32a --- /dev/null +++ b/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json @@ -0,0 +1,14 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "lastUpdatedAt": { + "$date": "2026-09-09T00:05:18.752Z" + }, + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json new file mode 100644 index 000000000..9064c1c9f --- /dev/null +++ b/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment manager not found: agentcoreclipaymente2e-ktdwha51g1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-create.golden.json b/src/handlers/payment/__fixtures__/manager-create.golden.json new file mode 100644 index 000000000..3950ee0d5 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-create.golden.json @@ -0,0 +1,15 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "createdAt": "2026-09-09T00:05:18.383Z", + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + }, + "tags": { + "created-by": "agentcore-cli-e2e" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-delete.golden.json b/src/handlers/payment/__fixtures__/manager-delete.golden.json new file mode 100644 index 000000000..302c0f350 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-delete.golden.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-update.golden.json b/src/handlers/payment/__fixtures__/manager-update.golden.json new file mode 100644 index 000000000..57a6a1ae5 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-update.golden.json @@ -0,0 +1,12 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "lastUpdatedAt": "2026-09-09T00:05:18.752Z", + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/manager/create/index.tsx b/src/handlers/payment/manager/create/index.tsx new file mode 100644 index 000000000..3114cf077 --- /dev/null +++ b/src/handlers/payment/manager/create/index.tsx @@ -0,0 +1,86 @@ +import type { AuthorizerConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonObjectFlag, parseTags } from "../../../utils"; +import type { CreatePaymentManagerInput } from "../../types"; + +export const createCreatePaymentManagerHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a payment manager (auto-provisions a service role if none given)", + flags: [ + flag( + "name", + "the payment manager name (letters and digits, up to 48 characters)", + z.string().optional(), + ), + flag("description", "payment manager description", z.string().optional()), + flag( + "authorizer-type", + "how agents authenticate to the data plane: AWS_IAM (default) or CUSTOM_JWT", + z.enum(["AWS_IAM", "CUSTOM_JWT"]).default("AWS_IAM"), + ), + flag( + "authorizer-configuration", + "CUSTOM_JWT configuration (JSON AuthorizerConfiguration; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "role-arn", + "IAM role the Payments service assumes; a default service role is created when omitted", + z.string().min(1).optional(), + ), + flag( + "kms-key-arn", + "customer managed KMS key ARN for encrypting sensitive data at rest", + z.string().min(1).optional(), + ), + flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + // Required at runtime but declared optional so that a bare invocation can + // fall through to the TUI once a screen exists. + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if ( + flags["authorizer-type"] === "CUSTOM_JWT" && + flags["authorizer-configuration"] === undefined + ) { + throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); + } + if ( + flags["authorizer-type"] !== "CUSTOM_JWT" && + flags["authorizer-configuration"] !== undefined + ) { + throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const authorizerConfiguration = parseJsonObjectFlag( + "authorizer-configuration", + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + ); + const tags = parseTags(flags.tags); + + const input: CreatePaymentManagerInput = { + name: flags.name, + authorizerType: flags["authorizer-type"], + ...(flags.description ? { description: flags.description } : {}), + ...(authorizerConfiguration ? { authorizerConfiguration } : {}), + ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), + ...(flags["kms-key-arn"] ? { kmsKeyArn: flags["kms-key-arn"] } : {}), + ...(tags ? { tags } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.createPaymentManager(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/manager/delete/index.tsx b/src/handlers/payment/manager/delete/index.tsx new file mode 100644 index 000000000..4f9136aec --- /dev/null +++ b/src/handlers/payment/manager/delete/index.tsx @@ -0,0 +1,31 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeletePaymentManagerHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment manager (delete its connectors first)", + flags: [ + flag("id", "the payment manager id", z.string().optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + + ctx.require(JsonRendererKey).renderJson( + await core.payment.deletePaymentManager( + { + paymentManagerId: flags.id, + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }, + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/payment/manager/index.tsx b/src/handlers/payment/manager/index.tsx index 1cd6b8652..7bd935043 100644 --- a/src/handlers/payment/manager/index.tsx +++ b/src/handlers/payment/manager/index.tsx @@ -2,12 +2,18 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentManagerHandler } from "./create"; +import { createDeletePaymentManagerHandler } from "./delete"; import { createGetPaymentManagerHandler } from "./get"; import { createListPaymentManagersHandler } from "./list"; +import { createUpdatePaymentManagerHandler } from "./update"; export function createPaymentManagerHandler(core: Core, io: AppIO): Router { return new Router("manager", "manage AgentCore payment managers") .default(renderTui(core, io)) + .handler(createCreatePaymentManagerHandler(core, io)) .handler(createGetPaymentManagerHandler(core)) - .handler(createListPaymentManagersHandler(core)); + .handler(createListPaymentManagersHandler(core)) + .handler(createUpdatePaymentManagerHandler(core, io)) + .handler(createDeletePaymentManagerHandler(core)); } diff --git a/src/handlers/payment/manager/update/index.tsx b/src/handlers/payment/manager/update/index.tsx new file mode 100644 index 000000000..d5e7dc32b --- /dev/null +++ b/src/handlers/payment/manager/update/index.tsx @@ -0,0 +1,70 @@ +import type { AuthorizerConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonObjectFlag } from "../../../utils"; +import type { UpdatePaymentManagerInput } from "../../types"; + +// The payment APIs are PATCH-style with no clear wrapper: an omitted flag leaves +// the field unchanged, and there is no way to unset a description, KMS key, or +// authorizer configuration, so the CLI offers no --clear-* flags here. +export const createUpdatePaymentManagerHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update a payment manager", + flags: [ + flag("id", "the payment manager id", z.string().optional()), + flag("description", "updated description", z.string().optional()), + flag( + "authorizer-type", + "updated data-plane authorizer: AWS_IAM or CUSTOM_JWT", + z.enum(["AWS_IAM", "CUSTOM_JWT"]).optional(), + ), + flag( + "authorizer-configuration", + "replacement CUSTOM_JWT configuration (JSON AuthorizerConfiguration; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "role-arn", + "updated IAM role the Payments service assumes", + z.string().min(1).optional(), + ), + flag("kms-key-arn", "updated customer managed KMS key ARN", z.string().min(1).optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + if ( + flags["authorizer-type"] === "AWS_IAM" && + flags["authorizer-configuration"] !== undefined + ) { + throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const authorizerConfiguration = parseJsonObjectFlag( + "authorizer-configuration", + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + ); + + const input: UpdatePaymentManagerInput = { + paymentManagerId: flags.id, + ...(flags.description !== undefined ? { description: flags.description } : {}), + ...(flags["authorizer-type"] ? { authorizerType: flags["authorizer-type"] } : {}), + ...(authorizerConfiguration ? { authorizerConfiguration } : {}), + ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), + ...(flags["kms-key-arn"] ? { kmsKeyArn: flags["kms-key-arn"] } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.updatePaymentManager(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/payment.test.tsx b/src/handlers/payment/payment.test.tsx new file mode 100644 index 000000000..4e2211f42 --- /dev/null +++ b/src/handlers/payment/payment.test.tsx @@ -0,0 +1,413 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; +import { CoreClient } from "../../core"; +import { paymentServiceRoleName } from "../../core/paymentServiceRole"; +import { createRootHandler } from "../index"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; + +// End-to-end command-flow tests for the `payment` subtree's manager leaves. +// +// Each test builds the real root handler over a real CoreClient whose SDK +// clients are the fixture-backed fakes, then drives it through `route()` exactly +// as the CLI does, so one test covers parsing, middleware, the leaf handler, +// PaymentClient, and the rendered output. +// +// Record with: +// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/payment.test.tsx +// The read-only goldens use a manager that already exists in the test account. +// The write flow creates a manager named AgentCoreCliPaymentE2E, provisions its +// default service role, updates it, and deletes the manager again (the role is +// intentionally left in place, as the harness flow leaves its execution role). + +const FIXTURES = join(import.meta.dir, "__fixtures__"); +const REGION = "us-west-2"; +const EXISTING_MANAGER_ID = "mypaymentmanager-o4ks3qfgtb"; +// The write flow records in a second region: the test account's us-west-2 +// payment-manager quota is used up by long-lived bug-bash managers. Fixtures are +// keyed by operation and input, not region, so the two regions never collide. +const WRITE_REGION = "us-east-1"; +// Fixtures are keyed by operation and input, so a `get` issued after the delete +// would overwrite the READY response the earlier readiness poll replays. Reads +// that expect the resource to be gone record into their own directory. +const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); +const E2E_NAME = "AgentCoreCliPaymentE2E"; +// Generous timeouts: in record mode, readiness polls wait on real control-plane +// transitions. Replay never sleeps. +const FLOW_TIMEOUT = 600_000; + +function createFixtureCore(fixtures = FIXTURES): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(fixtures); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +function createRoot(core = createFixtureCore(), io = testIO()) { + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + return { root, io }; +} + +async function run(args: string[], region = REGION, fixtures = FIXTURES): Promise { + const { root, io } = createRoot(createFixtureCore(fixtures)); + await root.route(["node", "agentcore", ...args, "--region", region]); + return io.stdout(); +} + +// pollUntil re-runs `command` until `done(parsed output)` is true. Polling only +// sleeps in record mode; in replay the fixture already holds the settled state +// (the last recorded poll), so the first read satisfies `done`. +async function pollUntil(command: string[], done: (output: any) => boolean): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + const parsed = JSON.parse(await run(command, WRITE_REGION)); + if (done(parsed)) return; + if (!isRecording()) { + throw new Error( + `Replayed fixture for \`${command.join(" ")}\` is not in the awaited state; re-record.`, + ); + } + await Bun.sleep(5_000); + } + throw new Error(`Timed out waiting for \`${command.join(" ")}\``); +} + +// pollUntilGone re-runs a `get` until the service reports the resource missing. +async function pollUntilGone(command: string[]): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + try { + await run(command, WRITE_REGION, AFTER_DELETE_FIXTURES); + } catch (error) { + if (/ResourceNotFound|not found/i.test((error as Error).message)) return; + throw error; + } + if (!isRecording()) { + throw new Error(`Replayed fixture for \`${command.join(" ")}\` still exists; re-record.`); + } + await Bun.sleep(5_000); + } + throw new Error(`Timed out waiting for \`${command.join(" ")}\` to disappear`); +} + +describe("payment command hierarchy", () => { + test("registers manager, connector, session, and instrument sub-routers", () => { + const { root } = createRoot(); + const payment = root.children().find((child) => child.name() === "payment"); + + expect(payment?.children().map((child) => child.name())).toEqual([ + "manager", + "connector", + "session", + "instrument", + ]); + expect( + payment + ?.children() + .find((child) => child.name() === "manager") + ?.children() + .map((child) => child.name()), + ).toEqual(["create", "get", "list", "update", "delete"]); + }); +}); + +describe("payment manager list", () => { + test("prints the listed payment managers as JSON", async () => { + const out = await run(["payment", "manager", "list", "--json"]); + matchGolden(FIXTURES, "manager-list.golden.json", out); + }); + + test("output is valid JSON containing a paymentManagers array", async () => { + const parsed = JSON.parse(await run(["payment", "manager", "list", "--json"])); + expect(Array.isArray(parsed.paymentManagers)).toBe(true); + }); +}); + +describe("payment manager get", () => { + test("prints the manager detail as JSON for a given id", async () => { + const out = await run(["payment", "manager", "get", "--id", EXISTING_MANAGER_ID]); + matchGolden(FIXTURES, "manager-get.golden.json", out); + expect(JSON.parse(out).paymentManagerId).toBe(EXISTING_MANAGER_ID); + }); + + test("errors when --id is omitted", async () => { + await expect(run(["payment", "manager", "get", "--id", ""])).rejects.toThrow(/--id/); + }); +}); + +describe("payment manager write validation", () => { + test.each(["create", "update"] as const)( + "`%s` preserves explicit role and KMS references", + async (command) => { + const factories = fixtureFactories(FIXTURES); + const sdk = mock(() => { + throw new Error("unexpected SDK client creation"); + }); + for (const name of Object.keys(factories) as (keyof typeof factories)[]) { + spyOn(factories, name).mockImplementation(sdk); + } + const send = mock(async () => ({})); + spyOn(factories, "createControlClient").mockReturnValue({ + send, + } as unknown as BedrockAgentCoreControlClient); + const { root } = createRoot(new CoreClient({ ...factories, logger: createSilentLogger() })); + const roleArn = "arn:aws:iam::123456789012:role/PaymentRole"; + const kmsKeyArn = + "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"; + + await root.route([ + "node", + "agentcore", + "payment", + "manager", + command, + ...(command === "create" + ? ["--name", "ExplicitReferences"] + : ["--id", EXISTING_MANAGER_ID]), + "--role-arn", + roleArn, + "--kms-key-arn", + kmsKeyArn, + "--region", + REGION, + ]); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ roleArn, kmsKeyArn }) }), + ); + expect(sdk).not.toHaveBeenCalled(); + }, + ); + + test.each([ + ["create", "role-arn"], + ["create", "kms-key-arn"], + ["update", "role-arn"], + ["update", "kms-key-arn"], + ] as const)("`%s` rejects empty --%s before Core or IO", async (command, flagName) => { + const factories = fixtureFactories(FIXTURES); + const sdk = mock(() => { + throw new Error("unexpected SDK client creation"); + }); + for (const name of Object.keys(factories) as (keyof typeof factories)[]) { + spyOn(factories, name).mockImplementation(sdk); + } + const core = new CoreClient({ ...factories, logger: createSilentLogger() }); + const call = spyOn( + core.payment, + command === "create" ? "createPaymentManager" : "updatePaymentManager", + ); + const read = mock(() => { + throw new Error("unexpected stdin read"); + }); + const stdin = new Readable({ read }); + const io = testIO(); + io.io.stdin = stdin as NodeJS.ReadStream; + const { root } = createRoot(core, io); + + try { + await expect( + root.route([ + "node", + "agentcore", + "payment", + "manager", + command, + ...(command === "create" ? ["--name", "EmptyReference"] : ["--id", EXISTING_MANAGER_ID]), + `--${flagName}`, + "", + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + "-", + "--region", + REGION, + ]), + ).rejects.toThrow(`Invalid value for option '--${flagName}'`); + expect(call).not.toHaveBeenCalled(); + expect(sdk).not.toHaveBeenCalled(); + expect(read).not.toHaveBeenCalled(); + expect(io.stdout()).toBe(""); + expect(io.stderr()).toBe(""); + } finally { + call.mockRestore(); + stdin.destroy(); + } + }); + + // Each write leaf declares its identifying flags optional (so a bare + // invocation can fall through to the TUI once one exists) but requires them + // at runtime. None of these reach the SDK, so no fixtures are involved. + test("`create` errors when --name is omitted", async () => { + await expect(run(["payment", "manager", "create", "--name", ""])).rejects.toThrow(/--name/); + }); + + test("`create` requires --authorizer-configuration for CUSTOM_JWT", async () => { + await expect( + run(["payment", "manager", "create", "--name", "Jwt", "--authorizer-type", "CUSTOM_JWT"]), + ).rejects.toThrow(/CUSTOM_JWT requires --authorizer-configuration/); + }); + + test("`create` rejects --authorizer-configuration for AWS_IAM", async () => { + await expect( + run([ + "payment", + "manager", + "create", + "--name", + "Iam", + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://example.test/.well-known/openid-configuration"}}', + ]), + ).rejects.toThrow(/valid only with CUSTOM_JWT/); + }); + + test("`create` rejects a malformed --authorizer-configuration", async () => { + await expect( + run([ + "payment", + "manager", + "create", + "--name", + "Jwt", + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + "{not json", + ]), + ).rejects.toThrow(/Invalid JSON for option '--authorizer-configuration'/); + }); + + test("`create` rejects a malformed tag", async () => { + await expect( + run(["payment", "manager", "create", "--name", "Tagged", "--tags", "novalue"]), + ).rejects.toThrow(/Invalid tag/); + }); + + test("`update` errors when --id is omitted", async () => { + await expect(run(["payment", "manager", "update", "--id", ""])).rejects.toThrow(/--id/); + }); + + test("`update` rejects --authorizer-configuration together with AWS_IAM", async () => { + await expect( + run([ + "payment", + "manager", + "update", + "--id", + "m-1", + "--authorizer-type", + "AWS_IAM", + "--authorizer-configuration", + "{}", + ]), + ).rejects.toThrow(/valid only with CUSTOM_JWT/); + }); + + test("`delete` errors when --id is omitted", async () => { + await expect(run(["payment", "manager", "delete", "--id", ""])).rejects.toThrow(/--id/); + }); +}); + +// ─── write flow (create → update → delete) ─────────────────────────────────── +// +// Drives the lifecycle of a real payment manager, in order, through route(). +// In record mode it hits the live control plane (and IAM for the default +// service role) and persists every exchange; replays are offline and instant. +// Later tests consume the id parsed from earlier output. + +const state: { managerId?: string } = {}; + +describe("payment manager write flow", () => { + test( + "`create` provisions a default service role and creates the manager", + async () => { + const out = await run( + [ + "payment", + "manager", + "create", + "--name", + E2E_NAME, + "--description", + "Created by the agentcore CLI end-to-end test", + "--tags", + "created-by=agentcore-cli-e2e", + ], + WRITE_REGION, + ); + matchGolden(FIXTURES, "manager-create.golden.json", out); + + const parsed = JSON.parse(out); + expect(parsed.name).toBe(E2E_NAME); + expect(parsed.authorizerType).toBe("AWS_IAM"); + // No --role-arn was passed: the default service role was provisioned. + expect(parsed.roleArn).toContain(paymentServiceRoleName(E2E_NAME, WRITE_REGION)); + expect(parsed.paymentManagerId).toBeDefined(); + state.managerId = parsed.paymentManagerId; + + await pollUntil( + ["payment", "manager", "get", "--id", state.managerId!], + (o) => o.status === "READY", + ); + }, + FLOW_TIMEOUT, + ); + + test( + "`update` changes the description", + async () => { + const out = await run( + [ + "payment", + "manager", + "update", + "--id", + state.managerId!, + "--description", + "Updated by the agentcore CLI end-to-end test", + ], + WRITE_REGION, + ); + matchGolden(FIXTURES, "manager-update.golden.json", out); + expect(JSON.parse(out).paymentManagerId).toBe(state.managerId); + + await pollUntil( + ["payment", "manager", "get", "--id", state.managerId!], + (o) => o.status === "READY" && /Updated by/.test(o.description ?? ""), + ); + }, + FLOW_TIMEOUT, + ); + + test( + "`delete` deletes the manager", + async () => { + const out = await run( + ["payment", "manager", "delete", "--id", state.managerId!], + WRITE_REGION, + ); + matchGolden(FIXTURES, "manager-delete.golden.json", out); + expect(JSON.parse(out).status).toBe("DELETING"); + + await pollUntilGone(["payment", "manager", "get", "--id", state.managerId!]); + }, + FLOW_TIMEOUT, + ); +}); From c340575782a588034fedeb640a87c48590df9879 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 20:04:35 +0000 Subject: [PATCH 03/10] feat(payment): add connector mutations and Quick Create --- ...mentConnectorCommand.3a23138a2103205b.json | 12 + ...mentConnectorCommand.8ee89f4fdcbd5119.json | 17 + ...mentConnectorCommand.1c6bed13c7d0db3a.json | 4 + ...ymentConnectorCommand.9f8dfd59b8af870.json | 4 + ...mentConnectorCommand.f54471b4c372f9aa.json | 17 + ...mentConnectorCommand.1c6bed13c7d0db3a.json | 6 + ...ymentConnectorCommand.9f8dfd59b8af870.json | 6 + .../connector/connector-create.golden.json | 15 + .../connector/connector-delete.golden.json | 4 + .../connector-quick-create.golden.json | 10 + .../connector-quick-delete.golden.json | 4 + .../connector/connector-update.golden.json | 15 + .../payment/connector/connector.test.tsx | 839 ++++++++++++++++++ .../payment/connector/create/index.tsx | 98 ++ .../payment/connector/delete/index.tsx | 38 + src/handlers/payment/connector/get/index.tsx | 3 +- src/handlers/payment/connector/index.tsx | 8 +- .../payment/connector/update/index.tsx | 51 ++ 18 files changed, 1149 insertions(+), 2 deletions(-) create mode 100644 src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json create mode 100644 src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json create mode 100644 src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json create mode 100644 src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json create mode 100644 src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json create mode 100644 src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json create mode 100644 src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json create mode 100644 src/handlers/payment/__fixtures__/connector/connector-create.golden.json create mode 100644 src/handlers/payment/__fixtures__/connector/connector-delete.golden.json create mode 100644 src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json create mode 100644 src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json create mode 100644 src/handlers/payment/__fixtures__/connector/connector-update.golden.json create mode 100644 src/handlers/payment/connector/connector.test.tsx create mode 100644 src/handlers/payment/connector/create/index.tsx create mode 100644 src/handlers/payment/connector/delete/index.tsx create mode 100644 src/handlers/payment/connector/update/index.tsx diff --git a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json new file mode 100644 index 000000000..df9a2ae99 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json @@ -0,0 +1,12 @@ +{ + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliQuickE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [], + "createdAt": { + "$date": "2026-09-08T20:14:33.909Z" + }, + "status": "PENDING_AUTHENTICATION", + "authorizationUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3ANmI3ZmI4ZGMtZTVhNi00YTVlLWE5NzctMjY1MjQ0MGE1NWIy" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json new file mode 100644 index 000000000..ffa9e78e2 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json @@ -0,0 +1,17 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "createdAt": { + "$date": "2026-09-08T20:14:31.151Z" + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json new file mode 100644 index 000000000..d4c2477cb --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json new file mode 100644 index 000000000..9633a3865 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json b/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json new file mode 100644 index 000000000..033f90717 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json @@ -0,0 +1,17 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "lastUpdatedAt": { + "$date": "2026-09-08T20:14:32.139Z" + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json new file mode 100644 index 000000000..d79a3c462 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment connector not found: agentcorecliquicke2e-wolx3aywni" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json new file mode 100644 index 000000000..bfadee775 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment connector not found: agentcorecliconnectore2e-6rodjuiuig" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-create.golden.json b/src/handlers/payment/__fixtures__/connector/connector-create.golden.json new file mode 100644 index 000000000..878ab3090 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-create.golden.json @@ -0,0 +1,15 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "createdAt": "2026-09-08T20:14:31.151Z", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json b/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json new file mode 100644 index 000000000..9633a3865 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json b/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json new file mode 100644 index 000000000..4fd5f5006 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json @@ -0,0 +1,10 @@ +{ + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliQuickE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [], + "createdAt": "2026-09-08T20:14:33.909Z", + "status": "PENDING_AUTHENTICATION", + "authorizationUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3ANmI3ZmI4ZGMtZTVhNi00YTVlLWE5NzctMjY1MjQ0MGE1NWIy" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json b/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json new file mode 100644 index 000000000..d4c2477cb --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-update.golden.json b/src/handlers/payment/__fixtures__/connector/connector-update.golden.json new file mode 100644 index 000000000..4d369b25f --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-update.golden.json @@ -0,0 +1,15 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "lastUpdatedAt": "2026-09-08T20:14:32.139Z", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/connector/connector.test.tsx b/src/handlers/payment/connector/connector.test.tsx new file mode 100644 index 000000000..45751edc3 --- /dev/null +++ b/src/handlers/payment/connector/connector.test.tsx @@ -0,0 +1,839 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { + CreatePaymentConnectorCommand, + GetPaymentConnectorCommand, + GetPaymentCredentialProviderCommand, + UpdatePaymentConnectorCommand, + type BedrockAgentCoreControlClient, + type CreatePaymentConnectorResponse, + type GetPaymentConnectorResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { CoreClient } from "../../../core"; +import type { ClientConfig } from "../../../core/types"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + parse, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import quickCreateFixture from "../__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json"; + +// End-to-end command-flow tests for the `payment connector` leaves. +// +// Each test builds the real root handler over a real CoreClient whose SDK +// clients are the fixture-backed fakes, then drives it through `route()` exactly +// as the CLI does, so one test covers parsing, middleware, the leaf handler, +// PaymentClient (including its credential-provider lookup in identity), and the +// rendered output. +// +// Record with: +// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/connector/connector.test.tsx +// The flows use a payment manager and a CoinbaseCDP payment credential provider +// that already exist in the test account (the account's manager quota is +// exhausted, so none is created here). The manual flow creates a connector named +// AgentCoreCliConnectorE2E from the named provider, updates it, and deletes it; +// the Quick Create flow creates AgentCoreCliQuickE2E and deletes it without +// completing the OAuth consent. + +const FIXTURES = join(import.meta.dir, "..", "__fixtures__", "connector"); +// A fixture is keyed by operation and request, and every `get` of one connector +// sends the same request, so the readiness polls and the post-delete not-found +// reads would overwrite each other. The post-delete reads record to a sibling +// directory so both settled states replay. +const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); +const REGION = "us-west-2"; +const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; +const CREDENTIAL_PROVIDER = "MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp"; +const MANUAL_NAME = "AgentCoreCliConnectorE2E"; +const QUICK_NAME = "AgentCoreCliQuickE2E"; +// Generous timeouts: in record mode, readiness polls wait on real control-plane +// transitions. Replay never sleeps. +const FLOW_TIMEOUT = 600_000; + +function createFixtureCore(dir = FIXTURES): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(dir); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +// createFakedControlCore swaps the control plane's `.send()` for `send` while +// keeping the real CoreClient and PaymentClient in the loop. Used for connector +// states a recording cannot reach on demand (an expired consent window). +function createFakedControlCore(send: (command: unknown) => Promise): CoreClient { + const { createDataClient, createIamClient, createLogsClient } = fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient: () => ({ send }) as unknown as BedrockAgentCoreControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +function createRoot(core = createFixtureCore()) { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + return { root, io }; +} + +async function runCapturing( + args: string[], + core?: CoreClient, +): Promise<{ stdout: string; stderr: string }> { + const { root, io } = createRoot(core); + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return { stdout: io.stdout(), stderr: io.stderr() }; +} + +async function run(args: string[], core?: CoreClient): Promise { + return (await runCapturing(args, core)).stdout; +} + +// pollUntil re-runs `command` until `done(parsed output)` is true. Polling only +// sleeps in record mode; in replay the fixture already holds the settled state +// (the last recorded poll), so the first read satisfies `done`. +async function pollUntil(command: string[], done: (output: any) => boolean): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + const parsed = JSON.parse(await run(command)); + if (done(parsed)) return; + if (!isRecording()) { + throw new Error( + `Replayed fixture for \`${command.join(" ")}\` is not in the awaited state; re-record.`, + ); + } + await Bun.sleep(5_000); + } + throw new Error(`Timed out waiting for \`${command.join(" ")}\``); +} + +// pollUntilGone re-runs a `get` until the service reports the resource missing. +async function pollUntilGone(command: string[]): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + try { + await run(command, createFixtureCore(AFTER_DELETE_FIXTURES)); + } catch (error) { + if (/ResourceNotFound|not found/i.test((error as Error).message)) return; + throw error; + } + if (!isRecording()) { + throw new Error(`Replayed fixture for \`${command.join(" ")}\` still exists; re-record.`); + } + await Bun.sleep(5_000); + } + throw new Error(`Timed out waiting for \`${command.join(" ")}\` to disappear`); +} + +describe("payment connector command hierarchy", () => { + test("registers the five connector leaves", () => { + const { root } = createRoot(); + const connector = root + .children() + .find((child) => child.name() === "payment") + ?.children() + .find((child) => child.name() === "connector"); + + expect(connector?.children().map((child) => child.name())).toEqual([ + "create", + "get", + "list", + "update", + "delete", + ]); + }); +}); + +describe("payment connector flag validation", () => { + test.each([ + ["create", "name"], + ["create", "ARN"], + ["update", "name"], + ["update", "ARN"], + ] as const)("`%s` accepts a credential-provider %s", async (command, referenceType) => { + const providerArn = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/paymentcredentialprovider/provider"; + const commands: unknown[] = []; + const core = createFakedControlCore(async (request) => { + commands.push(request); + if (request instanceof GetPaymentCredentialProviderCommand) { + expect(request.input.name).toBe(CREDENTIAL_PROVIDER); + return { + credentialProviderArn: providerArn, + credentialProviderVendor: "CoinbaseCDP", + }; + } + if (request instanceof GetPaymentConnectorCommand) return connectorDetail("READY"); + if ( + request instanceof CreatePaymentConnectorCommand || + request instanceof UpdatePaymentConnectorCommand + ) { + return connectorDetail("READY"); + } + throw new Error("unexpected SDK command"); + }); + + await run( + [ + "payment", + "connector", + command, + "--manager-id", + MANAGER_ID, + ...(command === "create" ? ["--name", MANUAL_NAME] : ["--connector-id", "c-1"]), + ...(command === "create" && referenceType === "ARN" ? ["--type", "CoinbaseCDP"] : []), + "--credential-provider", + referenceType === "ARN" ? providerArn : CREDENTIAL_PROVIDER, + ], + core, + ); + + expect(commands).toEqual([ + ...(command === "update" ? [expect.any(GetPaymentConnectorCommand)] : []), + ...(referenceType === "name" ? [expect.any(GetPaymentCredentialProviderCommand)] : []), + expect.any( + command === "create" ? CreatePaymentConnectorCommand : UpdatePaymentConnectorCommand, + ), + ]); + expect(commands.at(-1)).toMatchObject({ + input: { + credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: providerArn } }], + }, + }); + }); + + test.each(["create", "update"] as const)( + "`%s` rejects empty --credential-provider before Core or SDK calls", + async (command) => { + const factories = fixtureFactories(FIXTURES); + const sdk = mock(() => { + throw new Error("unexpected SDK client creation"); + }); + for (const name of Object.keys(factories) as (keyof typeof factories)[]) { + spyOn(factories, name).mockImplementation(sdk); + } + const core = new CoreClient({ ...factories, logger: createSilentLogger() }); + const call = spyOn( + core.payment, + command === "create" ? "createPaymentConnector" : "updatePaymentConnector", + ); + const { root, io } = createRoot(core); + + try { + await expect( + root.route([ + "node", + "agentcore", + "payment", + "connector", + command, + "--manager-id", + MANAGER_ID, + ...(command === "create" ? ["--name", "EmptyReference"] : ["--connector-id", "c-1"]), + "--credential-provider", + "", + "--region", + REGION, + ]), + ).rejects.toThrow("Invalid value for option '--credential-provider'"); + expect(call).not.toHaveBeenCalled(); + expect(sdk).not.toHaveBeenCalled(); + expect(io.stdout()).toBe(""); + expect(io.stderr()).toBe(""); + } finally { + call.mockRestore(); + } + }, + ); + + // Each leaf declares its identifying flags optional (so a bare invocation can + // fall through to the TUI once one exists) but requires them at runtime. None + // of these reach the SDK, so no fixtures are involved. + test("`create` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "connector", "create", "--manager-id", "", "--name", "X", "--quick-create"]), + ).rejects.toThrow("required option '--manager-id ' not specified"); + }); + + test("`create` errors when --name is omitted", async () => { + await expect( + run([ + "payment", + "connector", + "create", + "--manager-id", + "m-1", + "--name", + "", + "--quick-create", + ]), + ).rejects.toThrow("required option '--name ' not specified"); + }); + + test("`create` rejects --quick-create together with --credential-provider", async () => { + await expect( + run([ + "payment", + "connector", + "create", + "--manager-id", + "m-1", + "--name", + "Both", + "--quick-create", + "--credential-provider", + "some-provider", + ]), + ).rejects.toThrow("specify exactly one of '--quick-create' or '--credential-provider'"); + }); + + test("`create` rejects neither --quick-create nor --credential-provider", async () => { + await expect( + run(["payment", "connector", "create", "--manager-id", "m-1", "--name", "Neither"]), + ).rejects.toThrow("specify exactly one of '--quick-create' or '--credential-provider'"); + }); + + test("`create` rejects an unsupported --type", async () => { + await expect( + run([ + "payment", + "connector", + "create", + "--manager-id", + "m-1", + "--name", + "Bad", + "--type", + "Paypal", + "--quick-create", + ]), + ).rejects.toThrow(/Invalid value for option '--type'/); + }); + + test("`create --type StripePrivy --quick-create` surfaces the Core validation error", async () => { + await expect( + run([ + "payment", + "connector", + "create", + "--manager-id", + "m-1", + "--name", + "Stripe", + "--type", + "StripePrivy", + "--quick-create", + ]), + ).rejects.toThrow("Quick Create is available only for CoinbaseCDP connectors, not StripePrivy"); + }); + + test("`get` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "connector", "get", "--manager-id", "", "--connector-id", "c-1"]), + ).rejects.toThrow("required option '--manager-id ' not specified"); + }); + + test("`get` errors when --connector-id is omitted", async () => { + await expect( + run(["payment", "connector", "get", "--manager-id", "m-1", "--connector-id", ""]), + ).rejects.toThrow("required option '--connector-id ' not specified"); + }); + + test("`list` errors when --manager-id is omitted", async () => { + await expect(run(["payment", "connector", "list", "--manager-id", ""])).rejects.toThrow( + "required option '--manager-id ' not specified", + ); + }); + + test("`update` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "connector", "update", "--manager-id", "", "--connector-id", "c-1"]), + ).rejects.toThrow("required option '--manager-id ' not specified"); + }); + + test("`update` errors when --connector-id is omitted", async () => { + await expect( + run(["payment", "connector", "update", "--manager-id", "m-1", "--connector-id", ""]), + ).rejects.toThrow("required option '--connector-id ' not specified"); + }); + + test("`update` does not offer --type", async () => { + await expect( + run([ + "payment", + "connector", + "update", + "--manager-id", + "m-1", + "--connector-id", + "c-1", + "--type", + "StripePrivy", + ]), + ).rejects.toThrow(/unknown option '--type'/); + }); + + test("`delete` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "connector", "delete", "--manager-id", "", "--connector-id", "c-1"]), + ).rejects.toThrow("required option '--manager-id ' not specified"); + }); + + test("`delete` errors when --connector-id is omitted", async () => { + await expect( + run(["payment", "connector", "delete", "--manager-id", "m-1", "--connector-id", ""]), + ).rejects.toThrow("required option '--connector-id ' not specified"); + }); +}); + +// ─── stderr hints against a faked control plane ────────────────────────────── +// +// The consent-window states are not reachable on demand in a recording (a +// Quick Create connector expires ten minutes after creation), so the control +// plane is faked at .send() while the real PaymentClient and handlers run. + +const AUTHORIZATION_URL = "https://login.coinbase.com/oauth2/auth?client_id=agentcore&state=abc"; + +function connectorDetail( + status: GetPaymentConnectorResponse["status"], +): GetPaymentConnectorResponse { + return { + paymentConnectorId: "quick-abc123", + name: QUICK_NAME, + type: "CoinbaseCDP", + credentialProviderConfigurations: [], + createdAt: new Date("2026-09-01T00:00:00.000Z"), + lastUpdatedAt: new Date("2026-09-01T00:00:00.000Z"), + status, + }; +} + +function coreReturningConnector(detail: GetPaymentConnectorResponse): CoreClient { + return createFakedControlCore(async (command) => { + if (command instanceof GetPaymentConnectorCommand) return detail; + throw new Error(`unexpected command ${(command as object).constructor.name}`); + }); +} + +function coreCreatingPendingConnector(): CoreClient { + const created: CreatePaymentConnectorResponse = { + ...connectorDetail("PENDING_AUTHENTICATION"), + paymentManagerId: MANAGER_ID, + authorizationUrl: AUTHORIZATION_URL, + }; + return createFakedControlCore(async (command) => { + if (command instanceof CreatePaymentConnectorCommand) return created; + throw new Error(`unexpected command ${(command as object).constructor.name}`); + }); +} + +describe("payment connector hints", () => { + const getArgs = [ + "payment", + "connector", + "get", + "--manager-id", + MANAGER_ID, + "--connector-id", + "quick-abc123", + ]; + + test("`get` prints a re-create hint on stderr for AUTHENTICATION_EXPIRED", async () => { + const { stdout, stderr } = await runCapturing( + getArgs, + coreReturningConnector(connectorDetail("AUTHENTICATION_EXPIRED")), + ); + expect(JSON.parse(stdout).status).toBe("AUTHENTICATION_EXPIRED"); + expect(stderr).toContain("cannot be renewed"); + expect(stderr).toContain("create it again with --quick-create"); + }); + + test("`get` prints the same hint for AUTHENTICATION_FAILED", async () => { + const { stdout, stderr } = await runCapturing( + getArgs, + coreReturningConnector(connectorDetail("AUTHENTICATION_FAILED")), + ); + expect(JSON.parse(stdout).status).toBe("AUTHENTICATION_FAILED"); + expect(stderr).toContain("create it again with --quick-create"); + }); + + test("`get` prints no hint for a READY connector", async () => { + const { stderr } = await runCapturing( + getArgs, + coreReturningConnector(connectorDetail("READY")), + ); + expect(stderr).toBe(""); + }); + + test("`get --json` suppresses the hint", async () => { + const { stdout, stderr } = await runCapturing( + [...getArgs, "--json"], + coreReturningConnector(connectorDetail("AUTHENTICATION_EXPIRED")), + ); + expect(JSON.parse(stdout).status).toBe("AUTHENTICATION_EXPIRED"); + expect(stderr).toBe(""); + }); + + const createArgs = [ + "payment", + "connector", + "create", + "--manager-id", + MANAGER_ID, + "--name", + QUICK_NAME, + "--quick-create", + ]; + + test("`create --quick-create` prints the authorization hint on stderr", async () => { + const { stdout, stderr } = await runCapturing(createArgs, coreCreatingPendingConnector()); + const parsed = JSON.parse(stdout); + expect(parsed.status).toBe("PENDING_AUTHENTICATION"); + expect(parsed.authorizationUrl).toBe(AUTHORIZATION_URL); + expect(stderr).toContain(AUTHORIZATION_URL); + expect(stderr).toContain("10 minutes"); + expect(stderr).toContain( + `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id quick-abc123`, + ); + }); + + test.each([ + { + label: "explicit region", + regionArgs: ["--region", "eu-west-1"], + environmentRegion: "us-east-1", + endpointUrl: undefined, + }, + { + label: "resolved environment region", + regionArgs: [], + environmentRegion: "eu-west-1", + endpointUrl: undefined, + }, + { + label: "endpoint containing URL punctuation, spaces, and a quote", + regionArgs: ["--region", "eu-west-1"], + environmentRegion: "us-east-1", + endpointUrl: "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", + }, + ])( + "`create --quick-create` follow-up preserves $label", + async ({ regionArgs, environmentRegion, endpointUrl }) => { + const savedRegion = process.env.AWS_REGION; + const configs: ClientConfig[] = []; + const getRequests: GetPaymentConnectorCommand["input"][] = []; + const factories = fixtureFactories(FIXTURES); + const coreOptions = { + ...factories, + createControlClient: (config: ClientConfig) => { + configs.push(config); + return { + send: async (command: unknown) => { + if (command instanceof CreatePaymentConnectorCommand) { + return parse(JSON.stringify(quickCreateFixture)); + } + if (command instanceof GetPaymentConnectorCommand) { + getRequests.push(command.input); + return parse( + JSON.stringify({ + ...quickCreateFixture, + status: "READY", + lastUpdatedAt: quickCreateFixture.createdAt, + }), + ); + } + throw new Error("unexpected command in Quick Create hint test"); + }, + } as unknown as BedrockAgentCoreControlClient; + }, + logger: createSilentLogger(), + }; + try { + process.env.AWS_REGION = environmentRegion; + const created = createRoot(new CoreClient(coreOptions)); + await created.root.route([ + "node", + "agentcore", + ...createArgs, + ...regionArgs, + ...(endpointUrl === undefined ? [] : ["--endpoint-url", endpointUrl]), + ]); + expect(configs).toEqual([{ region: "eu-west-1", endpoint: endpointUrl }]); + + const command = created.io.stderr().match(/`(agentcore payment connector get [^`]+)`/)?.[1]; + expect(command).toBeDefined(); + // Parse the displayed command with a shell without invoking the installed CLI. + const argv = execFileSync("sh", ["-c", `set -- ${command}\nprintf '%s\\0' "$@"`], { + encoding: "utf8", + }) + .split("\0") + .slice(0, -1); + + process.env.AWS_REGION = "us-east-1"; + const followUp = createRoot(new CoreClient(coreOptions)); + await followUp.root.route(["node", ...argv]); + expect(getRequests).toEqual([ + { + paymentManagerId: MANAGER_ID, + paymentConnectorId: quickCreateFixture.paymentConnectorId, + }, + ]); + expect(configs).toEqual([ + { region: "eu-west-1", endpoint: endpointUrl }, + { region: "eu-west-1", endpoint: endpointUrl }, + ]); + expect(JSON.parse(followUp.io.stdout()).status).toBe("READY"); + expect(followUp.io.stderr()).toBe(""); + if (endpointUrl === undefined) expect(command).not.toContain("--endpoint-url"); + } finally { + if (savedRegion === undefined) delete process.env.AWS_REGION; + else process.env.AWS_REGION = savedRegion; + } + }, + ); + + test.each([ + undefined, + "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", + ])("`create --quick-create --json` suppresses the hint with endpoint %j", async (endpointUrl) => { + const { stdout, stderr } = await runCapturing( + [ + ...createArgs, + ...(endpointUrl === undefined ? [] : ["--endpoint-url", endpointUrl]), + "--json", + ], + coreCreatingPendingConnector(), + ); + expect(JSON.parse(stdout).authorizationUrl).toBe(AUTHORIZATION_URL); + expect(stderr).toBe(""); + }); +}); + +// ─── manual flow (create → get → list → update → delete) ───────────────────── +// +// Drives the lifecycle of a real connector backed by an existing CoinbaseCDP +// payment credential provider, in order, through route(). In record mode it hits +// the live control plane and persists every exchange; replays are offline and +// instant. Later tests consume the id parsed from earlier output. + +const state: { connectorId?: string; quickConnectorId?: string } = {}; + +describe("payment connector manual flow", () => { + test( + "`create` infers the type from the named credential provider", + async () => { + const out = await run([ + "payment", + "connector", + "create", + "--manager-id", + MANAGER_ID, + "--name", + MANUAL_NAME, + "--description", + "Created by the agentcore CLI end-to-end test", + "--credential-provider", + CREDENTIAL_PROVIDER, + ]); + matchGolden(FIXTURES, "connector-create.golden.json", out); + + const parsed = JSON.parse(out); + expect(parsed.name).toBe(MANUAL_NAME); + // No --type was passed: the vendor of the named provider decided it. + expect(parsed.type).toBe("CoinbaseCDP"); + expect(parsed.paymentConnectorId).toBeDefined(); + state.connectorId = parsed.paymentConnectorId; + + await pollUntil( + [ + "payment", + "connector", + "get", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.connectorId!, + ], + (o) => o.status === "READY", + ); + }, + FLOW_TIMEOUT, + ); + + test("`list` includes the connector", async () => { + const out = await run(["payment", "connector", "list", "--manager-id", MANAGER_ID]); + matchGolden(FIXTURES, "connector-list.golden.json", out); + + const parsed = JSON.parse(out); + expect(Array.isArray(parsed.paymentConnectors)).toBe(true); + expect( + parsed.paymentConnectors.map((c: { paymentConnectorId: string }) => c.paymentConnectorId), + ).toContain(state.connectorId); + }); + + test( + "`update` changes the description", + async () => { + const out = await run([ + "payment", + "connector", + "update", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.connectorId!, + "--description", + "Updated by the agentcore CLI end-to-end test", + ]); + matchGolden(FIXTURES, "connector-update.golden.json", out); + expect(JSON.parse(out).paymentConnectorId).toBe(state.connectorId); + + await pollUntil( + [ + "payment", + "connector", + "get", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.connectorId!, + ], + (o) => o.status === "READY" && /Updated by/.test(o.description ?? ""), + ); + }, + FLOW_TIMEOUT, + ); + + // Sits after `update` on purpose: every `get` of this connector shares one + // fixture, which holds the last recorded (post-update) state. + test("`get` prints the connector detail as JSON", async () => { + const { stdout, stderr } = await runCapturing([ + "payment", + "connector", + "get", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.connectorId!, + ]); + matchGolden(FIXTURES, "connector-get.golden.json", stdout); + + const parsed = JSON.parse(stdout); + expect(parsed.paymentConnectorId).toBe(state.connectorId); + expect(parsed.status).toBe("READY"); + expect(parsed.description).toBe("Updated by the agentcore CLI end-to-end test"); + expect(parsed.credentialProviderConfigurations[0].coinbaseCDP.credentialProviderArn).toContain( + CREDENTIAL_PROVIDER, + ); + expect(stderr).toBe(""); + }); + + test( + "`delete` deletes the connector", + async () => { + const out = await run([ + "payment", + "connector", + "delete", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.connectorId!, + ]); + matchGolden(FIXTURES, "connector-delete.golden.json", out); + expect(JSON.parse(out).status).toBe("DELETING"); + + await pollUntilGone([ + "payment", + "connector", + "get", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.connectorId!, + ]); + }, + FLOW_TIMEOUT, + ); +}); + +// ─── Quick Create flow (create → delete) ───────────────────────────────────── +// +// Quick Create asks Coinbase to provision the credentials after OAuth consent. +// The consent is never completed here: the test only checks that the CLI hands +// back the authorization URL and cleans the pending connector up again. + +describe("payment connector quick create flow", () => { + test( + "`create --quick-create` returns a pending connector with an authorization URL", + async () => { + const { stdout, stderr } = await runCapturing([ + "payment", + "connector", + "create", + "--manager-id", + MANAGER_ID, + "--name", + QUICK_NAME, + "--quick-create", + ]); + matchGolden(FIXTURES, "connector-quick-create.golden.json", stdout); + + const parsed = JSON.parse(stdout); + expect(parsed.name).toBe(QUICK_NAME); + expect(parsed.type).toBe("CoinbaseCDP"); + expect(parsed.status).toBe("PENDING_AUTHENTICATION"); + expect(parsed.authorizationUrl).toMatch(/^https:\/\//); + expect(parsed.paymentConnectorId).toBeDefined(); + state.quickConnectorId = parsed.paymentConnectorId; + + expect(stderr).toContain(parsed.authorizationUrl); + expect(stderr).toContain( + `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${state.quickConnectorId}`, + ); + }, + FLOW_TIMEOUT, + ); + + test( + "`delete` removes the pending connector", + async () => { + const out = await run([ + "payment", + "connector", + "delete", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.quickConnectorId!, + ]); + matchGolden(FIXTURES, "connector-quick-delete.golden.json", out); + expect(JSON.parse(out).status).toBe("DELETING"); + + await pollUntilGone([ + "payment", + "connector", + "get", + "--manager-id", + MANAGER_ID, + "--connector-id", + state.quickConnectorId!, + ]); + }, + FLOW_TIMEOUT, + ); +}); diff --git a/src/handlers/payment/connector/create/index.tsx b/src/handlers/payment/connector/create/index.tsx new file mode 100644 index 000000000..9dbcf31d7 --- /dev/null +++ b/src/handlers/payment/connector/create/index.tsx @@ -0,0 +1,98 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import { JsonKey } from "../../../keys"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import type { CreatePaymentConnectorInput } from "../../types"; + +export const createCreatePaymentConnectorHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a payment connector under a payment manager", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("name", "the payment connector name", z.string().optional()), + flag("description", "payment connector description", z.string().optional()), + flag( + "type", + "connector type: CoinbaseCDP or StripePrivy (inferred from a credential provider name; required with an ARN)", + z.enum(["CoinbaseCDP", "StripePrivy"]).optional(), + ), + flag( + "credential-provider", + "payment credential provider name or ARN that backs the connector", + z.string().min(1).optional(), + ), + flag( + "quick-create", + "let Coinbase provision the credentials after OAuth consent (CoinbaseCDP only)", + z.boolean().default(false), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + // Required at runtime but declared optional so that a bare invocation can + // fall through to the TUI once a screen exists. + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if (flags["quick-create"] === (flags["credential-provider"] !== undefined)) { + throw new InputValidationError( + "specify exactly one of '--quick-create' or '--credential-provider'", + ); + } + + const input: CreatePaymentConnectorInput = { + managerId: flags["manager-id"], + name: flags.name, + ...(flags.description ? { description: flags.description } : {}), + ...(flags.type ? { type: flags.type } : {}), + ...(flags["credential-provider"] + ? { credentialProvider: flags["credential-provider"] } + : {}), + ...(flags["quick-create"] ? { quickCreate: true } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + const options = coreOptsFromCtx(ctx); + const response = await core.payment.createPaymentConnector(input, options); + ctx.require(JsonRendererKey).renderJson(response); + + // Quick Create leaves the connector waiting on OAuth consent; the URL is + // in the JSON, but a scripted caller does not need the walkthrough. + if ( + !ctx.require(JsonKey) && + response.status === "PENDING_AUTHENTICATION" && + response.authorizationUrl + ) { + const command = [ + "agentcore", + "payment", + "connector", + "get", + "--manager-id", + flags["manager-id"], + "--connector-id", + response.paymentConnectorId ?? "", + "--region", + options.region, + ...(options.endpointUrl !== undefined ? ["--endpoint-url", options.endpointUrl] : []), + ] + .map((value) => + /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`, + ) + .join(" "); + io.stderr.write( + `Open ${response.authorizationUrl} within about 10 minutes to authorize with Coinbase, then run ` + + `\`${command}\` ` + + "to confirm the connector is READY.\n", + ); + } + }, + }); diff --git a/src/handlers/payment/connector/delete/index.tsx b/src/handlers/payment/connector/delete/index.tsx new file mode 100644 index 000000000..8f44c0b84 --- /dev/null +++ b/src/handlers/payment/connector/delete/index.tsx @@ -0,0 +1,38 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeletePaymentConnectorHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment connector", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("connector-id", "the payment connector id", z.string().optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + + ctx.require(JsonRendererKey).renderJson( + await core.payment.deletePaymentConnector( + { + paymentManagerId: flags["manager-id"], + paymentConnectorId: flags["connector-id"], + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }, + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/payment/connector/get/index.tsx b/src/handlers/payment/connector/get/index.tsx index 54a548761..d8cee86ab 100644 --- a/src/handlers/payment/connector/get/index.tsx +++ b/src/handlers/payment/connector/get/index.tsx @@ -40,7 +40,8 @@ export const createGetPaymentConnectorHandler = (core: Core, io: AppIO) => response.status === "AUTHENTICATION_FAILED") ) { io.stderr.write( - `warning: connector status is ${response.status}; its authorization URL cannot be renewed.\n`, + `warning: the authorization URL of a ${response.status} connector cannot be renewed; ` + + "delete this connector and create it again with --quick-create.\n", ); } }, diff --git a/src/handlers/payment/connector/index.tsx b/src/handlers/payment/connector/index.tsx index b66523054..82a077d05 100644 --- a/src/handlers/payment/connector/index.tsx +++ b/src/handlers/payment/connector/index.tsx @@ -2,12 +2,18 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentConnectorHandler } from "./create"; +import { createDeletePaymentConnectorHandler } from "./delete"; import { createGetPaymentConnectorHandler } from "./get"; import { createListPaymentConnectorsHandler } from "./list"; +import { createUpdatePaymentConnectorHandler } from "./update"; export function createPaymentConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "manage connectors under a payment manager") .default(renderTui(core, io)) + .handler(createCreatePaymentConnectorHandler(core, io)) .handler(createGetPaymentConnectorHandler(core, io)) - .handler(createListPaymentConnectorsHandler(core)); + .handler(createListPaymentConnectorsHandler(core)) + .handler(createUpdatePaymentConnectorHandler(core)) + .handler(createDeletePaymentConnectorHandler(core)); } diff --git a/src/handlers/payment/connector/update/index.tsx b/src/handlers/payment/connector/update/index.tsx new file mode 100644 index 000000000..de6e97b9a --- /dev/null +++ b/src/handlers/payment/connector/update/index.tsx @@ -0,0 +1,51 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import type { UpdatePaymentConnectorInput } from "../../types"; + +// No --type flag: the service rejects any change to a connector's type after +// creation. Like the manager leaf, an omitted flag leaves the field unchanged +// and there is no way to unset a description, so no --clear-* flags either. +export const createUpdatePaymentConnectorHandler = (core: Core) => + createHandler({ + name: "update", + description: "update a payment connector", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("connector-id", "the payment connector id", z.string().optional()), + flag("description", "updated description", z.string().optional()), + flag( + "credential-provider", + "replacement payment credential provider name or ARN (must match the connector type)", + z.string().min(1).optional(), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + + const input: UpdatePaymentConnectorInput = { + managerId: flags["manager-id"], + connectorId: flags["connector-id"], + ...(flags.description !== undefined ? { description: flags.description } : {}), + ...(flags["credential-provider"] + ? { credentialProvider: flags["credential-provider"] } + : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.updatePaymentConnector(input, coreOptsFromCtx(ctx))); + }, + }); From cd4ae51be51c674a029a0a1387a5a644b6d5420e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 20:05:38 +0000 Subject: [PATCH 04/10] feat(payment): add session and instrument lifecycle commands --- ...entInstrumentCommand.7b6d22c9eab936d3.json | 23 + ...entInstrumentCommand.4b151101264cb8f8.json | 3 + ...entInstrumentCommand.1f6c9a8ae4328e61.json | 6 + .../instrument/instrument-create.golden.json | 19 + .../instrument/instrument-delete.golden.json | 3 + ...aymentSessionCommand.902bade07933ebb1.json | 29 + ...aymentSessionCommand.86f3e58b4b886322.json | 3 + ...aymentSessionCommand.86f3e58b4b886322.json | 6 + .../session/session-create.golden.json | 23 + .../session/session-delete.golden.json | 3 + .../payment/instrument/create/index.tsx | 144 +++++ .../payment/instrument/delete/index.tsx | 58 ++ src/handlers/payment/instrument/index.tsx | 4 + .../payment/instrument/instrument.test.tsx | 600 ++++++++++++++++++ src/handlers/payment/session/create/index.tsx | 88 +++ src/handlers/payment/session/delete/index.tsx | 45 ++ src/handlers/payment/session/index.tsx | 6 +- src/handlers/payment/session/session.test.tsx | 437 +++++++++++++ 18 files changed, 1499 insertions(+), 1 deletion(-) create mode 100644 src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json create mode 100644 src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json create mode 100644 src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json create mode 100644 src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json create mode 100644 src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json create mode 100644 src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json create mode 100644 src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json create mode 100644 src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json create mode 100644 src/handlers/payment/__fixtures__/session/session-create.golden.json create mode 100644 src/handlers/payment/__fixtures__/session/session-delete.golden.json create mode 100644 src/handlers/payment/instrument/create/index.tsx create mode 100644 src/handlers/payment/instrument/delete/index.tsx create mode 100644 src/handlers/payment/instrument/instrument.test.tsx create mode 100644 src/handlers/payment/session/create/index.tsx create mode 100644 src/handlers/payment/session/delete/index.tsx create mode 100644 src/handlers/payment/session/session.test.tsx diff --git a/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json b/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json new file mode 100644 index 000000000..045ee3a27 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json @@ -0,0 +1,23 @@ +{ + "paymentInstrument": { + "paymentInstrumentId": "payment-instrument-CG2Tl7U1HnCGfHW", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", + "userId": "agentcore-cli-e2e", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "walletAddress": "0x93581aB831Cc862aA451E91fBf8365e098930859", + "redirectUrl": "https://hub.cdp.coinbase.com/e3eae6406a52" + } + }, + "createdAt": { + "$date": "2026-09-08T20:24:40.849Z" + }, + "status": "ACTIVE", + "updatedAt": { + "$date": "2026-09-08T20:24:41.673Z" + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json b/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json b/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json new file mode 100644 index 000000000..e9e7d7731 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment instrument not found: payment-instrument-CG2Tl7U1HnCGfHW for the given user and manager." + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json new file mode 100644 index 000000000..468d5d808 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json @@ -0,0 +1,19 @@ +{ + "paymentInstrument": { + "paymentInstrumentId": "payment-instrument-CG2Tl7U1HnCGfHW", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", + "userId": "agentcore-cli-e2e", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "walletAddress": "0x93581aB831Cc862aA451E91fBf8365e098930859", + "redirectUrl": "https://hub.cdp.coinbase.com/e3eae6406a52" + } + }, + "createdAt": "2026-09-08T20:24:40.849Z", + "status": "ACTIVE", + "updatedAt": "2026-09-08T20:24:41.673Z" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json b/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json new file mode 100644 index 000000000..a0a7107be --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json @@ -0,0 +1,29 @@ +{ + "paymentSession": { + "paymentSessionId": "payment-session-nq812U4e1BJIfw1", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "userId": "agentcore-cli-e2e", + "expiryTimeInMinutes": 15, + "createdAt": { + "$date": "2026-09-08T20:20:28.618Z" + }, + "updatedAt": { + "$date": "2026-09-08T20:20:28.618Z" + }, + "limits": { + "maxSpendAmount": { + "value": "1.00", + "currency": "USD" + } + }, + "availableLimits": { + "availableSpendAmount": { + "value": "1.00", + "currency": "USD" + }, + "updatedAt": { + "$date": "2026-09-08T20:20:28.697Z" + } + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json b/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json b/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json new file mode 100644 index 000000000..aedf25702 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment session not found: payment-session-nq812U4e1BJIfw1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-create.golden.json b/src/handlers/payment/__fixtures__/session/session-create.golden.json new file mode 100644 index 000000000..c6d24d46a --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/session-create.golden.json @@ -0,0 +1,23 @@ +{ + "paymentSession": { + "paymentSessionId": "payment-session-nq812U4e1BJIfw1", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "userId": "agentcore-cli-e2e", + "expiryTimeInMinutes": 15, + "createdAt": "2026-09-08T20:20:28.618Z", + "updatedAt": "2026-09-08T20:20:28.618Z", + "limits": { + "maxSpendAmount": { + "value": "1.00", + "currency": "USD" + } + }, + "availableLimits": { + "availableSpendAmount": { + "value": "1.00", + "currency": "USD" + }, + "updatedAt": "2026-09-08T20:20:28.697Z" + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-delete.golden.json b/src/handlers/payment/__fixtures__/session/session-delete.golden.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/session-delete.golden.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/instrument/create/index.tsx b/src/handlers/payment/instrument/create/index.tsx new file mode 100644 index 000000000..1a5d6238e --- /dev/null +++ b/src/handlers/payment/instrument/create/index.tsx @@ -0,0 +1,144 @@ +import type { + CryptoWalletNetwork, + EmbeddedCryptoWallet, + LinkedAccount, + PaymentInstrumentType, +} from "@aws-sdk/client-bedrock-agentcore"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonObjectFlag } from "../../../utils"; +import type { CreatePaymentInstrumentInput } from "../../types"; + +// Pinning these lists against the SDK types turns a new enum value into a +// compile-time reminder to widen the flag. +const INSTRUMENT_TYPES = [ + "EMBEDDED_CRYPTO_WALLET", +] as const satisfies readonly PaymentInstrumentType[]; +const DEFAULT_INSTRUMENT_TYPE: PaymentInstrumentType = "EMBEDDED_CRYPTO_WALLET"; +const NETWORKS = ["ETHEREUM", "SOLANA"] as const satisfies readonly CryptoWalletNetwork[]; + +export const createCreatePaymentInstrumentHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a payment instrument (an embedded crypto wallet) for a user", + flags: [ + flag("manager-id", "the payment manager ID that owns the instrument", z.string().optional()), + flag( + "user-id", + "the user the instrument belongs to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("agent-name", "agent name recorded for observability", z.string().optional()), + flag( + "connector-id", + "the payment connector that provisions the wallet", + z.string().optional(), + ), + flag( + "type", + `instrument type (${INSTRUMENT_TYPES.join(" | ")}; default ${DEFAULT_INSTRUMENT_TYPE})`, + z.enum(INSTRUMENT_TYPES).default(DEFAULT_INSTRUMENT_TYPE), + ), + flag( + "network", + `blockchain network of the wallet (${NETWORKS.join(" | ")}; shorthand form)`, + z.enum(NETWORKS).optional(), + ), + flag( + "email", + "email address linked to the wallet (repeatable; shorthand form)", + z.array(z.string()).optional(), + ), + flag( + "phone-number", + "E.164 phone number linked to the wallet (repeatable; shorthand form)", + z.array(z.string()).optional(), + ), + flag( + "instrument-details", + "full wallet definition (JSON EmbeddedCryptoWallet; inline, file://, or - for stdin); replaces the shorthand flags", + z.string().optional(), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + // Required at runtime but declared optional so that a bare invocation can + // fall through to the TUI once a screen exists. + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const wallet = await resolveWallet(flags, source); + + const request: CreatePaymentInstrumentInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentConnectorId: flags["connector-id"], + paymentInstrumentType: flags.type, + paymentInstrumentDetails: { embeddedCryptoWallet: wallet }, + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.createPaymentInstrument(request, coreOptsFromCtx(ctx))); + }, + }); + +type WalletFlags = { + network?: CryptoWalletNetwork; + email?: string[]; + "phone-number"?: string[]; + "instrument-details"?: string; +}; + +// resolveWallet builds the EmbeddedCryptoWallet from whichever input form the +// caller chose. The JSON form is passed through as-is (deep validation is left to +// the service); the shorthand form covers the common email/SMS onboarding case. +async function resolveWallet( + flags: WalletFlags, + source: SourceResolver, +): Promise { + const shorthandUsed = + flags.network !== undefined || flags.email !== undefined || flags["phone-number"] !== undefined; + + if (flags["instrument-details"] !== undefined) { + if (shorthandUsed) { + throw new InputValidationError( + "--instrument-details is mutually exclusive with --network, --email, and --phone-number", + ); + } + return parseJsonObjectFlag( + "instrument-details", + await source.resolveText("instrument-details", flags["instrument-details"]), + )!; + } + + if (!flags.network) { + throw new InputValidationError("required option '--network ' not specified"); + } + const linkedAccounts: LinkedAccount[] = [ + ...(flags.email ?? []).map((emailAddress) => ({ email: { emailAddress } })), + ...(flags["phone-number"] ?? []).map((phoneNumber) => ({ sms: { phoneNumber } })), + ]; + if (linkedAccounts.length === 0) { + throw new InputValidationError( + "the shorthand form needs at least one --email or --phone-number to link to the wallet", + ); + } + return { network: flags.network, linkedAccounts }; +} diff --git a/src/handlers/payment/instrument/delete/index.tsx b/src/handlers/payment/instrument/delete/index.tsx new file mode 100644 index 000000000..75aceedd4 --- /dev/null +++ b/src/handlers/payment/instrument/delete/index.tsx @@ -0,0 +1,58 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import type { DeletePaymentInstrumentInput } from "../../types"; + +// DeletePaymentInstrumentInput carries no agentName, so unlike the other +// instrument leaves this one offers no --agent-name. +export const createDeletePaymentInstrumentHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment instrument", + flags: [ + flag("manager-id", "the payment manager ID that owns the instrument", z.string().optional()), + flag( + "user-id", + "the user the instrument belongs to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag( + "connector-id", + "the payment connector the instrument was created under", + z.string().optional(), + ), + flag("instrument-id", "the payment instrument id", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + if (!flags["instrument-id"]) { + throw new InputValidationError( + "required option '--instrument-id ' not specified", + ); + } + + const request: DeletePaymentInstrumentInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentConnectorId: flags["connector-id"], + paymentInstrumentId: flags["instrument-id"], + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.deletePaymentInstrument(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/instrument/index.tsx b/src/handlers/payment/instrument/index.tsx index 6b76db261..152bdfcef 100644 --- a/src/handlers/payment/instrument/index.tsx +++ b/src/handlers/payment/instrument/index.tsx @@ -2,6 +2,8 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentInstrumentHandler } from "./create"; +import { createDeletePaymentInstrumentHandler } from "./delete"; import { createGetPaymentInstrumentHandler } from "./get"; import { createListPaymentInstrumentsHandler } from "./list"; import { createGetPaymentInstrumentBalanceHandler } from "./balance"; @@ -9,7 +11,9 @@ import { createGetPaymentInstrumentBalanceHandler } from "./balance"; export function createPaymentInstrumentHandler(core: Core, io: AppIO): Router { return new Router("instrument", "manage payment instruments (embedded crypto wallets)") .default(renderTui(core, io)) + .handler(createCreatePaymentInstrumentHandler(core, io)) .handler(createGetPaymentInstrumentHandler(core)) .handler(createListPaymentInstrumentsHandler(core)) + .handler(createDeletePaymentInstrumentHandler(core)) .handler(createGetPaymentInstrumentBalanceHandler(core)); } diff --git a/src/handlers/payment/instrument/instrument.test.tsx b/src/handlers/payment/instrument/instrument.test.tsx new file mode 100644 index 000000000..9faec35a3 --- /dev/null +++ b/src/handlers/payment/instrument/instrument.test.tsx @@ -0,0 +1,600 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { + BedrockAgentCoreClient, + CreatePaymentInstrumentRequest, + EmbeddedCryptoWallet, +} from "@aws-sdk/client-bedrock-agentcore"; +import { + GetPaymentManagerCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { CoreClient } from "../../../core"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; + +// End-to-end command-flow tests for the `payment instrument` leaves. +// +// Each test builds the real root handler over a real CoreClient whose SDK +// clients are the fixture-backed fakes, then drives it through `route()` exactly +// as the CLI does, so one test covers parsing, middleware, the leaf handler, +// PaymentClient, and the rendered output. +// +// Record with: +// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/instrument/instrument.test.tsx +// The flow uses an AWS_IAM payment manager and a READY CoinbaseCDP connector that +// already exist in the test account (the manager quota is exhausted, so none is +// created here) and leaves nothing behind: it creates one embedded wallet and +// deletes it again. + +const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); +const FIXTURES = join(PAYMENT_FIXTURES, "instrument"); +// Fixtures are keyed by operation and input, so a `get` issued after the delete +// would overwrite the pre-delete `get` fixture. Reads that expect the instrument +// to be gone record into their own directory. +const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); +const REGION = "us-west-2"; +const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; +const MANAGER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira"; +const CONNECTOR_ID = "mycdpconnectoraidandal-okve8guw4y"; +const USER_ID = "agentcore-cli-e2e"; +const EMAIL = "agentcore-cli-e2e@example.com"; +const FLOW_TIMEOUT = 120_000; + +function createFixtureCore(fixtures = FIXTURES): CoreClient { + const { createControlClient, createIamClient, createLogsClient } = + fixtureFactories(PAYMENT_FIXTURES); + const { createDataClient } = fixtureFactories(fixtures); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +// createCapturingDataCore swaps the data plane's `.send()` for one that records +// the request it was handed, while keeping the real CoreClient and PaymentClient +// in the loop. Fixtures only key on the request hash, so this is how the tests +// assert the exact request each flag form builds. +function createCapturingDataCore(): { + core: CoreClient; + sent: unknown[]; + lookups: GetPaymentManagerCommand[]; +} { + const sent: unknown[] = []; + const lookups: GetPaymentManagerCommand[] = []; + const { createIamClient, createLogsClient } = fixtureFactories(PAYMENT_FIXTURES); + const core = new CoreClient({ + createControlClient: () => + ({ + send: async (command: GetPaymentManagerCommand) => { + expect(command).toBeInstanceOf(GetPaymentManagerCommand); + lookups.push(command); + return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; + }, + }) as unknown as BedrockAgentCoreControlClient, + createDataClient: () => + ({ + send: async (command: { input: unknown }) => { + sent.push(command.input); + return {}; + }, + }) as unknown as BedrockAgentCoreClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); + return { core, sent, lookups }; +} + +function createRoot(core = createFixtureCore(), stdin?: string) { + const io = testIO({ stdin }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + return { root, io }; +} + +async function run( + args: string[], + { fixtures = FIXTURES, stdin }: { fixtures?: string; stdin?: string } = {}, +): Promise { + const { root, io } = createRoot(createFixtureCore(fixtures), stdin); + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +async function capture(args: string[], stdin?: string): Promise { + const { core, sent, lookups } = createCapturingDataCore(); + const { root } = createRoot(core, stdin); + await root.route(["node", "agentcore", ...args, "--region", REGION]); + expect(lookups).toHaveLength(1); + expect(lookups[0]?.input).toEqual({ paymentManagerId: MANAGER_ID }); + expect(sent).toHaveLength(1); + expect(sent[0]).toHaveProperty("paymentManagerArn", MANAGER_ARN); + expect(sent[0]).not.toHaveProperty("managerId"); + return sent[0] as CreatePaymentInstrumentRequest; +} + +const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; +const connectorScoped = [...scoped, "--connector-id", CONNECTOR_ID]; +const shorthand = ["--network", "ETHEREUM", "--email", EMAIL]; +const walletJson = JSON.stringify({ + network: "ETHEREUM", + linkedAccounts: [{ email: { emailAddress: EMAIL } }], +}); + +describe("payment instrument command hierarchy", () => { + test("registers create, get, list, delete, and balance leaves", () => { + const { root } = createRoot(); + const instrument = root + .children() + .find((child) => child.name() === "payment") + ?.children() + .find((child) => child.name() === "instrument"); + + expect(instrument?.children().map((child) => child.name())).toEqual([ + "create", + "get", + "list", + "delete", + "balance", + ]); + }); +}); + +describe("payment instrument validation", () => { + test.each(["create", "get", "list", "delete"])( + "`%s` rejects the removed --manager-arn flag, including alongside --manager-id", + async (command) => { + for (const idArgs of [[], scoped]) { + await expect( + run(["payment", "instrument", command, ...idArgs, "--manager-arn", MANAGER_ARN]), + ).rejects.toThrow(/unknown option '--manager-arn'/); + } + }, + ); + + test.each(["create", "get", "list", "delete"])( + "`%s` rejects an explicitly empty --manager-id", + async (command) => { + await expect( + run(["payment", "instrument", command, "--manager-id", "", "--user-id", USER_ID]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }, + ); + + // Every leaf declares its identifying flags optional (so a bare invocation can + // fall through to the TUI once one exists) but requires them at runtime. None + // of these reach the SDK, so no fixtures are involved. + test("`create` errors when --manager-id is omitted", async () => { + await expect( + run([ + "payment", + "instrument", + "create", + "--user-id", + USER_ID, + "--connector-id", + CONNECTOR_ID, + ...shorthand, + ]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }); + + test("`create` errors when --user-id is omitted", async () => { + await expect( + run([ + "payment", + "instrument", + "create", + "--manager-id", + MANAGER_ID, + "--connector-id", + CONNECTOR_ID, + ...shorthand, + ]), + ).rejects.toThrow(/required option '--user-id ' not specified/); + }); + + test("`create` errors when --connector-id is omitted", async () => { + await expect(run(["payment", "instrument", "create", ...scoped, ...shorthand])).rejects.toThrow( + /required option '--connector-id ' not specified/, + ); + }); + + test("`create` rejects shorthand flags together with --instrument-details", async () => { + await expect( + run([ + "payment", + "instrument", + "create", + ...connectorScoped, + ...shorthand, + "--instrument-details", + walletJson, + ]), + ).rejects.toThrow(/--instrument-details is mutually exclusive with/); + }); + + test("`create` rejects --phone-number together with --instrument-details", async () => { + await expect( + run([ + "payment", + "instrument", + "create", + ...connectorScoped, + "--phone-number", + "+15555550100", + "--instrument-details", + walletJson, + ]), + ).rejects.toThrow(/--instrument-details is mutually exclusive with/); + }); + + test("`create` errors when the shorthand form omits --network", async () => { + await expect( + run(["payment", "instrument", "create", ...connectorScoped, "--email", EMAIL]), + ).rejects.toThrow(/required option '--network ' not specified/); + }); + + test("`create` errors when no wallet details are given at all", async () => { + await expect(run(["payment", "instrument", "create", ...connectorScoped])).rejects.toThrow( + /required option '--network ' not specified/, + ); + }); + + test("`create` errors when the shorthand form has no linked account", async () => { + await expect( + run(["payment", "instrument", "create", ...connectorScoped, "--network", "ETHEREUM"]), + ).rejects.toThrow(/at least one --email or --phone-number/); + }); + + test("`create` rejects an unsupported --network", async () => { + await expect( + run([ + "payment", + "instrument", + "create", + ...connectorScoped, + "--network", + "BITCOIN", + "--email", + EMAIL, + ]), + ).rejects.toThrow(/Invalid value for option '--network'/); + }); + + test("`create` rejects an unsupported --type", async () => { + await expect( + run(["payment", "instrument", "create", ...connectorScoped, ...shorthand, "--type", "CARD"]), + ).rejects.toThrow(/Invalid value for option '--type'/); + }); + + test("`create` rejects malformed --instrument-details JSON", async () => { + await expect( + run([ + "payment", + "instrument", + "create", + ...connectorScoped, + "--instrument-details", + "{not json", + ]), + ).rejects.toThrow(/Invalid JSON for option '--instrument-details'/); + }); + + test("`create` rejects --instrument-details that is not a JSON object", async () => { + await expect( + run(["payment", "instrument", "create", ...connectorScoped, "--instrument-details", "[]"]), + ).rejects.toThrow(/Option '--instrument-details' must be a JSON object/); + }); + + test("`get` errors when --instrument-id is omitted", async () => { + await expect(run(["payment", "instrument", "get", ...scoped])).rejects.toThrow( + /required option '--instrument-id ' not specified/, + ); + }); + + test("`get` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "instrument", "get", "--user-id", USER_ID, "--instrument-id", "i-1"]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }); + + test("`get` errors when --user-id is omitted", async () => { + await expect( + run(["payment", "instrument", "get", "--manager-id", MANAGER_ID, "--instrument-id", "i-1"]), + ).rejects.toThrow(/required option '--user-id ' not specified/); + }); + + test("`list` errors when --manager-id is omitted", async () => { + await expect(run(["payment", "instrument", "list", "--user-id", USER_ID])).rejects.toThrow( + /required option '--manager-id ' not specified/, + ); + }); + + test("`list` errors when --user-id is omitted", async () => { + await expect( + run(["payment", "instrument", "list", "--manager-id", MANAGER_ID]), + ).rejects.toThrow(/required option '--user-id ' not specified/); + }); + + test("`delete` errors when --connector-id is omitted", async () => { + await expect( + run(["payment", "instrument", "delete", ...scoped, "--instrument-id", "i-1"]), + ).rejects.toThrow(/required option '--connector-id ' not specified/); + }); + + test("`delete` errors when --instrument-id is omitted", async () => { + await expect(run(["payment", "instrument", "delete", ...connectorScoped])).rejects.toThrow( + /required option '--instrument-id ' not specified/, + ); + }); + + test("`delete` errors when --manager-id is omitted", async () => { + await expect( + run([ + "payment", + "instrument", + "delete", + "--user-id", + USER_ID, + "--connector-id", + CONNECTOR_ID, + "--instrument-id", + "i-1", + ]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }); + + test("`delete` errors when --user-id is omitted", async () => { + await expect( + run([ + "payment", + "instrument", + "delete", + "--manager-id", + MANAGER_ID, + "--connector-id", + CONNECTOR_ID, + "--instrument-id", + "i-1", + ]), + ).rejects.toThrow(/required option '--user-id ' not specified/); + }); +}); + +describe("payment instrument create request mapping", () => { + test("shorthand flags build one linked account per --email and --phone-number", async () => { + const request = await capture([ + "payment", + "instrument", + "create", + ...connectorScoped, + "--network", + "SOLANA", + "--email", + "one@example.com", + "--email", + "two@example.com", + "--phone-number", + "+15555550100", + ]); + + expect(request).toEqual({ + paymentManagerArn: MANAGER_ARN, + userId: USER_ID, + paymentConnectorId: CONNECTOR_ID, + paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET", + paymentInstrumentDetails: { + embeddedCryptoWallet: { + network: "SOLANA", + linkedAccounts: [ + { email: { emailAddress: "one@example.com" } }, + { email: { emailAddress: "two@example.com" } }, + { sms: { phoneNumber: "+15555550100" } }, + ], + }, + }, + }); + }); + + test("--instrument-details passes the wallet through, reaching every field", async () => { + const wallet: EmbeddedCryptoWallet = { + network: "ETHEREUM", + linkedAccounts: [ + { developerJwt: { kid: "key-1", sub: "user-1" } }, + { oAuth2: { google: { sub: "google-sub", emailAddress: "g@example.com" } } }, + ], + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + redirectUrl: "https://example.test/return", + }; + const request = await capture([ + "payment", + "instrument", + "create", + ...connectorScoped, + "--instrument-details", + JSON.stringify(wallet), + ]); + + expect(request.paymentInstrumentDetails).toEqual({ embeddedCryptoWallet: wallet }); + expect(request.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); + }); + + test("--instrument-details - reads the wallet from stdin", async () => { + const request = await capture( + ["payment", "instrument", "create", ...connectorScoped, "--instrument-details", "-"], + walletJson, + ); + + expect(request.paymentInstrumentDetails).toEqual({ + embeddedCryptoWallet: JSON.parse(walletJson), + }); + }); + + test("optional --agent-name and --client-token are forwarded only when set", async () => { + const bare = await capture([ + "payment", + "instrument", + "create", + ...connectorScoped, + ...shorthand, + ]); + expect(bare).not.toHaveProperty("agentName"); + expect(bare).not.toHaveProperty("clientToken"); + + const full = await capture([ + "payment", + "instrument", + "create", + ...connectorScoped, + ...shorthand, + "--agent-name", + "my-agent", + "--client-token", + "token-1", + ]); + expect(full.agentName).toBe("my-agent"); + expect(full.clientToken).toBe("token-1"); + }); +}); + +// ─── instrument flow (create → get → list → delete → get) ──────────────────── +// +// Drives the lifecycle of a real embedded crypto wallet, in order, through +// route(). In record mode it hits the live data plane and persists every +// exchange; replays are offline and instant. Later tests consume the id parsed +// from earlier output. + +const state: { instrumentId?: string } = {}; + +// pollUntilSettled re-runs `get` until the instrument is ACTIVE or the polling +// budget runs out, and returns the last observed status. The CoinbaseCDP +// connector has provisioned the wallet as ACTIVE within the create call itself; +// the poll guards a re-record against a slower INITIATED → ACTIVE transition. +// The fixture ends up holding the last poll; in replay the first read is final. +async function pollUntilSettled(command: string[]): Promise { + let status = ""; + for (let attempt = 0; attempt < 12; attempt++) { + status = JSON.parse(await run(command)).paymentInstrument.status; + if (status === "ACTIVE" || !isRecording()) return status; + await Bun.sleep(5_000); + } + return status; +} + +describe("payment instrument flow", () => { + test( + "`create` provisions an embedded wallet from the shorthand flags", + async () => { + const out = await run(["payment", "instrument", "create", ...connectorScoped, ...shorthand]); + matchGolden(FIXTURES, "instrument-create.golden.json", out); + + const { paymentInstrument } = JSON.parse(out); + expect(paymentInstrument.paymentInstrumentId).toBeDefined(); + expect(paymentInstrument.paymentManagerArn).toBe(MANAGER_ARN); + expect(paymentInstrument.paymentConnectorId).toBe(CONNECTOR_ID); + expect(paymentInstrument.userId).toBe(USER_ID); + expect(paymentInstrument.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); + expect(paymentInstrument.paymentInstrumentDetails.embeddedCryptoWallet.network).toBe( + "ETHEREUM", + ); + state.instrumentId = paymentInstrument.paymentInstrumentId; + + const status = await pollUntilSettled([ + "payment", + "instrument", + "get", + ...scoped, + "--instrument-id", + state.instrumentId!, + ]); + expect(status).toBe("ACTIVE"); + }, + FLOW_TIMEOUT, + ); + + test( + "`get` returns the instrument", + async () => { + const out = await run([ + "payment", + "instrument", + "get", + ...scoped, + "--instrument-id", + state.instrumentId!, + ]); + matchGolden(FIXTURES, "instrument-get.golden.json", out); + + const { paymentInstrument } = JSON.parse(out); + expect(paymentInstrument.paymentInstrumentId).toBe(state.instrumentId); + expect(paymentInstrument.paymentConnectorId).toBe(CONNECTOR_ID); + expect(paymentInstrument.status).toBe("ACTIVE"); + expect(paymentInstrument.paymentInstrumentDetails.embeddedCryptoWallet.walletAddress).toMatch( + /^0x[0-9a-fA-F]{40}$/, + ); + }, + FLOW_TIMEOUT, + ); + + test( + "`list` includes the instrument", + async () => { + const out = await run(["payment", "instrument", "list", ...connectorScoped]); + matchGolden(FIXTURES, "instrument-list.golden.json", out); + + const parsed = JSON.parse(out); + expect(Array.isArray(parsed.paymentInstruments)).toBe(true); + expect( + parsed.paymentInstruments.map( + (instrument: { paymentInstrumentId: string }) => instrument.paymentInstrumentId, + ), + ).toContain(state.instrumentId); + }, + FLOW_TIMEOUT, + ); + + test( + "`delete` deletes the instrument", + async () => { + const out = await run([ + "payment", + "instrument", + "delete", + ...connectorScoped, + "--instrument-id", + state.instrumentId!, + ]); + matchGolden(FIXTURES, "instrument-delete.golden.json", out); + expect(JSON.parse(out).status).toBe("DELETED"); + }, + FLOW_TIMEOUT, + ); + + test( + "`get` after delete reports the instrument gone", + async () => { + await expect( + run(["payment", "instrument", "get", ...scoped, "--instrument-id", state.instrumentId!], { + fixtures: AFTER_DELETE_FIXTURES, + }), + ).rejects.toThrow(/ResourceNotFound|not found/i); + }, + FLOW_TIMEOUT, + ); +}); diff --git a/src/handlers/payment/session/create/index.tsx b/src/handlers/payment/session/create/index.tsx new file mode 100644 index 000000000..d6eb875c0 --- /dev/null +++ b/src/handlers/payment/session/create/index.tsx @@ -0,0 +1,88 @@ +import type { Currency } from "@aws-sdk/client-bedrock-agentcore"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import type { CreatePaymentSessionInput } from "../../types"; + +// The service accepts only USD today. Pinning the list against the SDK type +// turns a new Currency value into a compile-time reminder to widen this. +const CURRENCIES = ["USD"] as const satisfies readonly Currency[]; +const DEFAULT_CURRENCY: Currency = "USD"; + +export const createCreatePaymentSessionHandler = (core: Core) => + createHandler({ + name: "create", + description: "create a payment session (a time-boxed payment context with a spend limit)", + flags: [ + flag("manager-id", "the payment manager ID that owns the session", z.string().optional()), + flag( + "user-id", + "the user the session is scoped to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("agent-name", "agent name recorded for observability", z.string().optional()), + flag( + "expiry-minutes", + "how long the session stays active, in minutes (15 to 480)", + z.number().int().min(15).max(480).optional(), + ), + flag( + "max-spend", + "maximum amount the session may spend, as a decimal string (e.g. 25.00)", + z.string().optional(), + ), + flag( + "currency", + `currency of --max-spend (${CURRENCIES.join(" | ")}; default ${DEFAULT_CURRENCY})`, + z.enum(CURRENCIES).optional(), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + // Required at runtime but declared optional so that a bare invocation can + // fall through to the TUI once a screen exists. + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (flags["expiry-minutes"] === undefined) { + throw new InputValidationError( + "required option '--expiry-minutes ' not specified", + ); + } + const maxSpend = flags["max-spend"]; + if (maxSpend !== undefined && maxSpend.trim() === "") { + throw new InputValidationError("--max-spend must not be empty or whitespace"); + } + if (flags.currency !== undefined && maxSpend === undefined) { + throw new InputValidationError("--currency requires --max-spend"); + } + + const request: CreatePaymentSessionInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + expiryTimeInMinutes: flags["expiry-minutes"], + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + ...(maxSpend !== undefined + ? { + limits: { + maxSpendAmount: { + value: maxSpend, + currency: flags.currency ?? DEFAULT_CURRENCY, + }, + }, + } + : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.createPaymentSession(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/session/delete/index.tsx b/src/handlers/payment/session/delete/index.tsx new file mode 100644 index 000000000..21a2eac0e --- /dev/null +++ b/src/handlers/payment/session/delete/index.tsx @@ -0,0 +1,45 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import type { DeletePaymentSessionInput } from "../../types"; + +// DeletePaymentSessionInput carries no agentName, so unlike the other session +// leaves this one offers no --agent-name. +export const createDeletePaymentSessionHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment session", + flags: [ + flag("manager-id", "the payment manager ID that owns the session", z.string().optional()), + flag( + "user-id", + "the user the session is scoped to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("session-id", "the payment session id", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (!flags["session-id"]) { + throw new InputValidationError("required option '--session-id ' not specified"); + } + + const request: DeletePaymentSessionInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentSessionId: flags["session-id"], + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.deletePaymentSession(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/session/index.tsx b/src/handlers/payment/session/index.tsx index dff3e769a..881f0de66 100644 --- a/src/handlers/payment/session/index.tsx +++ b/src/handlers/payment/session/index.tsx @@ -2,12 +2,16 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentSessionHandler } from "./create"; +import { createDeletePaymentSessionHandler } from "./delete"; import { createGetPaymentSessionHandler } from "./get"; import { createListPaymentSessionsHandler } from "./list"; export function createPaymentSessionHandler(core: Core, io: AppIO): Router { return new Router("session", "manage payment sessions (budget-limited payment contexts)") .default(renderTui(core, io)) + .handler(createCreatePaymentSessionHandler(core)) .handler(createGetPaymentSessionHandler(core)) - .handler(createListPaymentSessionsHandler(core)); + .handler(createListPaymentSessionsHandler(core)) + .handler(createDeletePaymentSessionHandler(core)); } diff --git a/src/handlers/payment/session/session.test.tsx b/src/handlers/payment/session/session.test.tsx new file mode 100644 index 000000000..ac8020bba --- /dev/null +++ b/src/handlers/payment/session/session.test.tsx @@ -0,0 +1,437 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import { + CreatePaymentSessionCommand, + type BedrockAgentCoreClient, + type CreatePaymentSessionRequest, +} from "@aws-sdk/client-bedrock-agentcore"; +import { + GetPaymentManagerCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { CoreClient } from "../../../core"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + parse, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import sessionCreateFixture from "../__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json"; + +// End-to-end command-flow tests for the `payment session` leaves. +// +// Each test builds the real root handler over a real CoreClient whose SDK +// clients are the fixture-backed fakes, then drives it through `route()` exactly +// as the CLI does, so one test covers parsing, middleware, the leaf handler, +// PaymentClient, and the rendered output. +// +// Record with: +// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/session/session.test.tsx +// The flow uses an AWS_IAM payment manager that already exists in the test +// account (the manager quota is exhausted, so none is created here) and leaves +// nothing behind: it creates one session and deletes it again. + +const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); +const FIXTURES = join(PAYMENT_FIXTURES, "session"); +// Fixtures are keyed by operation and input, so a `get` issued after the delete +// would overwrite the pre-delete `get` fixture. Reads that expect the session to +// be gone record into their own directory. +const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); +const REGION = "us-west-2"; +const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; +const MANAGER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira"; +const USER_ID = "agentcore-cli-e2e"; +const FLOW_TIMEOUT = 120_000; + +function createFixtureCore(fixtures = FIXTURES): CoreClient { + const { createControlClient, createIamClient, createLogsClient } = + fixtureFactories(PAYMENT_FIXTURES); + const { createDataClient } = fixtureFactories(fixtures); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +function createRoot(core = createFixtureCore()) { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + return { root, io }; +} + +async function run(args: string[], fixtures = FIXTURES): Promise { + const { root, io } = createRoot(createFixtureCore(fixtures)); + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; + +describe("payment session command hierarchy", () => { + test("registers create, get, list, and delete leaves", () => { + const { root } = createRoot(); + const session = root + .children() + .find((child) => child.name() === "payment") + ?.children() + .find((child) => child.name() === "session"); + + expect(session?.children().map((child) => child.name())).toEqual([ + "create", + "get", + "list", + "delete", + ]); + }); +}); + +describe("payment session validation", () => { + test.each(["create", "get", "list", "delete"])( + "`%s` rejects the removed --manager-arn flag, including alongside --manager-id", + async (command) => { + for (const idArgs of [[], scoped]) { + await expect( + run(["payment", "session", command, ...idArgs, "--manager-arn", MANAGER_ARN]), + ).rejects.toThrow(/unknown option '--manager-arn'/); + } + }, + ); + + test.each(["create", "get", "list", "delete"])( + "`%s` rejects an explicitly empty --manager-id", + async (command) => { + await expect( + run(["payment", "session", command, "--manager-id", "", "--user-id", USER_ID]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }, + ); + + // Every leaf declares its identifying flags optional (so a bare invocation can + // fall through to the TUI once one exists) but requires them at runtime. None + // of these reach the SDK, so no fixtures are involved. + test("`create` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "session", "create", "--user-id", USER_ID, "--expiry-minutes", "15"]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }); + + test("`create` errors when --user-id is omitted", async () => { + await expect( + run(["payment", "session", "create", "--manager-id", MANAGER_ID, "--expiry-minutes", "15"]), + ).rejects.toThrow(/required option '--user-id ' not specified/); + }); + + test("`create` errors when --expiry-minutes is omitted", async () => { + await expect(run(["payment", "session", "create", ...scoped])).rejects.toThrow( + /required option '--expiry-minutes ' not specified/, + ); + }); + + test("`create` rejects --expiry-minutes below 15", async () => { + await expect( + run(["payment", "session", "create", ...scoped, "--expiry-minutes", "14"]), + ).rejects.toThrow(/Invalid value for option '--expiry-minutes'/); + }); + + test("`create` rejects --expiry-minutes above 480", async () => { + await expect( + run(["payment", "session", "create", ...scoped, "--expiry-minutes", "481"]), + ).rejects.toThrow(/Invalid value for option '--expiry-minutes'/); + }); + + test("`create` rejects a fractional --expiry-minutes", async () => { + await expect( + run(["payment", "session", "create", ...scoped, "--expiry-minutes", "15.5"]), + ).rejects.toThrow(/Invalid value for option '--expiry-minutes'/); + }); + + test("`create` rejects --currency without --max-spend", async () => { + await expect( + run([ + "payment", + "session", + "create", + ...scoped, + "--expiry-minutes", + "15", + "--currency", + "USD", + ]), + ).rejects.toThrow(/--currency requires --max-spend/); + }); + + test.each(["", " \t\n "])( + "`create` rejects blank --max-spend %j before calling Core", + async (maxSpend) => { + const core = createFixtureCore(); + const createSession = spyOn(core.payment, "createPaymentSession").mockRejectedValue( + new Error("unexpected session creation during validation"), + ); + try { + for (const currencyArgs of [[], ["--currency", "USD"]]) { + const { root } = createRoot(core); + await expect( + root.route([ + "node", + "agentcore", + "payment", + "session", + "create", + ...scoped, + "--expiry-minutes", + "15", + "--max-spend", + maxSpend, + ...currencyArgs, + "--region", + REGION, + ]), + ).rejects.toThrow("--max-spend must not be empty or whitespace"); + expect(createSession).not.toHaveBeenCalled(); + } + } finally { + createSession.mockRestore(); + } + }, + ); + + test.each([ + { label: "omitted", args: [], limits: undefined }, + { + label: "zero", + args: ["--max-spend", "0"], + limits: { maxSpendAmount: { value: "0", currency: "USD" } }, + }, + { + label: "exact decimal text", + args: ["--max-spend", "10.00"], + limits: { maxSpendAmount: { value: "10.00", currency: "USD" } }, + }, + ])("`create` preserves $label --max-spend in the SDK request", async ({ args, limits }) => { + const requests: CreatePaymentSessionRequest[] = []; + const lookups: GetPaymentManagerCommand[] = []; + const core = new CoreClient({ + ...fixtureFactories(PAYMENT_FIXTURES), + createControlClient: () => + ({ + send: async (command: GetPaymentManagerCommand) => { + expect(command).toBeInstanceOf(GetPaymentManagerCommand); + lookups.push(command); + return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; + }, + }) as unknown as BedrockAgentCoreControlClient, + createDataClient: () => + ({ + send: async (command: CreatePaymentSessionCommand) => { + expect(command).toBeInstanceOf(CreatePaymentSessionCommand); + requests.push(command.input); + return parse(JSON.stringify(sessionCreateFixture)); + }, + }) as unknown as BedrockAgentCoreClient, + logger: createSilentLogger(), + }); + const { root, io } = createRoot(core); + await root.route([ + "node", + "agentcore", + "payment", + "session", + "create", + ...scoped, + "--expiry-minutes", + "15", + ...args, + "--region", + REGION, + ]); + expect(lookups).toHaveLength(1); + expect(lookups[0]?.input).toEqual({ paymentManagerId: MANAGER_ID }); + expect(requests).toHaveLength(1); + expect(requests[0]?.paymentManagerArn).toBe(MANAGER_ARN); + expect(requests[0]).not.toHaveProperty("managerId"); + expect(requests[0]?.limits).toEqual(limits); + expect(JSON.parse(io.stdout()).paymentSession.paymentSessionId).toBe( + sessionCreateFixture.paymentSession.paymentSessionId, + ); + }); + + test("`create` rejects an unsupported --currency", async () => { + await expect( + run([ + "payment", + "session", + "create", + ...scoped, + "--expiry-minutes", + "15", + "--max-spend", + "1.00", + "--currency", + "EUR", + ]), + ).rejects.toThrow(/Invalid value for option '--currency'/); + }); + + test("`get` errors when --session-id is omitted", async () => { + await expect(run(["payment", "session", "get", ...scoped])).rejects.toThrow( + /required option '--session-id ' not specified/, + ); + }); + + test("`get` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "session", "get", "--user-id", USER_ID, "--session-id", "s-1"]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }); + + test("`get` errors when --user-id is omitted", async () => { + await expect( + run(["payment", "session", "get", "--manager-id", MANAGER_ID, "--session-id", "s-1"]), + ).rejects.toThrow(/required option '--user-id ' not specified/); + }); + + test("`list` errors when --manager-id is omitted", async () => { + await expect(run(["payment", "session", "list", "--user-id", USER_ID])).rejects.toThrow( + /required option '--manager-id ' not specified/, + ); + }); + + test("`list` errors when --user-id is omitted", async () => { + await expect(run(["payment", "session", "list", "--manager-id", MANAGER_ID])).rejects.toThrow( + /required option '--user-id ' not specified/, + ); + }); + + test("`delete` errors when --session-id is omitted", async () => { + await expect(run(["payment", "session", "delete", ...scoped])).rejects.toThrow( + /required option '--session-id ' not specified/, + ); + }); + + test("`delete` errors when --manager-id is omitted", async () => { + await expect( + run(["payment", "session", "delete", "--user-id", USER_ID, "--session-id", "s-1"]), + ).rejects.toThrow(/required option '--manager-id ' not specified/); + }); + + test("`delete` errors when --user-id is omitted", async () => { + await expect( + run(["payment", "session", "delete", "--manager-id", MANAGER_ID, "--session-id", "s-1"]), + ).rejects.toThrow(/required option '--user-id ' not specified/); + }); +}); + +// ─── session flow (create → get → list → delete → get) ─────────────────────── +// +// Drives the lifecycle of a real payment session, in order, through route(). +// In record mode it hits the live data plane and persists every exchange; +// replays are offline and instant. Later tests consume the id parsed from +// earlier output. + +const state: { sessionId?: string } = {}; + +describe("payment session flow", () => { + test( + "`create` opens a session with a spend limit", + async () => { + const out = await run([ + "payment", + "session", + "create", + ...scoped, + "--expiry-minutes", + "15", + "--max-spend", + "1.00", + "--currency", + "USD", + ]); + matchGolden(FIXTURES, "session-create.golden.json", out); + + const { paymentSession } = JSON.parse(out); + expect(paymentSession.paymentSessionId).toBeDefined(); + expect(paymentSession.paymentManagerArn).toBe(MANAGER_ARN); + expect(paymentSession.userId).toBe(USER_ID); + expect(paymentSession.expiryTimeInMinutes).toBe(15); + expect(paymentSession.limits.maxSpendAmount.currency).toBe("USD"); + expect(Number(paymentSession.limits.maxSpendAmount.value)).toBe(1); + state.sessionId = paymentSession.paymentSessionId; + }, + FLOW_TIMEOUT, + ); + + test( + "`get` returns the session", + async () => { + const out = await run([ + "payment", + "session", + "get", + ...scoped, + "--session-id", + state.sessionId!, + ]); + matchGolden(FIXTURES, "session-get.golden.json", out); + expect(JSON.parse(out).paymentSession.paymentSessionId).toBe(state.sessionId); + }, + FLOW_TIMEOUT, + ); + + test( + "`list` includes the session", + async () => { + const out = await run(["payment", "session", "list", ...scoped]); + matchGolden(FIXTURES, "session-list.golden.json", out); + + const parsed = JSON.parse(out); + expect(Array.isArray(parsed.paymentSessions)).toBe(true); + expect( + parsed.paymentSessions.map( + (session: { paymentSessionId: string }) => session.paymentSessionId, + ), + ).toContain(state.sessionId); + }, + FLOW_TIMEOUT, + ); + + test( + "`delete` deletes the session", + async () => { + const out = await run([ + "payment", + "session", + "delete", + ...scoped, + "--session-id", + state.sessionId!, + ]); + matchGolden(FIXTURES, "session-delete.golden.json", out); + expect(JSON.parse(out).status).toBe("DELETED"); + }, + FLOW_TIMEOUT, + ); + + test( + "`get` after delete reports the session missing", + async () => { + await expect( + run( + ["payment", "session", "get", ...scoped, "--session-id", state.sessionId!], + AFTER_DELETE_FIXTURES, + ), + ).rejects.toThrow(/ResourceNotFound|not found/i); + }, + FLOW_TIMEOUT, + ); +}); From 8a9a0c20326e699b4ea93fdd06d73b77d3c25e95 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 20:06:25 +0000 Subject: [PATCH 05/10] feat(identity): add payment credential provider mutations --- ...ntialProviderCommand.6a6a6d692f0c855a.json | 19 + ...ntialProviderCommand.7301309043cb46c3.json | 18 + ...ntialProviderCommand.b4f925d7ba338077.json | 18 + ...entialProviderCommand.9ebdd66cb38819e.json | 1 + ...ntialProviderCommand.d4020a1cc9b08a46.json | 1 + ...ntialProviderCommand.f7b7a089d09d9e1d.json | 1 + ...ntialProviderCommand.417ee64c8755742b.json | 24 + .../__fixtures__/create-2.golden.json | 18 + .../__fixtures__/create-stripe.golden.json | 19 + .../__fixtures__/create.golden.json | 18 + .../__fixtures__/delete-2.golden.json | 1 + .../__fixtures__/delete-stripe.golden.json | 1 + .../__fixtures__/delete.golden.json | 1 + .../list-after-delete.golden.json | 60 + .../__fixtures__/update.golden.json | 20 + .../create/index.tsx | 38 + .../delete/index.tsx | 24 + .../payment-credential-provider/flags.ts | 294 +++++ .../payment-credential-provider/index.tsx | 8 +- .../paymentCredentialProvider.read.test.tsx | 12 +- .../paymentCredentialProvider.test.tsx | 1069 +++++++++++++++++ .../update/index.tsx | 39 + .../validation.test.ts | 3 + .../validation.ts | 5 +- src/handlers/identity/types.tsx | 4 +- .../project/add/credentials/payment/input.ts | 2 +- 26 files changed, 1710 insertions(+), 8 deletions(-) create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.6a6a6d692f0c855a.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.7301309043cb46c3.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.b4f925d7ba338077.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.9ebdd66cb38819e.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.d4020a1cc9b08a46.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.f7b7a089d09d9e1d.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/UpdatePaymentCredentialProviderCommand.417ee64c8755742b.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/create-2.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/create-stripe.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/create.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/delete-2.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/delete-stripe.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/delete.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/list-after-delete.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/update.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/create/index.tsx create mode 100644 src/handlers/identity/payment-credential-provider/delete/index.tsx create mode 100644 src/handlers/identity/payment-credential-provider/flags.ts create mode 100644 src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx create mode 100644 src/handlers/identity/payment-credential-provider/update/index.tsx rename src/handlers/{project/add/credentials/payment => identity/payment-credential-provider}/validation.test.ts (88%) rename src/handlers/{project/add/credentials/payment => identity/payment-credential-provider}/validation.ts (90%) diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.6a6a6d692f0c855a.json b/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.6a6a6d692f0c855a.json new file mode 100644 index 000000000..84e12fb4e --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.6a6a6d692f0c855a.json @@ -0,0 +1,19 @@ +{ + "name": "agentcore-cli-payment-fixture-stripe", + "credentialProviderVendor": "StripePrivy", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture-stripe", + "providerConfigurationOutput": { + "stripePrivyConfiguration": { + "appId": "agentcore-cli-fixture-app", + "appSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/stripeprivy/agentcore-cli-payment-fixture-stripe-28e8771e/appsecret-Ns6m08" + }, + "authorizationPrivateKeyArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/stripeprivy/agentcore-cli-payment-fixture-stripe-28e8771e/authprivkey-7sMU9C" + }, + "authorizationId": "agentcore-cli-fixture-auth", + "appSecretSource": "MANAGED", + "authorizationPrivateKeySource": "MANAGED" + } + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.7301309043cb46c3.json b/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.7301309043cb46c3.json new file mode 100644 index 000000000..a5443ffff --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.7301309043cb46c3.json @@ -0,0 +1,18 @@ +{ + "name": "agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "providerConfigurationOutput": { + "coinbaseCdpConfiguration": { + "apiKeyId": "agentcore-cli-fixture-key", + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/apikey-eNjrnd" + }, + "walletSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/wallet-DFZFS6" + }, + "apiKeySecretSource": "MANAGED", + "walletSecretSource": "MANAGED" + } + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.b4f925d7ba338077.json b/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.b4f925d7ba338077.json new file mode 100644 index 000000000..28a910f44 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/CreatePaymentCredentialProviderCommand.b4f925d7ba338077.json @@ -0,0 +1,18 @@ +{ + "name": "agentcore-cli-payment-fixture-2", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture-2", + "providerConfigurationOutput": { + "coinbaseCdpConfiguration": { + "apiKeyId": "agentcore-cli-fixture-key-2", + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-2-eb60ae2f/apikey-Cjw9fY" + }, + "walletSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-2-eb60ae2f/wallet-hdssxR" + }, + "apiKeySecretSource": "MANAGED", + "walletSecretSource": "MANAGED" + } + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.9ebdd66cb38819e.json b/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.9ebdd66cb38819e.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.9ebdd66cb38819e.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.d4020a1cc9b08a46.json b/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.d4020a1cc9b08a46.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.d4020a1cc9b08a46.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.f7b7a089d09d9e1d.json b/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.f7b7a089d09d9e1d.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/DeletePaymentCredentialProviderCommand.f7b7a089d09d9e1d.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/UpdatePaymentCredentialProviderCommand.417ee64c8755742b.json b/src/handlers/identity/payment-credential-provider/__fixtures__/UpdatePaymentCredentialProviderCommand.417ee64c8755742b.json new file mode 100644 index 000000000..373e326f0 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/UpdatePaymentCredentialProviderCommand.417ee64c8755742b.json @@ -0,0 +1,24 @@ +{ + "name": "agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "providerConfigurationOutput": { + "coinbaseCdpConfiguration": { + "apiKeyId": "agentcore-cli-fixture-key-rotated", + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/apikey-eNjrnd" + }, + "walletSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/wallet-DFZFS6" + }, + "apiKeySecretSource": "MANAGED", + "walletSecretSource": "MANAGED" + } + }, + "createdTime": { + "$date": "2026-09-08T20:16:25.544Z" + }, + "lastUpdatedTime": { + "$date": "2026-09-08T20:16:27.626Z" + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/create-2.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/create-2.golden.json new file mode 100644 index 000000000..28a910f44 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/create-2.golden.json @@ -0,0 +1,18 @@ +{ + "name": "agentcore-cli-payment-fixture-2", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture-2", + "providerConfigurationOutput": { + "coinbaseCdpConfiguration": { + "apiKeyId": "agentcore-cli-fixture-key-2", + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-2-eb60ae2f/apikey-Cjw9fY" + }, + "walletSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-2-eb60ae2f/wallet-hdssxR" + }, + "apiKeySecretSource": "MANAGED", + "walletSecretSource": "MANAGED" + } + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/create-stripe.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/create-stripe.golden.json new file mode 100644 index 000000000..84e12fb4e --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/create-stripe.golden.json @@ -0,0 +1,19 @@ +{ + "name": "agentcore-cli-payment-fixture-stripe", + "credentialProviderVendor": "StripePrivy", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture-stripe", + "providerConfigurationOutput": { + "stripePrivyConfiguration": { + "appId": "agentcore-cli-fixture-app", + "appSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/stripeprivy/agentcore-cli-payment-fixture-stripe-28e8771e/appsecret-Ns6m08" + }, + "authorizationPrivateKeyArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/stripeprivy/agentcore-cli-payment-fixture-stripe-28e8771e/authprivkey-7sMU9C" + }, + "authorizationId": "agentcore-cli-fixture-auth", + "appSecretSource": "MANAGED", + "authorizationPrivateKeySource": "MANAGED" + } + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/create.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/create.golden.json new file mode 100644 index 000000000..a5443ffff --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/create.golden.json @@ -0,0 +1,18 @@ +{ + "name": "agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "providerConfigurationOutput": { + "coinbaseCdpConfiguration": { + "apiKeyId": "agentcore-cli-fixture-key", + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/apikey-eNjrnd" + }, + "walletSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/wallet-DFZFS6" + }, + "apiKeySecretSource": "MANAGED", + "walletSecretSource": "MANAGED" + } + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/delete-2.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/delete-2.golden.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/delete-2.golden.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/delete-stripe.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/delete-stripe.golden.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/delete-stripe.golden.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/delete.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/delete.golden.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/delete.golden.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/list-after-delete.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/list-after-delete.golden.json new file mode 100644 index 000000000..692af061d --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/list-after-delete.golden.json @@ -0,0 +1,60 @@ +{ + "credentialProviders": [ + { + "name": "DeployTest-CdpConn-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/DeployTest-CdpConn-cdp", + "createdTime": "2026-05-14T15:48:17.857Z", + "lastUpdatedTime": "2026-05-14T15:48:17.857Z" + }, + { + "name": "InvokeMgr-InvokeCdp-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/InvokeMgr-InvokeCdp-cdp", + "createdTime": "2026-05-20T19:09:36.566Z", + "lastUpdatedTime": "2026-05-20T19:09:36.566Z" + }, + { + "name": "MyPaymentManager-MyCdpConnector-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManager-MyCdpConnector-cdp", + "createdTime": "2026-05-05T03:02:59.583Z", + "lastUpdatedTime": "2026-05-21T19:22:56.053Z" + }, + { + "name": "MyPaymentManager-MyStripePrivyConnector-stripe-privy", + "credentialProviderVendor": "StripePrivy", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManager-MyStripePrivyConnector-stripe-privy", + "createdTime": "2026-05-21T19:22:56.308Z", + "lastUpdatedTime": "2026-05-21T19:22:56.308Z" + }, + { + "name": "MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp", + "createdTime": "2026-06-08T18:10:19.508Z", + "lastUpdatedTime": "2026-06-08T18:10:19.508Z" + }, + { + "name": "cdp-creds", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/cdp-creds", + "createdTime": "2026-05-12T21:18:46.009Z", + "lastUpdatedTime": "2026-05-12T21:33:40.066Z" + }, + { + "name": "paymgr-cdp-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/paymgr-cdp-cdp", + "createdTime": "2026-05-21T21:19:41.791Z", + "lastUpdatedTime": "2026-05-22T04:40:50.491Z" + }, + { + "name": "pmgr-cdp-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/pmgr-cdp-cdp", + "createdTime": "2026-05-22T01:59:54.168Z", + "lastUpdatedTime": "2026-05-22T02:42:24.884Z" + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/update.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/update.golden.json new file mode 100644 index 000000000..954b15006 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/update.golden.json @@ -0,0 +1,20 @@ +{ + "name": "agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "providerConfigurationOutput": { + "coinbaseCdpConfiguration": { + "apiKeyId": "agentcore-cli-fixture-key-rotated", + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/apikey-eNjrnd" + }, + "walletSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/agentcore-cli-payment-fixture-541ff54c/wallet-DFZFS6" + }, + "apiKeySecretSource": "MANAGED", + "walletSecretSource": "MANAGED" + } + }, + "createdTime": "2026-09-08T20:16:25.544Z", + "lastUpdatedTime": "2026-09-08T20:16:27.626Z" +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/create/index.tsx b/src/handlers/identity/payment-credential-provider/create/index.tsx new file mode 100644 index 000000000..a52e9b80d --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/create/index.tsx @@ -0,0 +1,38 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseTags } from "../../../utils"; +import { + PaymentProviderConfigurationResolver, + paymentCredentialProviderConfigFlags, +} from "../flags"; + +export const createCreatePaymentCredentialProviderHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a payment credential provider", + flags: [ + flag("name", "the name of the payment credential provider", z.string().optional()), + ...paymentCredentialProviderConfigFlags, + flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + const configuration = await new PaymentProviderConfigurationResolver(flags, io).resolve(); + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.createPaymentCredentialProvider( + { name: flags.name, ...configuration, tags: parseTags(flags.tags) }, + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/identity/payment-credential-provider/delete/index.tsx b/src/handlers/identity/payment-credential-provider/delete/index.tsx new file mode 100644 index 000000000..f7c12f6e0 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/delete/index.tsx @@ -0,0 +1,24 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeletePaymentCredentialProviderHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment credential provider", + flags: [flag("name", "the name of the payment credential provider", z.string().optional())], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.deletePaymentCredentialProvider(flags.name, coreOptsFromCtx(ctx)), + ); + }, + }); diff --git a/src/handlers/identity/payment-credential-provider/flags.ts b/src/handlers/identity/payment-credential-provider/flags.ts new file mode 100644 index 000000000..79b92f117 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/flags.ts @@ -0,0 +1,294 @@ +import z from "zod"; +import type { + PaymentCredentialProviderVendorType, + PaymentProviderConfigurationInput, + SecretReference, + SecretSourceType, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../../../errors"; +import { type AppIO, SourceResolver } from "../../../io"; +import { flag } from "../../../router"; +import { parseSecretReference } from "../parser"; +import { + stripWalletAuthPrefix, + validateApiKeySecret, + validateAppSecret, + validateAuthorizationPrivateKey, + validatePaymentIdentifier, + validateWalletSecret, +} from "./validation"; + +export const PAYMENT_CREDENTIAL_PROVIDER_VENDORS = [ + "CoinbaseCDP", + "StripePrivy", +] as const satisfies readonly PaymentCredentialProviderVendorType[]; + +const SECRET_SOURCE_HELP = "file://path or - for stdin; inline values are rejected"; +const SECRET_REFERENCE_HELP = + 'as an external secret reference JSON: {"secretId":"","jsonKey":""}'; + +export const paymentCredentialProviderConfigFlags = [ + flag("vendor", "the payment vendor: CoinbaseCDP or StripePrivy", z.string().optional()), + flag("api-key-id", "Coinbase CDP API key ID", z.string().optional()), + flag( + "api-key-secret", + `Coinbase CDP API key secret (${SECRET_SOURCE_HELP})`, + z.string().optional(), + { sensitive: true }, + ), + flag( + "api-key-secret-reference", + `Coinbase CDP API key secret ${SECRET_REFERENCE_HELP}`, + z.string().optional(), + ), + flag( + "wallet-secret", + `Coinbase CDP wallet secret (${SECRET_SOURCE_HELP})`, + z.string().optional(), + { + sensitive: true, + }, + ), + flag( + "wallet-secret-reference", + `Coinbase CDP wallet secret ${SECRET_REFERENCE_HELP}`, + z.string().optional(), + ), + flag("app-id", "Privy application ID", z.string().optional()), + flag("app-secret", `Privy application secret (${SECRET_SOURCE_HELP})`, z.string().optional(), { + sensitive: true, + }), + flag( + "app-secret-reference", + `Privy application secret ${SECRET_REFERENCE_HELP}`, + z.string().optional(), + ), + flag("authorization-id", "Stripe/Privy authorization identifier", z.string().optional()), + flag( + "authorization-private-key", + `Stripe/Privy authorization private key (${SECRET_SOURCE_HELP})`, + z.string().optional(), + { sensitive: true }, + ), + flag( + "authorization-private-key-reference", + `Stripe/Privy authorization private key ${SECRET_REFERENCE_HELP}`, + z.string().optional(), + ), +] as const; + +export interface PaymentCredentialProviderConfigFlags { + vendor?: string; + "api-key-id"?: string; + "api-key-secret"?: string; + "api-key-secret-reference"?: string; + "wallet-secret"?: string; + "wallet-secret-reference"?: string; + "app-id"?: string; + "app-secret"?: string; + "app-secret-reference"?: string; + "authorization-id"?: string; + "authorization-private-key"?: string; + "authorization-private-key-reference"?: string; +} + +type VendorFlagName = Exclude; +type IdentifierFlagName = "api-key-id" | "app-id" | "authorization-id"; +type SecretFlagName = + "api-key-secret" | "wallet-secret" | "app-secret" | "authorization-private-key"; + +const COINBASE_FLAGS = [ + "api-key-id", + "api-key-secret", + "api-key-secret-reference", + "wallet-secret", + "wallet-secret-reference", +] as const satisfies readonly VendorFlagName[]; +const STRIPE_PRIVY_FLAGS = [ + "app-id", + "app-secret", + "app-secret-reference", + "authorization-id", + "authorization-private-key", + "authorization-private-key-reference", +] as const satisfies readonly VendorFlagName[]; + +export interface PaymentProviderConfiguration { + credentialProviderVendor: PaymentCredentialProviderVendorType; + providerConfigurationInput: PaymentProviderConfigurationInput; +} + +type SecretInput = + | { flagName: SecretFlagName; kind: "inline"; source: string } + | { flagName: SecretFlagName; kind: "reference"; config: SecretReference }; + +interface ResolvedSecret { + value?: string; + source: SecretSourceType; + config?: SecretReference; +} + +type SecretValidator = (value: string) => true | string; + +function isPaymentCredentialProviderVendor( + value: string, +): value is PaymentCredentialProviderVendorType { + return (PAYMENT_CREDENTIAL_PROVIDER_VENDORS as readonly string[]).includes(value); +} + +// PaymentProviderConfigurationResolver turns the shared vendor flags of +// `identity payment-credential-provider create|update` into the SDK's +// providerConfigurationInput union. Shape checks (vendor, cross-vendor flags, +// identifiers, inline-versus-reference) all run before any secret is read so a +// rejected command never consumes stdin. +export class PaymentProviderConfigurationResolver { + private readonly resolver: SourceResolver; + + constructor( + private readonly flags: PaymentCredentialProviderConfigFlags, + io: AppIO, + ) { + this.resolver = new SourceResolver({ stdin: io.stdin }); + } + + async resolve(): Promise { + const vendor = this.vendor(); + this.rejectOtherVendorFlags(vendor); + return vendor === "CoinbaseCDP" ? this.resolveCoinbaseCdp() : this.resolveStripePrivy(); + } + + private async resolveCoinbaseCdp(): Promise { + const apiKeyId = this.identifier("api-key-id"); + const apiKeySecretInput = this.secretInput("api-key-secret"); + const walletSecretInput = this.secretInput("wallet-secret"); + if (this.flags["api-key-secret"] === "-" && this.flags["wallet-secret"] === "-") { + throw new InputValidationError( + "--api-key-secret and --wallet-secret cannot both read from stdin", + ); + } + const apiKeySecret = await this.secret(apiKeySecretInput, validateApiKeySecret); + const walletSecret = await this.secret(walletSecretInput, validateWalletSecret); + + return { + credentialProviderVendor: "CoinbaseCDP", + providerConfigurationInput: { + coinbaseCdpConfiguration: { + apiKeyId, + apiKeySecret: apiKeySecret.value, + apiKeySecretSource: apiKeySecret.source, + apiKeySecretConfig: apiKeySecret.config, + walletSecret: walletSecret.value, + walletSecretSource: walletSecret.source, + walletSecretConfig: walletSecret.config, + }, + }, + }; + } + + private async resolveStripePrivy(): Promise { + const appId = this.identifier("app-id"); + const authorizationId = this.identifier("authorization-id"); + const appSecretInput = this.secretInput("app-secret"); + const authorizationPrivateKeyInput = this.secretInput("authorization-private-key"); + if (this.flags["app-secret"] === "-" && this.flags["authorization-private-key"] === "-") { + throw new InputValidationError( + "--app-secret and --authorization-private-key cannot both read from stdin", + ); + } + const appSecret = await this.secret(appSecretInput, validateAppSecret); + const authorizationPrivateKey = await this.secret( + authorizationPrivateKeyInput, + validateAuthorizationPrivateKey, + stripWalletAuthPrefix, + ); + + return { + credentialProviderVendor: "StripePrivy", + providerConfigurationInput: { + stripePrivyConfiguration: { + appId, + appSecret: appSecret.value, + appSecretSource: appSecret.source, + appSecretConfig: appSecret.config, + authorizationPrivateKey: authorizationPrivateKey.value, + authorizationPrivateKeySource: authorizationPrivateKey.source, + authorizationPrivateKeyConfig: authorizationPrivateKey.config, + authorizationId, + }, + }, + }; + } + + private vendor(): PaymentCredentialProviderVendorType { + const vendor = this.flags.vendor; + if (vendor === undefined) { + throw new InputValidationError("required option '--vendor ' not specified"); + } + if (!isPaymentCredentialProviderVendor(vendor)) { + throw new InputValidationError( + `--vendor must be one of ${PAYMENT_CREDENTIAL_PROVIDER_VENDORS.join(", ")}`, + ); + } + return vendor; + } + + private rejectOtherVendorFlags(vendor: PaymentCredentialProviderVendorType): void { + const otherVendorFlags = vendor === "CoinbaseCDP" ? STRIPE_PRIVY_FLAGS : COINBASE_FLAGS; + const passed = otherVendorFlags.filter((flagName) => this.flags[flagName] !== undefined); + if (passed.length === 0) return; + throw new InputValidationError( + `${passed.map((flagName) => `--${flagName}`).join(", ")} ${ + passed.length === 1 ? "is" : "are" + } not valid with --vendor ${vendor}`, + ); + } + + private identifier(flagName: IdentifierFlagName): string { + const raw = this.flags[flagName]; + if (raw === undefined) { + throw new InputValidationError(`required option '--${flagName} <${flagName}>' not specified`); + } + const value = raw.trim(); + const validation = validatePaymentIdentifier(`--${flagName}`, value); + if (validation !== true) throw new InputValidationError(validation); + return value; + } + + private secretInput(flagName: SecretFlagName): SecretInput { + const referenceFlagName = `${flagName}-reference` as const; + const source = this.flags[flagName]; + const reference = this.flags[referenceFlagName]; + if (source !== undefined && reference !== undefined) { + throw new InputValidationError( + `--${flagName} and --${referenceFlagName} are mutually exclusive`, + ); + } + if (source === undefined && reference === undefined) { + throw new InputValidationError(`either --${flagName} or --${referenceFlagName} is required`); + } + if (source !== undefined) return { flagName, kind: "inline", source }; + return { + flagName, + kind: "reference", + config: parseSecretReference(referenceFlagName, reference!), + }; + } + + private async secret( + input: SecretInput, + validate: SecretValidator, + normalize: (value: string) => string = (value) => value.trim(), + ): Promise { + if (input.kind === "reference") return { source: "EXTERNAL", config: input.config }; + + const value = normalize( + (await this.resolver.resolveSecret(input.flagName, input.source)) ?? "", + ); + if (value.length === 0) { + throw new InputValidationError(`--${input.flagName} must not be empty`); + } + const validation = validate(value); + if (validation !== true) throw new InputValidationError(validation); + return { source: "MANAGED", value }; + } +} diff --git a/src/handlers/identity/payment-credential-provider/index.tsx b/src/handlers/identity/payment-credential-provider/index.tsx index ed8323500..efe695139 100644 --- a/src/handlers/identity/payment-credential-provider/index.tsx +++ b/src/handlers/identity/payment-credential-provider/index.tsx @@ -2,13 +2,19 @@ import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; +import { createCreatePaymentCredentialProviderHandler } from "./create"; +import { createDeletePaymentCredentialProviderHandler } from "./delete"; import { createGetPaymentCredentialProviderHandler } from "./get"; import { createListPaymentCredentialProvidersHandler } from "./list"; +import { createUpdatePaymentCredentialProviderHandler } from "./update"; export function createPaymentCredentialProviderHandler(core: Core, io: AppIO): Router { return new Router("payment-credential-provider", "manage payment credential providers") .default(renderTui(core, io)) .supportedTuiCommands() + .handler(createCreatePaymentCredentialProviderHandler(core, io)) .handler(createGetPaymentCredentialProviderHandler(core)) - .handler(createListPaymentCredentialProvidersHandler(core)); + .handler(createListPaymentCredentialProvidersHandler(core)) + .handler(createUpdatePaymentCredentialProviderHandler(core, io)) + .handler(createDeletePaymentCredentialProviderHandler(core)); } diff --git a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx index 1ae97cb9b..8a0eb93e8 100644 --- a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx +++ b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx @@ -40,8 +40,8 @@ async function run(args: string[]): Promise { return io.stdout(); } -describe("payment-credential-provider read-only command hierarchy", () => { - test("registers get and list only, with no create, update, or delete commands", () => { +describe("payment-credential-provider command hierarchy", () => { + test("retains get and list alongside the mutation commands", () => { const root = createRootHandler(createFixtureCore(), { io: testIO().io, logger: createSilentLogger(), @@ -52,7 +52,13 @@ describe("payment-credential-provider read-only command hierarchy", () => { ?.children() .find((child) => child.name() === "payment-credential-provider"); - expect(payment?.children().map((child) => child.name())).toEqual(["get", "list"]); + expect(payment?.children().map((child) => child.name())).toEqual([ + "create", + "get", + "list", + "update", + "delete", + ]); }); test("prints command help with --json", async () => { diff --git a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx new file mode 100644 index 000000000..74ed0db50 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx @@ -0,0 +1,1069 @@ +import { afterAll, describe, expect, mock, spyOn, test } from "bun:test"; +import { createPrivateKey, createPublicKey } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { CoreClient } from "../../../core"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { createRootHandler } from "../../index"; + +const REGION = "us-west-2"; +const FIXTURES = join(import.meta.dir, "__fixtures__"); +const BASE = ["identity", "payment-credential-provider"]; + +// Record with RECORD=1 AWS_PROFILE=deploy bun test src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx +// None of the fixture providers should exist before recording. The RECORD run creates +// two CoinbaseCDP providers, exercises pagination (requires >=2), updates one, attempts +// a StripePrivy provider, then deletes everything it created. +const FIXTURE_PROVIDER_NAME = "agentcore-cli-payment-fixture"; +const FIXTURE_PROVIDER_NAME_2 = "agentcore-cli-payment-fixture-2"; +const FIXTURE_STRIPE_PROVIDER_NAME = "agentcore-cli-payment-fixture-stripe"; +const SECRET_REFERENCE = { + secretId: "arn:aws:secretsmanager:us-west-2:123:secret:payment-fixture", + jsonKey: "secret", +}; +const SECRET_REFERENCE_JSON = JSON.stringify(SECRET_REFERENCE); + +// Fixtures are keyed by a hash of the request, so the throwaway secrets must be identical on +// every record and replay. They are derived from fixed bytes rather than committed as key +// material. The service wants the Coinbase CDP form of an Ed25519 key (32-byte seed followed +// by the 32-byte public key; a bare seed is rejected), and the P-256 key is assembled as SEC1 +// DER from a fixed scalar (the same encoding `openssl ecparam -genkey -outform DER` emits). +const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +function ed25519PrivateKey(fill: number): string { + const seed = Buffer.alloc(32, fill); + const key = createPrivateKey({ + key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]), + format: "der", + type: "pkcs8", + }); + const spki = createPublicKey(key).export({ format: "der", type: "spki" }) as Buffer; + return Buffer.concat([seed, spki.subarray(spki.length - 32)]).toString("base64"); +} + +const P256_OID = Buffer.from([0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07]); +const EC_PUBLIC_KEY_OID = Buffer.from([0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]); + +function der(tag: number, body: Buffer): Buffer { + if (body.length > 127) throw new Error("single-byte DER lengths only"); + return Buffer.concat([Buffer.from([tag, body.length]), body]); +} + +function p256PrivateKey(fill: number): string { + const scalar = Buffer.alloc(32, fill); + const bareKey = der(0x30, Buffer.concat([der(0x02, Buffer.from([1])), der(0x04, scalar)])); + const algorithm = der(0x30, Buffer.concat([EC_PUBLIC_KEY_OID, P256_OID])); + const pkcs8 = der( + 0x30, + Buffer.concat([der(0x02, Buffer.from([0])), algorithm, der(0x04, bareKey)]), + ); + const spki = createPublicKey( + createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" }), + ).export({ format: "der", type: "spki" }) as Buffer; + const point = spki.subarray(spki.length - 65); + return der( + 0x30, + Buffer.concat([ + der(0x02, Buffer.from([1])), + der(0x04, scalar), + der(0xa0, P256_OID), + der(0xa1, der(0x03, Buffer.concat([Buffer.from([0]), point]))), + ]), + ).toString("base64"); +} + +const API_KEY_SECRET = ed25519PrivateKey(0x11); +const WALLET_SECRET = p256PrivateKey(0x22); +const API_KEY_SECRET_2 = ed25519PrivateKey(0x33); +const WALLET_SECRET_2 = p256PrivateKey(0x44); +const UPDATED_API_KEY_SECRET = ed25519PrivateKey(0x55); +const UPDATED_WALLET_SECRET = p256PrivateKey(0x66); +const APP_SECRET = p256PrivateKey(0x77); +const AUTHORIZATION_PRIVATE_KEY = p256PrivateKey(0x88); + +const SECRETS_DIR = mkdtempSync(join(tmpdir(), "agentcore-payment-fixture-")); +afterAll(() => rmSync(SECRETS_DIR, { recursive: true, force: true })); + +function secretFile(name: string, content: string): string { + const path = join(SECRETS_DIR, name); + writeFileSync(path, `${content}\n`); + return `file://${path}`; +} + +const WALLET_SECRET_FILE = secretFile("wallet-secret", WALLET_SECRET); +const WALLET_SECRET_FILE_2 = secretFile("wallet-secret-2", WALLET_SECRET_2); +const UPDATED_WALLET_SECRET_FILE = secretFile("wallet-secret-updated", UPDATED_WALLET_SECRET); +const AUTHORIZATION_PRIVATE_KEY_FILE = secretFile( + "authorization-private-key", + `wallet-auth:${AUTHORIZATION_PRIVATE_KEY}`, +); + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +async function runRecorded(args: string[], stdin?: string): Promise { + const io = testIO({ stdin }); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +async function run( + args: string[], + stdin?: string, + core = new TestCoreClient(), +): Promise<{ core: TestCoreClient; stdout: string }> { + const io = testIO({ stdin }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return { core, stdout: io.stdout() }; +} + +describe("payment-credential-provider command hierarchy", () => { + test("registers the payment-credential-provider command hierarchy", () => { + const root = createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const identity = root.children().find((child) => child.name() === "identity"); + const payment = identity + ?.children() + .find((child) => child.name() === "payment-credential-provider"); + + expect(payment?.children().map((child) => child.name())).toEqual([ + "create", + "get", + "list", + "update", + "delete", + ]); + }); + + test("prints help for `identity payment-credential-provider --json` without an SDK call", async () => { + const { core, stdout } = await run([...BASE, "--json"]); + + expect(stdout).toContain("Usage: agentcore identity payment-credential-provider"); + expect(stdout).toContain("Commands:"); + expect(core.identity.calls).toEqual([]); + }); +}); + +describe("payment-credential-provider TUI dispatch", () => { + test("opens the TUI for a bare `payment-credential-provider`", async () => { + await expect(run([...BASE])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }); + + test.each(["create", "get", "update", "delete"] as const)( + "runs normal validation for bare CLI-only `%s`", + async (command) => { + await expect(run([...BASE, command])).rejects.toThrow( + "required option '--name ' not specified", + ); + }, + ); + + test("runs a bare `list` headlessly", async () => { + const { core } = await run([...BASE, "list"]); + + expect(core.identity.calls).toEqual([ + { + method: "listPaymentCredentialProviders", + args: [undefined, undefined, { region: REGION }], + }, + ]); + }); +}); + +describe("payment-credential-provider flag validation", () => { + test.each([ + ["create", "CoinbaseCDP", ["--api-key-id", "k"], ["api-key-secret", "wallet-secret"]], + ["update", "CoinbaseCDP", ["--api-key-id", "k"], ["api-key-secret", "wallet-secret"]], + [ + "create", + "StripePrivy", + ["--app-id", "a", "--authorization-id", "b"], + ["app-secret", "authorization-private-key"], + ], + [ + "update", + "StripePrivy", + ["--app-id", "a", "--authorization-id", "b"], + ["app-secret", "authorization-private-key"], + ], + ] as const)( + "`%s` rejects competing %s stdin secrets before Core or IO", + async (command, vendor, identifiers, secretFlags) => { + const factories = fixtureFactories(FIXTURES); + const sdk = mock(() => { + throw new Error("unexpected SDK client creation"); + }); + for (const name of Object.keys(factories) as (keyof typeof factories)[]) { + spyOn(factories, name).mockImplementation(sdk); + } + const core = new CoreClient({ ...factories, logger: createSilentLogger() }); + const call = spyOn( + core.identity, + command === "create" + ? "createPaymentCredentialProvider" + : "updatePaymentCredentialProvider", + ); + const read = mock(() => { + throw new Error("unexpected stdin read"); + }); + const stdin = new Readable({ read }); + const io = testIO(); + io.io.stdin = stdin as NodeJS.ReadStream; + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + try { + await expect( + root.route([ + "node", + "agentcore", + ...BASE, + command, + "--name", + "x", + "--vendor", + vendor, + ...identifiers, + ...secretFlags.flatMap((flagName) => [`--${flagName}`, "-"]), + "--region", + REGION, + ]), + ).rejects.toThrow( + `--${secretFlags[0]} and --${secretFlags[1]} cannot both read from stdin`, + ); + expect(call).not.toHaveBeenCalled(); + expect(sdk).not.toHaveBeenCalled(); + expect(read).not.toHaveBeenCalled(); + expect(io.stdout()).toBe(""); + expect(io.stderr()).toBe(""); + } finally { + call.mockRestore(); + stdin.destroy(); + } + }, + ); + + test.each([ + ["create --name only", [...BASE, "create", "--name", "x"], /--vendor /], + [ + "create with an unknown vendor", + [...BASE, "create", "--name", "x", "--vendor", "Square"], + "--vendor must be one of CoinbaseCDP, StripePrivy", + ], + ["get --json (no name)", [...BASE, "get", "--json"], /--name /], + ["delete --json (no name)", [...BASE, "delete", "--json"], /--name /], + ["update --name only", [...BASE, "update", "--name", "x"], /--vendor /], + [ + "update rejects --tags", + [...BASE, "update", "--name", "x", "--vendor", "CoinbaseCDP", "--tags", "a=b"], + /--tags/, + ], + [ + "CoinbaseCDP without --api-key-id", + [...BASE, "create", "--name", "x", "--vendor", "CoinbaseCDP", "--api-key-secret", "-"], + "required option '--api-key-id ' not specified", + ], + [ + "StripePrivy without --app-id", + [...BASE, "create", "--name", "x", "--vendor", "StripePrivy", "--authorization-id", "a"], + "required option '--app-id ' not specified", + ], + [ + "StripePrivy without --authorization-id", + [...BASE, "create", "--name", "x", "--vendor", "StripePrivy", "--app-id", "a"], + "required option '--authorization-id ' not specified", + ], + ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { + await expect(run([...args])).rejects.toThrow(message); + }); + + test.each([ + [ + "--app-id with CoinbaseCDP", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "k", + "--app-id", + "a", + ], + "--app-id is not valid with --vendor CoinbaseCDP", + ], + [ + "Coinbase flags with StripePrivy", + [ + ...BASE, + "update", + "--name", + "x", + "--vendor", + "StripePrivy", + "--api-key-id", + "k", + "--wallet-secret-reference", + SECRET_REFERENCE_JSON, + ], + "--api-key-id, --wallet-secret-reference are not valid with --vendor StripePrivy", + ], + ] as const)("rejects flags of the other vendor for `%s`", async (_label, args, message) => { + await expect(run([...args])).rejects.toThrow(message); + }); + + test.each([ + [ + "CoinbaseCDP without an api key secret", + [...BASE, "create", "--name", "x", "--vendor", "CoinbaseCDP", "--api-key-id", "k"], + "either --api-key-secret or --api-key-secret-reference is required", + ], + [ + "CoinbaseCDP without a wallet secret", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "k", + "--api-key-secret", + "-", + ], + "either --wallet-secret or --wallet-secret-reference is required", + ], + [ + "CoinbaseCDP with both api key secret forms", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "k", + "--api-key-secret", + "-", + "--api-key-secret-reference", + SECRET_REFERENCE_JSON, + "--wallet-secret-reference", + SECRET_REFERENCE_JSON, + ], + "--api-key-secret and --api-key-secret-reference are mutually exclusive", + ], + [ + "StripePrivy without an app secret", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "StripePrivy", + "--app-id", + "a", + "--authorization-id", + "b", + "--authorization-private-key-reference", + SECRET_REFERENCE_JSON, + ], + "either --app-secret or --app-secret-reference is required", + ], + [ + "StripePrivy with both authorization private key forms", + [ + ...BASE, + "update", + "--name", + "x", + "--vendor", + "StripePrivy", + "--app-id", + "a", + "--authorization-id", + "b", + "--app-secret-reference", + SECRET_REFERENCE_JSON, + "--authorization-private-key", + "-", + "--authorization-private-key-reference", + SECRET_REFERENCE_JSON, + ], + "--authorization-private-key and --authorization-private-key-reference are mutually exclusive", + ], + [ + "inline api key secret value", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "k", + "--api-key-secret", + API_KEY_SECRET, + "--wallet-secret-reference", + SECRET_REFERENCE_JSON, + ], + /--api-key-secret must come from stdin \('-'\) or a file \('file:\/\/'\)/, + ], + [ + "malformed secret reference", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "k", + "--api-key-secret-reference", + '{"jsonKey":"k"}', + "--wallet-secret-reference", + SECRET_REFERENCE_JSON, + ], + /--api-key-secret-reference must be a JSON object/, + ], + [ + "invalid api key id", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "bad key!", + "--api-key-secret-reference", + SECRET_REFERENCE_JSON, + "--wallet-secret-reference", + SECRET_REFERENCE_JSON, + ], + "--api-key-id must contain only alphanumeric characters, hyphens, and underscores", + ], + ] as const)("rejects invalid secret input for `%s`", async (_label, args, message) => { + const core = new TestCoreClient(); + + await expect(run([...args], undefined, core)).rejects.toThrow(message); + expect(core.identity.calls).toEqual([]); + }); + + test.each([ + [ + "api key secret that is not an Ed25519 key", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "k", + "--api-key-secret", + "-", + "--wallet-secret-reference", + SECRET_REFERENCE_JSON, + ], + "not-base64!", + /Ed25519/, + ], + [ + "wallet secret that is not a P-256 key", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "k", + "--api-key-secret-reference", + SECRET_REFERENCE_JSON, + "--wallet-secret", + "-", + ], + API_KEY_SECRET, + /P-256/, + ], + [ + "authorization private key that is not base64", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "StripePrivy", + "--app-id", + "a", + "--authorization-id", + "b", + "--app-secret-reference", + SECRET_REFERENCE_JSON, + "--authorization-private-key", + "-", + ], + "wallet-auth:not-base64!", + /authorizationPrivateKey must be base64-encoded/, + ], + [ + "empty app secret", + [ + ...BASE, + "create", + "--name", + "x", + "--vendor", + "StripePrivy", + "--app-id", + "a", + "--authorization-id", + "b", + "--app-secret", + "-", + "--authorization-private-key-reference", + SECRET_REFERENCE_JSON, + ], + "", + "--app-secret must not be empty", + ], + ] as const)("rejects a malformed %s", async (_label, args, stdin, message) => { + const core = new TestCoreClient(); + + await expect(run([...args], stdin, core)).rejects.toThrow(message); + expect(core.identity.calls).toEqual([]); + }); +}); + +describe("payment-credential-provider request mapping", () => { + test("creates a CoinbaseCDP provider with managed secrets and tags", async () => { + const { core } = await run( + [ + ...BASE, + "create", + "--name", + "cdp", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + " cdp-key-1 ", + "--api-key-secret", + "-", + "--wallet-secret", + WALLET_SECRET_FILE, + "--tags", + "team=payments", + "--tags", + "env=test", + ], + API_KEY_SECRET, + ); + + expect(core.identity.calls).toEqual([ + { + method: "createPaymentCredentialProvider", + args: [ + { + name: "cdp", + credentialProviderVendor: "CoinbaseCDP", + providerConfigurationInput: { + coinbaseCdpConfiguration: { + apiKeyId: "cdp-key-1", + apiKeySecret: API_KEY_SECRET, + apiKeySecretSource: "MANAGED", + walletSecret: WALLET_SECRET, + walletSecretSource: "MANAGED", + }, + }, + tags: { team: "payments", env: "test" }, + }, + { region: REGION }, + ], + }, + ]); + }); + + test("creates a CoinbaseCDP provider with external secret references", async () => { + const { core } = await run([ + ...BASE, + "create", + "--name", + "cdp", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "cdp-key-1", + "--api-key-secret-reference", + SECRET_REFERENCE_JSON, + "--wallet-secret-reference", + JSON.stringify({ ...SECRET_REFERENCE, jsonKey: "wallet" }), + ]); + + expect(core.identity.calls).toEqual([ + { + method: "createPaymentCredentialProvider", + args: [ + { + name: "cdp", + credentialProviderVendor: "CoinbaseCDP", + providerConfigurationInput: { + coinbaseCdpConfiguration: { + apiKeyId: "cdp-key-1", + apiKeySecretSource: "EXTERNAL", + apiKeySecretConfig: SECRET_REFERENCE, + walletSecretSource: "EXTERNAL", + walletSecretConfig: { ...SECRET_REFERENCE, jsonKey: "wallet" }, + }, + }, + }, + { region: REGION }, + ], + }, + ]); + }); + + test("mixes a managed api key secret with an external wallet secret", async () => { + const { core } = await run( + [ + ...BASE, + "create", + "--name", + "cdp", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "cdp-key-1", + "--api-key-secret", + "-", + "--wallet-secret-reference", + SECRET_REFERENCE_JSON, + ], + API_KEY_SECRET, + ); + + expect(core.identity.calls[0]?.args[0]).toMatchObject({ + providerConfigurationInput: { + coinbaseCdpConfiguration: { + apiKeySecret: API_KEY_SECRET, + apiKeySecretSource: "MANAGED", + walletSecretSource: "EXTERNAL", + walletSecretConfig: SECRET_REFERENCE, + }, + }, + }); + }); + + test("creates a StripePrivy provider with managed secrets and strips the wallet-auth prefix", async () => { + const { core } = await run( + [ + ...BASE, + "create", + "--name", + "privy", + "--vendor", + "StripePrivy", + "--app-id", + "privy-app", + "--app-secret", + "-", + "--authorization-id", + "privy-auth", + "--authorization-private-key", + AUTHORIZATION_PRIVATE_KEY_FILE, + ], + APP_SECRET, + ); + + expect(core.identity.calls).toEqual([ + { + method: "createPaymentCredentialProvider", + args: [ + { + name: "privy", + credentialProviderVendor: "StripePrivy", + providerConfigurationInput: { + stripePrivyConfiguration: { + appId: "privy-app", + appSecret: APP_SECRET, + appSecretSource: "MANAGED", + authorizationPrivateKey: AUTHORIZATION_PRIVATE_KEY, + authorizationPrivateKeySource: "MANAGED", + authorizationId: "privy-auth", + }, + }, + }, + { region: REGION }, + ], + }, + ]); + }); + + test("creates a StripePrivy provider with external secret references", async () => { + const { core } = await run([ + ...BASE, + "create", + "--name", + "privy", + "--vendor", + "StripePrivy", + "--app-id", + "privy-app", + "--app-secret-reference", + SECRET_REFERENCE_JSON, + "--authorization-id", + "privy-auth", + "--authorization-private-key-reference", + JSON.stringify({ ...SECRET_REFERENCE, jsonKey: "authorization" }), + ]); + + expect(core.identity.calls[0]?.args[0]).toEqual({ + name: "privy", + credentialProviderVendor: "StripePrivy", + providerConfigurationInput: { + stripePrivyConfiguration: { + appId: "privy-app", + appSecretSource: "EXTERNAL", + appSecretConfig: SECRET_REFERENCE, + authorizationPrivateKeySource: "EXTERNAL", + authorizationPrivateKeyConfig: { ...SECRET_REFERENCE, jsonKey: "authorization" }, + authorizationId: "privy-auth", + }, + }, + }); + }); + + test("updates a CoinbaseCDP provider with a full replacement configuration", async () => { + const { core } = await run( + [ + ...BASE, + "update", + "--name", + "cdp", + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "cdp-key-2", + "--api-key-secret", + "-", + "--wallet-secret", + UPDATED_WALLET_SECRET_FILE, + ], + UPDATED_API_KEY_SECRET, + ); + + expect(core.identity.calls).toEqual([ + { + method: "updatePaymentCredentialProvider", + args: [ + { + name: "cdp", + credentialProviderVendor: "CoinbaseCDP", + providerConfigurationInput: { + coinbaseCdpConfiguration: { + apiKeyId: "cdp-key-2", + apiKeySecret: UPDATED_API_KEY_SECRET, + apiKeySecretSource: "MANAGED", + walletSecret: UPDATED_WALLET_SECRET, + walletSecretSource: "MANAGED", + }, + }, + }, + { region: REGION }, + ], + }, + ]); + expect(core.identity.calls[0]?.args[0]).not.toHaveProperty("tags"); + }); + + test("updates a StripePrivy provider with external secret references", async () => { + const { core } = await run([ + ...BASE, + "update", + "--name", + "privy", + "--vendor", + "StripePrivy", + "--app-id", + "privy-app", + "--app-secret-reference", + SECRET_REFERENCE_JSON, + "--authorization-id", + "privy-auth", + "--authorization-private-key-reference", + SECRET_REFERENCE_JSON, + ]); + + expect(core.identity.calls).toEqual([ + { + method: "updatePaymentCredentialProvider", + args: [ + { + name: "privy", + credentialProviderVendor: "StripePrivy", + providerConfigurationInput: { + stripePrivyConfiguration: { + appId: "privy-app", + appSecretSource: "EXTERNAL", + appSecretConfig: SECRET_REFERENCE, + authorizationPrivateKeySource: "EXTERNAL", + authorizationPrivateKeyConfig: SECRET_REFERENCE, + authorizationId: "privy-auth", + }, + }, + }, + { region: REGION }, + ], + }, + ]); + }); + + test("passes --name through to get and delete", async () => { + const get = await run([...BASE, "get", "--name", "cdp"]); + const del = await run([...BASE, "delete", "--name", "cdp"]); + + expect(get.core.identity.calls).toEqual([ + { method: "getPaymentCredentialProvider", args: ["cdp", { region: REGION }] }, + ]); + expect(del.core.identity.calls).toEqual([ + { method: "deletePaymentCredentialProvider", args: ["cdp", { region: REGION }] }, + ]); + }); + + test("passes pagination flags through to list", async () => { + const { core } = await run([...BASE, "list", "--next-token", "token-1", "--max-results", "5"]); + + expect(core.identity.calls).toEqual([ + { method: "listPaymentCredentialProviders", args: ["token-1", 5, { region: REGION }] }, + ]); + }); +}); + +describe("payment-credential-provider CRUDL", () => { + test("creates a CoinbaseCDP payment credential provider", async () => { + const stdout = await runRecorded( + [ + ...BASE, + "create", + "--name", + FIXTURE_PROVIDER_NAME, + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "agentcore-cli-fixture-key", + "--api-key-secret", + "-", + "--wallet-secret", + WALLET_SECRET_FILE, + "--tags", + "owner=agentcore-cli-tests", + ], + API_KEY_SECRET, + ); + + matchGolden(FIXTURES, "create.golden.json", stdout); + expect(JSON.parse(stdout)).toMatchObject({ + name: FIXTURE_PROVIDER_NAME, + credentialProviderVendor: "CoinbaseCDP", + }); + }); + + test("creates a second CoinbaseCDP provider for pagination", async () => { + const stdout = await runRecorded( + [ + ...BASE, + "create", + "--name", + FIXTURE_PROVIDER_NAME_2, + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "agentcore-cli-fixture-key-2", + "--api-key-secret", + "-", + "--wallet-secret", + WALLET_SECRET_FILE_2, + ], + API_KEY_SECRET_2, + ); + + matchGolden(FIXTURES, "create-2.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME_2); + }); + + test("gets a payment credential provider", async () => { + const stdout = await runRecorded([...BASE, "get", "--name", FIXTURE_PROVIDER_NAME]); + + matchGolden(FIXTURES, "get.golden.json", stdout); + expect(JSON.parse(stdout)).toMatchObject({ + name: FIXTURE_PROVIDER_NAME, + credentialProviderVendor: "CoinbaseCDP", + }); + }); + + test("lists payment credential providers", async () => { + const stdout = await runRecorded([...BASE, "list", "--json"]); + + matchGolden(FIXTURES, "list.golden.json", stdout); + const names = JSON.parse(stdout).credentialProviders.map( + (provider: { name: string }) => provider.name, + ); + expect(names).toContain(FIXTURE_PROVIDER_NAME); + expect(names).toContain(FIXTURE_PROVIDER_NAME_2); + }); + + test("paginates the list with --max-results and --next-token", async () => { + const firstPage = await runRecorded([...BASE, "list", "--max-results", "1"]); + matchGolden(FIXTURES, "list-page-1.golden.json", firstPage); + + const first = JSON.parse(firstPage); + expect(first.credentialProviders).toHaveLength(1); + expect(first.nextToken).toBeString(); + + const secondPage = await runRecorded([ + ...BASE, + "list", + "--max-results", + "1", + "--next-token", + first.nextToken, + ]); + matchGolden(FIXTURES, "list-page-2.golden.json", secondPage); + expect(JSON.parse(secondPage).credentialProviders).toHaveLength(1); + }); + + test("updates a payment credential provider with fresh keys", async () => { + const stdout = await runRecorded( + [ + ...BASE, + "update", + "--name", + FIXTURE_PROVIDER_NAME, + "--vendor", + "CoinbaseCDP", + "--api-key-id", + "agentcore-cli-fixture-key-rotated", + "--api-key-secret", + "-", + "--wallet-secret", + UPDATED_WALLET_SECRET_FILE, + ], + UPDATED_API_KEY_SECRET, + ); + + matchGolden(FIXTURES, "update.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); + }); + + test("creates a StripePrivy payment credential provider", async () => { + const stdout = await runRecorded( + [ + ...BASE, + "create", + "--name", + FIXTURE_STRIPE_PROVIDER_NAME, + "--vendor", + "StripePrivy", + "--app-id", + "agentcore-cli-fixture-app", + "--app-secret", + "-", + "--authorization-id", + "agentcore-cli-fixture-auth", + "--authorization-private-key", + AUTHORIZATION_PRIVATE_KEY_FILE, + ], + APP_SECRET, + ); + + matchGolden(FIXTURES, "create-stripe.golden.json", stdout); + expect(JSON.parse(stdout)).toMatchObject({ + name: FIXTURE_STRIPE_PROVIDER_NAME, + credentialProviderVendor: "StripePrivy", + }); + }); + + test("deletes the StripePrivy payment credential provider", async () => { + const stdout = await runRecorded([...BASE, "delete", "--name", FIXTURE_STRIPE_PROVIDER_NAME]); + + matchGolden(FIXTURES, "delete-stripe.golden.json", stdout); + }); + + test("deletes the first payment credential provider", async () => { + const stdout = await runRecorded([...BASE, "delete", "--name", FIXTURE_PROVIDER_NAME]); + + matchGolden(FIXTURES, "delete.golden.json", stdout); + }); + + test("deletes the second payment credential provider", async () => { + const stdout = await runRecorded([...BASE, "delete", "--name", FIXTURE_PROVIDER_NAME_2]); + + matchGolden(FIXTURES, "delete-2.golden.json", stdout); + }); + + test("propagates ResourceNotFoundException from get once the provider is deleted", async () => { + await expect( + runRecorded([...BASE, "get", "--name", FIXTURE_PROVIDER_NAME_2]), + ).rejects.toMatchObject({ name: "ResourceNotFoundException" }); + }); + + test("no longer lists the deleted providers", async () => { + const stdout = await runRecorded([...BASE, "list", "--max-results", "20"]); + + matchGolden(FIXTURES, "list-after-delete.golden.json", stdout); + const names = JSON.parse(stdout).credentialProviders.map( + (provider: { name: string }) => provider.name, + ); + expect(names).not.toContain(FIXTURE_PROVIDER_NAME); + expect(names).not.toContain(FIXTURE_PROVIDER_NAME_2); + expect(names).not.toContain(FIXTURE_STRIPE_PROVIDER_NAME); + }); +}); diff --git a/src/handlers/identity/payment-credential-provider/update/index.tsx b/src/handlers/identity/payment-credential-provider/update/index.tsx new file mode 100644 index 000000000..2ed38da84 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/update/index.tsx @@ -0,0 +1,39 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import { + PaymentProviderConfigurationResolver, + paymentCredentialProviderConfigFlags, +} from "../flags"; + +// The service replaces the whole vendor configuration on update, so the flags +// and validation are the same as create minus tags. +export const createUpdatePaymentCredentialProviderHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update a payment credential provider", + flags: [ + flag("name", "the name of the payment credential provider", z.string().optional()), + ...paymentCredentialProviderConfigFlags, + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + const configuration = await new PaymentProviderConfigurationResolver(flags, io).resolve(); + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.updatePaymentCredentialProvider( + { name: flags.name, ...configuration }, + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/project/add/credentials/payment/validation.test.ts b/src/handlers/identity/payment-credential-provider/validation.test.ts similarity index 88% rename from src/handlers/project/add/credentials/payment/validation.test.ts rename to src/handlers/identity/payment-credential-provider/validation.test.ts index ac72406b4..2515a5c90 100644 --- a/src/handlers/project/add/credentials/payment/validation.test.ts +++ b/src/handlers/identity/payment-credential-provider/validation.test.ts @@ -20,6 +20,9 @@ describe("payment credential key validation", () => { test("rejects invalid Coinbase key formats", () => { expect(validateApiKeySecret("not-base64")).toContain("Ed25519"); expect(validateApiKeySecret(Buffer.alloc(48, 0x41).toString("base64"))).toContain("length"); + // A bare 32-byte seed is a valid Ed25519 key in general, but the service + // rejects it, so fail fast on the same shape it enforces. + expect(validateApiKeySecret(Buffer.alloc(32, 0x41).toString("base64"))).toContain("length"); expect(validateWalletSecret(ed25519Key)).toContain("P-256"); }); diff --git a/src/handlers/project/add/credentials/payment/validation.ts b/src/handlers/identity/payment-credential-provider/validation.ts similarity index 90% rename from src/handlers/project/add/credentials/payment/validation.ts rename to src/handlers/identity/payment-credential-provider/validation.ts index 9160258eb..d2f06b9a9 100644 --- a/src/handlers/project/add/credentials/payment/validation.ts +++ b/src/handlers/identity/payment-credential-provider/validation.ts @@ -1,5 +1,8 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]+=*$/; -const ED25519_KEY_LENGTHS = new Set([32, 64]); +// Coinbase CDP hands out the 64-byte seed‖public-key form, and the service rejects a +// bare 32-byte seed ("Expected base64-encoded Ed25519 private key"), so only the +// 64-byte form is accepted here. +const ED25519_KEY_LENGTHS = new Set([64]); const P256_MIN_BYTES = 100; const P256_MAX_BYTES = 200; const WALLET_AUTH_PREFIX = "wallet-auth:"; diff --git a/src/handlers/identity/types.tsx b/src/handlers/identity/types.tsx index 6f8363f90..feff95666 100644 --- a/src/handlers/identity/types.tsx +++ b/src/handlers/identity/types.tsx @@ -77,8 +77,8 @@ export interface CoreIdentityClient { // Payment credential providers hold a payment vendor's own credentials (a Coinbase // CDP API key and wallet secret, or Privy app and authorization secrets). They back - // payment connectors and can be inspected with `identity payment-credential-provider`. - // Project deployment uses the write operations below. + // payment connectors and are managed by `agentcore identity + // payment-credential-provider` as well as provisioned by `project deploy`. createPaymentCredentialProvider( input: CreatePaymentCredentialProviderInput, options: CoreOptions, diff --git a/src/handlers/project/add/credentials/payment/input.ts b/src/handlers/project/add/credentials/payment/input.ts index 038e3b41d..1080fbd24 100644 --- a/src/handlers/project/add/credentials/payment/input.ts +++ b/src/handlers/project/add/credentials/payment/input.ts @@ -12,7 +12,7 @@ import { validateAuthorizationPrivateKey, validatePaymentIdentifier, validateWalletSecret, -} from "./validation"; +} from "../../../../identity/payment-credential-provider/validation"; export const paymentCredentialInputFlags = [ flag("api-key-id", "Coinbase CDP API key ID", z.string().optional()), From b8b326cad40f3cffba75f500d544b24e830193ea Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 20:07:21 +0000 Subject: [PATCH 06/10] docs(payment): document mutation workflows and command surface --- README.md | 61 +++++++++++++++------- src/handlers/payment/payment.read.test.tsx | 20 +++---- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2a810396c..fe347637a 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,11 @@ agentcore # interactive TUI │ │ ├── update # update an OAuth2 credential provider │ │ └── delete # delete an OAuth2 credential provider │ └── payment-credential-provider +│ ├── create # store Coinbase CDP or Stripe/Privy credentials for payment connectors │ ├── get # get a payment credential provider -│ └── list # list payment credential providers +│ ├── list # list payment credential providers +│ ├── update # replace a payment credential provider's credentials +│ └── delete # delete a payment credential provider ├── runtime # inspect deployed AgentCore Runtimes │ ├── get # fetch a Runtime by id │ ├── list # list Runtimes (server-side paginated) @@ -110,19 +113,29 @@ agentcore # interactive TUI │ │ └── list # list Rules under a Gateway │ └── policy │ └── generate # generate Cedar for a Gateway from a prompt (TUI when run bare) -├── payment # inspect AgentCore Payments (command line only for now) +├── payment # manage AgentCore Payments (command line only for now) │ ├── manager +│ │ ├── create # create a payment manager (auto-provisions a service role if none given) │ │ ├── get # get a payment manager by id -│ │ └── list # list payment managers (server-side paginated) +│ │ ├── list # list payment managers (server-side paginated) +│ │ ├── update # update a payment manager +│ │ └── delete # delete a payment manager (delete its connectors first) │ ├── connector # connectors under a payment manager +│ │ ├── create # create a connector from a credential provider, or --quick-create for Coinbase │ │ ├── get # get a connector (shows the Quick Create authorization URL while pending) -│ │ └── list # list a manager's connectors +│ │ ├── list # list a manager's connectors +│ │ ├── update # update a connector's description or credential provider +│ │ └── delete # delete a connector │ ├── session # budget-limited payment contexts (data plane) +│ │ ├── create # create a session with an expiry and optional spend limit │ │ ├── get -│ │ └── list +│ │ ├── list +│ │ └── delete │ └── instrument # embedded crypto wallets (data plane) +│ ├── create # create a wallet for a user on a connector │ ├── get │ ├── list +│ ├── delete │ └── balance # read token balance on an explicit chain (default token: USDC) ├── eval # evaluate and optimize AgentCore agents │ └── evaluator # manage AgentCore evaluators @@ -203,27 +216,39 @@ agentcore project invoke harness \ Use `--target` to select a deployment target. When a project declares exactly one resource of the requested type, `--name` may be omitted. -### Inspect AgentCore Payments +### Manage AgentCore Payments The `payment` commands call the Payments control and data planes directly, with -no project involved. This command family currently provides read-only inspection -of existing managers, connectors, sessions, instruments, and payment credential -providers. It does not create IAM roles or change provider credentials. +no project involved. A manager created without `--role-arn` gets a default +service role named `AgentCorePayments--` (long names have a stable +hash suffix). Default roles are tagged with their CLI owner, manager name, and +region; only matching roles are reused and have their service policy refreshed. +An unowned role with the same name is not modified. Use `--role-arn` to supply an +existing role, which the CLI never edits. Old regionless default roles are not +migrated automatically, and manager deletion does not delete IAM roles. + +Default role provisioning requires IAM role read/create, tagging, and inline +policy permissions, in addition to the service's role-passing requirements. +For centrally managed IAM policies or stricter per-credential permissions, +provision the service role separately and pass `--role-arn`. ```bash -# Inspect managers and their connectors. -agentcore payment manager list --json -agentcore payment manager get --id -agentcore payment connector list --manager-id +# Create a manager, then a Coinbase connector through Quick Create. The create +# returns PENDING_AUTHENTICATION and an authorizationUrl: open it within ten +# minutes, then confirm the connector reached READY. +agentcore payment manager create --name Checkout +agentcore payment connector create --manager-id --name Coinbase --quick-create agentcore payment connector get --manager-id --connector-id -# Inspect provider metadata stored in AgentCore Identity. -agentcore identity payment-credential-provider list --json -agentcore identity payment-credential-provider get --name +# Or bring your own provider credentials, stored in AgentCore Identity, and +# reference the provider by name (its vendor selects the connector type). +agentcore identity payment-credential-provider create --name cdp-creds --vendor CoinbaseCDP \ + --api-key-id --api-key-secret file://api-key-secret.txt --wallet-secret file://wallet-secret.txt +agentcore payment connector create --manager-id --name Coinbase --credential-provider cdp-creds # Session and instrument commands take the parent manager ID and a user id. -agentcore payment session list --manager-id --user-id alice -agentcore payment instrument list --manager-id --user-id alice +agentcore payment session create --manager-id --user-id alice \ + --expiry-minutes 60 --max-spend 10.00 --currency USD # Check funding on one chain. USDC is the default token. agentcore payment instrument balance --manager-id --user-id alice \ diff --git a/src/handlers/payment/payment.read.test.tsx b/src/handlers/payment/payment.read.test.tsx index 8ef8211b2..9157104b0 100644 --- a/src/handlers/payment/payment.read.test.tsx +++ b/src/handlers/payment/payment.read.test.tsx @@ -177,8 +177,8 @@ const reads = [ expected: object; }[]; -describe("payment read-only command tree", () => { - test("exposes exactly nine CLI-only leaves and no write commands", () => { +describe("payment command tree", () => { + test("retains reads alongside mutation commands as CLI-only leaves", () => { const { root } = createCommandTest(); const payment = compile(root, ValueContext.EmptyContext()).commands.find( (command) => command.name() === "payment", @@ -193,13 +193,13 @@ describe("payment read-only command tree", () => { ]), ), ).toEqual({ - manager: ["get", "list"], - connector: ["get", "list"], - session: ["get", "list"], - instrument: ["get", "list", "balance"], + manager: ["create", "get", "list", "update", "delete"], + connector: ["create", "get", "list", "update", "delete"], + session: ["create", "get", "list", "delete"], + instrument: ["create", "get", "list", "delete", "balance"], }); const leaves = payment!.commands.flatMap((resource) => resource.commands); - expect(leaves).toHaveLength(9); + expect(leaves).toHaveLength(19); for (const command of [...payment!.commands, ...leaves]) { expect(isTuiCommandSupported(command)).toBe(false); expect(command.options.map((option) => option.long)).not.toContain("--wait"); @@ -516,7 +516,7 @@ describe("payment connector read-only hints", () => { "AUTHENTICATION_FAILED", "PENDING_AUTHENTICATION", "READY", - ] as const)("preserves %s without suggesting absent write commands", async (status) => { + ] as const)("preserves %s with actionable terminal-state guidance", async (status) => { for (const jsonFlags of [[], ["--json"]]) { const response = { ...parse(JSON.stringify(connectorFixture)), @@ -546,7 +546,9 @@ describe("payment connector read-only hints", () => { if (terminal && jsonFlags.length === 0) { expect(io.stderr()).toContain(status); expect(io.stderr()).toContain("cannot be renewed"); - expect(io.stderr()).not.toMatch(/create|delete|update|--quick-create|browser|wait/i); + expect(io.stderr()).toContain( + "delete this connector and create it again with --quick-create", + ); } else { expect(io.stderr()).toBe(""); } From 7d179366bbf05b498e59bbb33a2e5eaa41c1c0cd Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 21:13:00 +0000 Subject: [PATCH 07/10] test(payment): consolidate mutation command coverage --- src/core/payment.test.ts | 770 +++------------ .../paymentCredentialProvider.test.tsx | 742 +++----------- .../payment/connector/connector.test.tsx | 913 ++++-------------- .../payment/instrument/instrument.test.tsx | 631 ++---------- src/handlers/payment/payment.test.tsx | 454 +++------ src/handlers/payment/session/session.test.tsx | 460 ++------- 6 files changed, 729 insertions(+), 3241 deletions(-) diff --git a/src/core/payment.test.ts b/src/core/payment.test.ts index e71be905e..cb7165849 100644 --- a/src/core/payment.test.ts +++ b/src/core/payment.test.ts @@ -1,664 +1,192 @@ -import { describe, expect, mock, test } from "bun:test"; +import { expect, mock, test } from "bun:test"; import { - CreatePaymentConnectorCommand, CreatePaymentManagerCommand, - DeletePaymentManagerCommand, GetPaymentConnectorCommand, - GetPaymentManagerCommand, UpdatePaymentConnectorCommand, type GetPaymentCredentialProviderResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { - CreatePaymentInstrumentCommand, - CreatePaymentSessionCommand, - DeletePaymentInstrumentCommand, - DeletePaymentSessionCommand, - GetPaymentInstrumentBalanceCommand, - GetPaymentInstrumentCommand, - GetPaymentSessionCommand, - ListPaymentInstrumentsCommand, - ListPaymentSessionsCommand, -} from "@aws-sdk/client-bedrock-agentcore"; -import { CreateRoleCommand, GetRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam"; -import { ERROR_SOURCE, InputValidationError } from "../errors"; -import type { CoreIdentityClient } from "../handlers/identity/types"; +import { GetRoleCommand } from "@aws-sdk/client-iam"; import { PaymentClient } from "./payment"; import type { AwsClients } from "./types"; const options = { region: "us-west-2" }; -const ACCOUNT = "123456789012"; -const DEFAULT_ROLE_ARN = `arn:aws:iam::${ACCOUNT}:role/AgentCorePayments-us-west-2-Checkout`; -const MANAGER_ID = "checkout-abc1234567"; -const MANAGER_ARN = `arn:aws:bedrock-agentcore:us-west-2:${ACCOUNT}:payment-manager/${MANAGER_ID}`; -const PROVIDER_ARN = `arn:aws:bedrock-agentcore:us-west-2:${ACCOUNT}:token-vault/default/paymentcredentialprovider/cdp-creds`; - -interface SdkCommand { - constructor: { name: string }; - input: unknown; +const ROLE_ARN = "arn:aws:iam::123456789012:role/AgentCorePayments-us-west-2-Checkout"; +const PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/paymentcredentialprovider/cdp-creds"; +const connector = { managerId: "manager", name: "Coinbase" }; +const provider = { + credentialProviderArn: PROVIDER_ARN, + credentialProviderVendor: "CoinbaseCDP", +} as GetPaymentCredentialProviderResponse; +type Send = (command: { input: unknown }) => Promise; + +function setup(controlSend: Send = async () => ({}), iamSend?: Send) { + const unexpected = () => { + throw new Error("Unexpected SDK call"); + }; + const send = mock(controlSend); + const identity = { getPaymentCredentialProvider: mock(async (_name: string) => provider) }; + const clients = { + control: () => ({ send }), + data: unexpected, + iam: iamSend ? () => ({ send: iamSend }) : unexpected, + } as unknown as AwsClients; + return { client: new PaymentClient(clients, identity), send, identity }; } -type Send = (command: SdkCommand) => Promise; -const unexpected: Send = async (command) => { - throw new Error(`unexpected ${command.constructor.name}`); -}; +test("an explicit manager role bypasses IAM provisioning", async () => { + const { client, send } = setup(); + const input = { name: "Checkout", authorizerType: "AWS_IAM" as const, roleArn: ROLE_ARN }; + await client.createPaymentManager(input, options); + expect(send.mock.calls[0]?.[0]).toBeInstanceOf(CreatePaymentManagerCommand); + expect(send.mock.calls[0]?.[0].input).toEqual(input); +}); -// paymentClient wires a PaymentClient over fake SDK clients whose `.send()` is -// the supplied function, plus a partial identity client for name resolution. -function paymentClient( - sends: { control?: Send; data?: Send; iam?: Send }, - identity: Partial = {}, -): PaymentClient { - const client = - (send: Send = unexpected) => - () => - ({ send: mock(send) }) as never; - return new PaymentClient( - { - control: client(sends.control), - data: client(sends.data), - iam: client(sends.iam), - } as unknown as AwsClients, - identity as CoreIdentityClient, +test("a caller's access denial is not mistaken for service-role propagation", async () => { + const error = Object.assign( + new Error( + "User: arn:aws:sts::123456789012:assumed-role/Admin/session is not authorized to perform: bedrock-agentcore:CreatePaymentManager", + ), + { name: "AccessDeniedException" }, ); -} - -function serviceError(name: string, message: string, extra: Record = {}): Error { - const error = new Error(message); - error.name = name; - Object.assign(error, extra); - return error; -} - -function coinbaseProvider(name: string): GetPaymentCredentialProviderResponse { - return { - name, - credentialProviderArn: PROVIDER_ARN, - credentialProviderVendor: "CoinbaseCDP", - } as GetPaymentCredentialProviderResponse; -} - -describe("PaymentClient manager", () => { - test("createPaymentManager passes an explicit role through without touching IAM", async () => { - const client = paymentClient({ - control: async (command) => { - expect(command).toBeInstanceOf(CreatePaymentManagerCommand); - expect(command.input).toEqual({ - name: "Checkout", - authorizerType: "AWS_IAM", - roleArn: "arn:aws:iam::123456789012:role/MyRole", - }); - return { paymentManagerId: MANAGER_ID }; - }, - }); - - await expect( - client.createPaymentManager( - { - name: "Checkout", - authorizerType: "AWS_IAM", - roleArn: "arn:aws:iam::123456789012:role/MyRole", - }, - options, - ), - ).resolves.toMatchObject({ paymentManagerId: MANAGER_ID }); - }); - - test("createPaymentManager provisions the default service role when none is given", async () => { - const iamCalls: string[] = []; - const client = paymentClient({ - iam: async (command) => { - iamCalls.push(command.constructor.name); - if (command instanceof GetRoleCommand) { - throw serviceError("NoSuchEntityException", "role does not exist"); - } - if (command instanceof CreateRoleCommand) { - expect(command.input).toMatchObject({ RoleName: "AgentCorePayments-us-west-2-Checkout" }); - return { Role: { Arn: DEFAULT_ROLE_ARN } }; - } - if (command instanceof PutRolePolicyCommand) return {}; - throw new Error(`unexpected ${command.constructor.name}`); - }, - control: async (command) => { - expect(command).toBeInstanceOf(CreatePaymentManagerCommand); - expect(command.input).toEqual({ - name: "Checkout", - authorizerType: "AWS_IAM", - roleArn: DEFAULT_ROLE_ARN, - }); - return { paymentManagerId: MANAGER_ID }; - }, - }); - - await client.createPaymentManager({ name: "Checkout", authorizerType: "AWS_IAM" }, options); - expect(iamCalls).toEqual(["GetRoleCommand", "CreateRoleCommand", "PutRolePolicyCommand"]); - }); - - // IAM's own denial message ("assumed-role/... is not authorized") mentions a role - // too, so the propagation retry must key on the provisioned role, not on the - // word. An under-privileged caller gets the real error on the first attempt. - test("createPaymentManager does not retry a caller's own access denial", async () => { - let creates = 0; - const client = paymentClient({ - iam: async (command) => { - if (command instanceof GetRoleCommand) { - return { + const { client, send } = setup( + async () => { + throw error; + }, + async (command) => + command instanceof GetRoleCommand + ? { Role: { - Arn: DEFAULT_ROLE_ARN, + Arn: ROLE_ARN, Tags: [ { Key: "agentcore:managed-by", Value: "agentcore-cli" }, { Key: "agentcore:payment-manager", Value: "Checkout" }, { Key: "agentcore:region", Value: options.region }, ], }, - }; - } - return {}; - }, - control: async () => { - creates++; - throw serviceError( - "AccessDeniedException", - `User: arn:aws:sts::${ACCOUNT}:assumed-role/Admin/session is not authorized to perform: bedrock-agentcore:CreatePaymentManager`, - ); - }, - }); - - await expect( - client.createPaymentManager({ name: "Checkout", authorizerType: "AWS_IAM" }, options), - ).rejects.toMatchObject({ name: "AccessDeniedException" }); - expect(creates).toBe(1); - }); - - test("default role provisioning retains explicit credentials but not the AgentCore endpoint", async () => { - const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; - const iam = mock(() => { - throw new Error("captured IAM configuration"); - }); - const control = mock(() => ({ send: unexpected })); - const client = new PaymentClient({ iam, control } as unknown as AwsClients, { - getPaymentCredentialProvider: async () => coinbaseProvider("unused"), - }); - await expect( - client.createPaymentManager( - { name: "Checkout", authorizerType: "AWS_IAM" }, - { region: options.region, endpointUrl: "https://example.test/control", credentials }, - ), - ).rejects.toThrow("captured IAM configuration"); - expect(iam).toHaveBeenCalledWith({ region: options.region, credentials }); - expect(control).toHaveBeenCalledWith({ - region: options.region, - endpoint: "https://example.test/control", - credentials, - }); - }); - - test("deletePaymentManager forwards the id and client token", async () => { - const client = paymentClient({ - control: async (command) => { - expect(command).toBeInstanceOf(DeletePaymentManagerCommand); - expect(command.input).toEqual({ paymentManagerId: MANAGER_ID, clientToken: "tok" }); - return { status: "DELETING", paymentManagerId: MANAGER_ID }; - }, - }); - - await expect( - client.deletePaymentManager({ paymentManagerId: MANAGER_ID, clientToken: "tok" }, options), - ).resolves.toEqual({ status: "DELETING", paymentManagerId: MANAGER_ID }); - }); + } + : {}, + ); + await expect( + client.createPaymentManager({ name: "Checkout", authorizerType: "AWS_IAM" }, options), + ).rejects.toBe(error); + expect(send).toHaveBeenCalledTimes(1); }); -describe("PaymentClient connector create", () => { - test("Quick Create sends an empty credential list and the QUICK_CREATE provision mode", async () => { - const client = paymentClient({ - control: async (command) => { - expect(command).toBeInstanceOf(CreatePaymentConnectorCommand); - expect(command.input).toEqual({ - paymentManagerId: MANAGER_ID, - name: "Coinbase", - type: "CoinbaseCDP", - credentialProviderConfigurations: [], - provisionMode: "QUICK_CREATE", - }); - return { paymentConnectorId: "coinbase-xyz", status: "PENDING_AUTHENTICATION" }; - }, - }); - - await expect( - client.createPaymentConnector( - { managerId: MANAGER_ID, name: "Coinbase", quickCreate: true }, - options, - ), - ).resolves.toMatchObject({ status: "PENDING_AUTHENTICATION" }); - }); - - test("Quick Create rejects any type other than CoinbaseCDP before calling the service", async () => { - const client = paymentClient({}); - await expect( - client.createPaymentConnector( - { managerId: MANAGER_ID, name: "Privy", quickCreate: true, type: "StripePrivy" }, - options, - ), - ).rejects.toBeInstanceOf(InputValidationError); +test("IAM receives explicit credentials but not the AgentCore endpoint override", async () => { + const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; + const iam = mock(() => { + throw new Error("captured IAM configuration"); + }); + const control = mock(() => ({ send: async () => ({}) })); + const client = new PaymentClient({ iam, control } as unknown as AwsClients, { + getPaymentCredentialProvider: async () => provider, + }); + await expect( + client.createPaymentManager( + { name: "Checkout", authorizerType: "AWS_IAM" }, + { ...options, endpointUrl: "https://payments.example.test", credentials }, + ), + ).rejects.toThrow("captured IAM configuration"); + expect(iam).toHaveBeenCalledWith({ ...options, credentials }); + expect(control).toHaveBeenCalledWith({ + ...options, + endpoint: "https://payments.example.test", + credentials, }); +}); - test("a credential provider named by name is resolved through identity and sets the type from its vendor", async () => { - const identity = { - getPaymentCredentialProvider: mock(async (name: string) => coinbaseProvider(name)), - }; - const client = paymentClient( - { - control: async (command) => { - expect(command).toBeInstanceOf(CreatePaymentConnectorCommand); - expect(command.input).toEqual({ - paymentManagerId: MANAGER_ID, - name: "Coinbase", - description: "manual", - type: "CoinbaseCDP", - credentialProviderConfigurations: [ - { coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }, - ], - provisionMode: undefined, - }); - return { paymentConnectorId: "coinbase-xyz", status: "CREATING" }; - }, - }, - identity, - ); - - await client.createPaymentConnector( +test("a named provider supplies its ARN and vendor; a conflicting vendor is rejected", async () => { + const { client, send, identity } = setup(); + await client.createPaymentConnector({ ...connector, credentialProvider: "cdp-creds" }, options); + expect(identity.getPaymentCredentialProvider).toHaveBeenCalledWith("cdp-creds", options); + expect(send.mock.calls[0]?.[0].input).toEqual({ + paymentManagerId: "manager", + name: "Coinbase", + type: "CoinbaseCDP", + credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }], + provisionMode: undefined, + }); + await expect( + client.createPaymentConnector( { - managerId: MANAGER_ID, - name: "Coinbase", - description: "manual", + ...connector, credentialProvider: "cdp-creds", - }, - options, - ); - expect(identity.getPaymentCredentialProvider).toHaveBeenCalledWith("cdp-creds", options); - }); - - test("a credential provider ARN requires an explicit type and selects the matching union member", async () => { - const client = paymentClient({ - control: async (command) => { - expect(command.input).toMatchObject({ - type: "StripePrivy", - credentialProviderConfigurations: [ - { stripePrivy: { credentialProviderArn: PROVIDER_ARN } }, - ], - }); - return { status: "CREATING" }; - }, - }); - - await expect( - client.createPaymentConnector( - { managerId: MANAGER_ID, name: "Privy", credentialProvider: PROVIDER_ARN }, - options, - ), - ).rejects.toThrow(/--type/); - - await client.createPaymentConnector( - { - managerId: MANAGER_ID, - name: "Privy", - credentialProvider: PROVIDER_ARN, type: "StripePrivy", }, options, - ); - }); - - test("an explicit type that contradicts the provider's vendor is rejected", async () => { - const client = paymentClient( - {}, - { getPaymentCredentialProvider: async (name: string) => coinbaseProvider(name) }, - ); - await expect( - client.createPaymentConnector( - { - managerId: MANAGER_ID, - name: "Mismatch", - credentialProvider: "cdp-creds", - type: "StripePrivy", - }, - options, - ), - ).rejects.toThrow(/CoinbaseCDP/); - }); - - test("neither Quick Create nor a credential provider is an input error", async () => { - const client = paymentClient({}); - await expect( - client.createPaymentConnector({ managerId: MANAGER_ID, name: "Nothing" }, options), - ).rejects.toBeInstanceOf(InputValidationError); - }); - - test("a Marketplace subscription failure surfaces the product and subscription URL", async () => { - const client = paymentClient({ - control: async () => { - throw serviceError("SubscriptionRequiredException", "Subscription required", { - subscriptionUrl: "https://aws.amazon.com/marketplace/pp/prodview-example", - productName: "Coinbase Wallets for AgentCore Payments", - }); - }, - }); - - const failure = client.createPaymentConnector( - { managerId: MANAGER_ID, name: "Coinbase", quickCreate: true }, - options, - ); - await expect(failure).rejects.toThrow(/Coinbase Wallets for AgentCore Payments/); - await expect(failure).rejects.toThrow(/prodview-example/); - await expect(failure).rejects.toMatchObject({ - name: "SubscriptionRequiredException", - source: ERROR_SOURCE.USER, - }); - }); + ), + ).rejects.toThrow("cannot back a StripePrivy connector"); + expect(send).toHaveBeenCalledTimes(1); }); -describe("PaymentClient connector update", () => { - test("replacing the credential provider reads the connector type to pick the union member", async () => { - const sent: string[] = []; - const client = paymentClient( +test("a provider ARN requires a vendor and skips name resolution", async () => { + const { client, send, identity } = setup(); + await expect( + client.createPaymentConnector( { - control: async (command) => { - sent.push(command.constructor.name); - if (command instanceof GetPaymentConnectorCommand) { - expect(command.input).toEqual({ - paymentManagerId: MANAGER_ID, - paymentConnectorId: "coinbase-xyz", - }); - return { type: "CoinbaseCDP" }; - } - expect(command).toBeInstanceOf(UpdatePaymentConnectorCommand); - expect(command.input).toEqual({ - paymentManagerId: MANAGER_ID, - paymentConnectorId: "coinbase-xyz", - description: "rotated", - credentialProviderConfigurations: [ - { coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }, - ], - clientToken: undefined, - }); - return { status: "UPDATING" }; - }, - }, - { getPaymentCredentialProvider: async (name: string) => coinbaseProvider(name) }, - ); - - await client.updatePaymentConnector( - { - managerId: MANAGER_ID, - connectorId: "coinbase-xyz", - description: "rotated", - credentialProvider: "cdp-creds", - }, - options, - ); - expect(sent).toEqual(["GetPaymentConnectorCommand", "UpdatePaymentConnectorCommand"]); - }); - - test("a description-only update sends no credential configuration and skips the lookup", async () => { - const client = paymentClient({ - control: async (command) => { - expect(command).toBeInstanceOf(UpdatePaymentConnectorCommand); - expect(command.input).toEqual({ - paymentManagerId: MANAGER_ID, - paymentConnectorId: "coinbase-xyz", - description: "renamed", - credentialProviderConfigurations: undefined, - clientToken: undefined, - }); - return { status: "UPDATING" }; + ...connector, + credentialProvider: PROVIDER_ARN, }, - }); - - await client.updatePaymentConnector( - { managerId: MANAGER_ID, connectorId: "coinbase-xyz", description: "renamed" }, options, - ); - }); -}); - -describe("PaymentClient data plane", () => { - test("rejects a manager ARN used as an ID before any SDK call", async () => { - const client = paymentClient({}); - await expect( - client.listPaymentSessions({ managerId: MANAGER_ARN, userId: "alice" }, options), - ).rejects.toThrow(/manager ID, not an ARN/); - }); - - test("createPaymentSession resolves the manager ID before sending the request", async () => { - const request = { - managerId: MANAGER_ID, - userId: "alice", - expiryTimeInMinutes: 60, - limits: { maxSpendAmount: { value: "10.00", currency: "USD" as const } }, - }; - const sent: string[] = []; - const client = paymentClient({ - control: async (command) => { - sent.push(command.constructor.name); - expect(command).toBeInstanceOf(GetPaymentManagerCommand); - expect(command.input).toEqual({ paymentManagerId: MANAGER_ID }); - return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; - }, - data: async (command) => { - sent.push(command.constructor.name); - expect(command).toBeInstanceOf(CreatePaymentSessionCommand); - const { managerId: _managerId, ...rest } = request; - expect(command.input).toEqual({ ...rest, paymentManagerArn: MANAGER_ARN }); - return { paymentSession: { paymentSessionId: "session-1" } }; - }, - }); - - await expect(client.createPaymentSession(request, options)).resolves.toMatchObject({ - paymentSession: { paymentSessionId: "session-1" }, - }); - expect(sent).toEqual(["GetPaymentManagerCommand", "CreatePaymentSessionCommand"]); - }); - - test("a CUSTOM_JWT manager is rejected before contacting the data plane", async () => { - let dataCalls = 0; - const client = paymentClient({ - data: async () => { - dataCalls++; - return {}; - }, - control: async (command) => { - expect(command).toBeInstanceOf(GetPaymentManagerCommand); - expect(command.input).toEqual({ paymentManagerId: MANAGER_ID }); - return { paymentManagerArn: MANAGER_ARN, authorizerType: "CUSTOM_JWT" }; - }, - }); - - const failure = client.listPaymentSessions({ managerId: MANAGER_ID, userId: "alice" }, options); - await expect(failure).rejects.toThrow(new RegExp(`${MANAGER_ID}.*CUSTOM_JWT`)); - await expect(failure).rejects.toThrow(/bearer token/); - await expect(failure).rejects.toMatchObject({ source: ERROR_SOURCE.USER }); - expect(dataCalls).toBe(0); - }); - - test.each(["ResourceNotFoundException", "AccessDeniedException"])( - "a manager lookup %s is preserved and prevents the data-plane call", - async (name) => { - const error = serviceError(name, "GetPaymentManager failed"); - let dataCalls = 0; - const client = paymentClient({ - control: async () => { - throw error; - }, - data: async () => { - dataCalls++; - return {}; - }, - }); - await expect( - client.listPaymentSessions({ managerId: MANAGER_ID, userId: "alice" }, options), - ).rejects.toBe(error); - expect(dataCalls).toBe(0); - }, - ); - - test.each(["AccessDeniedException", "ValidationException", "ThrottlingException"])( - "a data-plane %s is preserved without a second manager lookup", - async (name) => { - const error = serviceError(name, "data-plane failure"); - let lookups = 0; - const client = paymentClient({ - control: async () => { - lookups++; - return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; - }, - data: async () => { - throw error; - }, - }); - await expect( - client.listPaymentSessions({ managerId: MANAGER_ID, userId: "alice" }, options), - ).rejects.toBe(error); - expect(lookups).toBe(1); + ), + ).rejects.toThrow("--type is required"); + await client.createPaymentConnector( + { + ...connector, + credentialProvider: PROVIDER_ARN, + type: "StripePrivy", }, + options, ); - - test("a manager response without an ARN fails before data-plane access", async () => { - let dataCalls = 0; - const client = paymentClient({ - control: async () => ({ authorizerType: "AWS_IAM" }), - data: async () => { - dataCalls++; - return {}; - }, - }); - await expect(client.listPaymentSessions({ managerId: MANAGER_ID }, options)).rejects.toThrow( - /ARN/, - ); - expect(dataCalls).toBe(0); - }); - - test("list requests reach the data plane with pagination intact", async () => { - const client = paymentClient({ - control: async () => ({ paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }), - data: async (command) => { - expect(command).toBeInstanceOf(ListPaymentSessionsCommand); - expect(command.input).toEqual({ - paymentManagerArn: MANAGER_ARN, - userId: "alice", - nextToken: "page-2", - maxResults: 5, - }); - return { paymentSessions: [], nextToken: undefined }; - }, - }); - - await client.listPaymentSessions( - { managerId: MANAGER_ID, userId: "alice", nextToken: "page-2", maxResults: 5 }, - options, - ); + expect(send.mock.calls[0]?.[0].input).toMatchObject({ + type: "StripePrivy", + credentialProviderConfigurations: [{ stripePrivy: { credentialProviderArn: PROVIDER_ARN } }], }); + expect(identity.getPaymentCredentialProvider).not.toHaveBeenCalled(); +}); - const scoped = { managerId: MANAGER_ID, userId: "alice" }; - const session = { ...scoped, paymentSessionId: "session-1" }; - const instrument = { - ...scoped, - paymentConnectorId: "connector-1", - paymentInstrumentId: "instrument-1", - }; - const wallet = { - ...scoped, - paymentConnectorId: "connector-1", - paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET" as const, - paymentInstrumentDetails: { - embeddedCryptoWallet: { - network: "ETHEREUM" as const, - linkedAccounts: [{ email: { emailAddress: "alice@example.test" } }], - }, - }, - }; - const balance = { ...instrument, chain: "BASE_SEPOLIA" as const, token: "USDC" as const }; - const instrumentList = { - ...scoped, - paymentConnectorId: "connector-1", - nextToken: "page-2", - maxResults: 2, - }; - test.each([ - { - command: GetPaymentSessionCommand, - input: session, - run: (c: PaymentClient) => c.getPaymentSession(session, options), - }, - { - command: DeletePaymentSessionCommand, - input: session, - run: (c: PaymentClient) => c.deletePaymentSession(session, options), - }, - { - command: CreatePaymentInstrumentCommand, - input: wallet, - run: (c: PaymentClient) => c.createPaymentInstrument(wallet, options), - }, - { - command: GetPaymentInstrumentCommand, - input: instrument, - run: (c: PaymentClient) => c.getPaymentInstrument(instrument, options), - }, - { - command: DeletePaymentInstrumentCommand, - input: instrument, - run: (c: PaymentClient) => c.deletePaymentInstrument(instrument, options), - }, - { - command: GetPaymentInstrumentBalanceCommand, - input: balance, - run: (c: PaymentClient) => c.getPaymentInstrumentBalance(balance, options), - }, - { - command: ListPaymentInstrumentsCommand, - input: instrumentList, - run: (c: PaymentClient) => c.listPaymentInstruments(instrumentList, options), - }, - ])( - "$command.name resolves the manager once and preserves the request", - async ({ command, input, run }) => { - const calls: string[] = []; - const client = paymentClient({ - control: async (sent) => { - calls.push(sent.constructor.name); - expect(sent.input).toEqual({ paymentManagerId: MANAGER_ID }); - return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; - }, - data: async (sent) => { - calls.push(sent.constructor.name); - expect(sent).toBeInstanceOf(command); - const { managerId: _id, ...request } = input; - expect(sent.input).toEqual({ ...request, paymentManagerArn: MANAGER_ARN }); - expect(input).toHaveProperty("managerId", MANAGER_ID); - expect(input).not.toHaveProperty("paymentManagerArn"); - return {}; - }, - }); - await run(client); - expect(calls).toEqual(["GetPaymentManagerCommand", command.name]); - }, +test("connector updates resolve replacement credentials but preserve omitted credentials", async () => { + const { client, send, identity } = setup(async (command) => + command instanceof GetPaymentConnectorCommand ? { type: "CoinbaseCDP" } : {}, ); + const input = { managerId: "manager", connectorId: "connector", description: "updated" }; + await client.updatePaymentConnector({ ...input, credentialProvider: "cdp-creds" }, options); + expect(send.mock.calls[0]?.[0]).toMatchObject({ + input: { paymentManagerId: "manager", paymentConnectorId: "connector" }, + }); + expect(send.mock.calls[1]?.[0]).toBeInstanceOf(UpdatePaymentConnectorCommand); + expect(send.mock.calls[1]?.[0].input).toEqual({ + paymentManagerId: "manager", + paymentConnectorId: "connector", + description: "updated", + credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }], + clientToken: undefined, + }); + await client.updatePaymentConnector(input, options); + expect(send).toHaveBeenCalledTimes(3); + expect(send.mock.calls[2]?.[0]).toBeInstanceOf(UpdatePaymentConnectorCommand); + expect(send.mock.calls[2]?.[0].input).toEqual({ + paymentManagerId: "manager", + paymentConnectorId: "connector", + description: "updated", + credentialProviderConfigurations: undefined, + clientToken: undefined, + }); + expect(identity.getPaymentCredentialProvider).toHaveBeenCalledTimes(1); +}); - test("manager lookup and data call retain the same region, endpoint, and credentials", async () => { - const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; - const config = { region: "us-east-1", endpoint: "https://example.test/payments", credentials }; - const control = mock(() => ({ - send: async () => ({ paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }), - })); - const data = mock(() => ({ send: async () => ({}) })); - const client = new PaymentClient({ control, data } as unknown as AwsClients, { - getPaymentCredentialProvider: async () => coinbaseProvider("unused"), - }); - await client.getPaymentInstrumentBalance(balance, { - region: config.region, - endpointUrl: config.endpoint, - credentials, - }); - expect(control).toHaveBeenCalledWith(config); - expect(data).toHaveBeenCalledWith(config); +test("Marketplace errors retain the subscription URL and product name", async () => { + const error = Object.assign(new Error("Subscription required"), { + name: "SubscriptionRequiredException", + subscriptionUrl: "https://aws.amazon.com/marketplace/pp/prodview-example", + productName: "Coinbase Wallets", + }); + const { client } = setup(async () => { + throw error; }); + const result = client.createPaymentConnector({ ...connector, quickCreate: true }, options); + await expect(result).rejects.toThrow("Coinbase Wallets"); + await expect(result).rejects.toThrow(error.subscriptionUrl); + await expect(result).rejects.toMatchObject({ cause: error, name: error.name }); }); diff --git a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx index 74ed0db50..b03e24e35 100644 --- a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx +++ b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx @@ -14,18 +14,25 @@ import { testIO, } from "../../../testing"; import { createRootHandler } from "../../index"; +import type { Core } from "../../types"; const REGION = "us-west-2"; const FIXTURES = join(import.meta.dir, "__fixtures__"); const BASE = ["identity", "payment-credential-provider"]; -// Record with RECORD=1 AWS_PROFILE=deploy bun test src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx -// None of the fixture providers should exist before recording. The RECORD run creates -// two CoinbaseCDP providers, exercises pagination (requires >=2), updates one, attempts -// a StripePrivy provider, then deletes everything it created. const FIXTURE_PROVIDER_NAME = "agentcore-cli-payment-fixture"; -const FIXTURE_PROVIDER_NAME_2 = "agentcore-cli-payment-fixture-2"; const FIXTURE_STRIPE_PROVIDER_NAME = "agentcore-cli-payment-fixture-stripe"; +const COINBASE_FLAGS = ["--name", "cdp", "--vendor", "CoinbaseCDP", "--api-key-id", "cdp-key-1"]; +const STRIPE_FLAGS = [ + "--name", + "privy", + "--vendor", + "StripePrivy", + "--app-id", + "privy-app", + "--authorization-id", + "privy-auth", +]; const SECRET_REFERENCE = { secretId: "arn:aws:secretsmanager:us-west-2:123:secret:payment-fixture", jsonKey: "secret", @@ -83,8 +90,6 @@ function p256PrivateKey(fill: number): string { const API_KEY_SECRET = ed25519PrivateKey(0x11); const WALLET_SECRET = p256PrivateKey(0x22); -const API_KEY_SECRET_2 = ed25519PrivateKey(0x33); -const WALLET_SECRET_2 = p256PrivateKey(0x44); const UPDATED_API_KEY_SECRET = ed25519PrivateKey(0x55); const UPDATED_WALLET_SECRET = p256PrivateKey(0x66); const APP_SECRET = p256PrivateKey(0x77); @@ -100,7 +105,6 @@ function secretFile(name: string, content: string): string { } const WALLET_SECRET_FILE = secretFile("wallet-secret", WALLET_SECRET); -const WALLET_SECRET_FILE_2 = secretFile("wallet-secret-2", WALLET_SECRET_2); const UPDATED_WALLET_SECRET_FILE = secretFile("wallet-secret-updated", UPDATED_WALLET_SECRET); const AUTHORIZATION_PRIVATE_KEY_FILE = secretFile( "authorization-private-key", @@ -119,23 +123,11 @@ function createFixtureCore(): CoreClient { }); } -async function runRecorded(args: string[], stdin?: string): Promise { - const io = testIO({ stdin }); - const root = createRootHandler(createFixtureCore(), { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - - await root.route(["node", "agentcore", ...args, "--region", REGION]); - return io.stdout(); -} - async function run( args: string[], stdin?: string, - core = new TestCoreClient(), -): Promise<{ core: TestCoreClient; stdout: string }> { + core: Core = createFixtureCore(), +): Promise { const io = testIO({ stdin }); const root = createRootHandler(core, { io: io.io, @@ -143,87 +135,19 @@ async function run( globalConfigAccessor: new TestGlobalConfigAccessor(), }); - await root.route(["node", "agentcore", ...args, "--region", REGION]); - return { core, stdout: io.stdout() }; + await root.route(["node", "agentcore", ...BASE, ...args, "--region", REGION]); + return io.stdout(); } -describe("payment-credential-provider command hierarchy", () => { - test("registers the payment-credential-provider command hierarchy", () => { - const root = createRootHandler(new TestCoreClient(), { - io: testIO().io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - const identity = root.children().find((child) => child.name() === "identity"); - const payment = identity - ?.children() - .find((child) => child.name() === "payment-credential-provider"); - - expect(payment?.children().map((child) => child.name())).toEqual([ - "create", - "get", - "list", - "update", - "delete", - ]); - }); - - test("prints help for `identity payment-credential-provider --json` without an SDK call", async () => { - const { core, stdout } = await run([...BASE, "--json"]); - - expect(stdout).toContain("Usage: agentcore identity payment-credential-provider"); - expect(stdout).toContain("Commands:"); - expect(core.identity.calls).toEqual([]); - }); -}); - -describe("payment-credential-provider TUI dispatch", () => { - test("opens the TUI for a bare `payment-credential-provider`", async () => { - await expect(run([...BASE])).rejects.toThrow( - "interactive mode requires a TTY on stdin and stdout", - ); - }); - - test.each(["create", "get", "update", "delete"] as const)( - "runs normal validation for bare CLI-only `%s`", - async (command) => { - await expect(run([...BASE, command])).rejects.toThrow( - "required option '--name ' not specified", - ); - }, - ); - - test("runs a bare `list` headlessly", async () => { - const { core } = await run([...BASE, "list"]); - - expect(core.identity.calls).toEqual([ - { - method: "listPaymentCredentialProviders", - args: [undefined, undefined, { region: REGION }], - }, - ]); - }); -}); - describe("payment-credential-provider flag validation", () => { test.each([ - ["create", "CoinbaseCDP", ["--api-key-id", "k"], ["api-key-secret", "wallet-secret"]], - ["update", "CoinbaseCDP", ["--api-key-id", "k"], ["api-key-secret", "wallet-secret"]], - [ - "create", - "StripePrivy", - ["--app-id", "a", "--authorization-id", "b"], - ["app-secret", "authorization-private-key"], - ], - [ - "update", - "StripePrivy", - ["--app-id", "a", "--authorization-id", "b"], - ["app-secret", "authorization-private-key"], - ], + ["create", "CoinbaseCDP", COINBASE_FLAGS, ["api-key-secret", "wallet-secret"]], + ["update", "CoinbaseCDP", COINBASE_FLAGS, ["api-key-secret", "wallet-secret"]], + ["create", "StripePrivy", STRIPE_FLAGS, ["app-secret", "authorization-private-key"]], + ["update", "StripePrivy", STRIPE_FLAGS, ["app-secret", "authorization-private-key"]], ] as const)( "`%s` rejects competing %s stdin secrets before Core or IO", - async (command, vendor, identifiers, secretFlags) => { + async (command, _vendor, flags, secretFlags) => { const factories = fixtureFactories(FIXTURES); const sdk = mock(() => { throw new Error("unexpected SDK client creation"); @@ -257,11 +181,7 @@ describe("payment-credential-provider flag validation", () => { "agentcore", ...BASE, command, - "--name", - "x", - "--vendor", - vendor, - ...identifiers, + ...flags, ...secretFlags.flatMap((flagName) => [`--${flagName}`, "-"]), "--region", REGION, @@ -281,66 +201,44 @@ describe("payment-credential-provider flag validation", () => { }, ); + test.each(["create", "update", "delete"])("requires a name for %s", async (command) => { + await expect(run([command])).rejects.toThrow("required option '--name ' not specified"); + }); + test.each([ - ["create --name only", [...BASE, "create", "--name", "x"], /--vendor /], + ["create without a vendor", ["create", "--name", "x"], /--vendor /], + ["update without a vendor", ["update", "--name", "x"], /--vendor /], [ "create with an unknown vendor", - [...BASE, "create", "--name", "x", "--vendor", "Square"], + ["create", "--name", "x", "--vendor", "Square"], "--vendor must be one of CoinbaseCDP, StripePrivy", ], - ["get --json (no name)", [...BASE, "get", "--json"], /--name /], - ["delete --json (no name)", [...BASE, "delete", "--json"], /--name /], - ["update --name only", [...BASE, "update", "--name", "x"], /--vendor /], - [ - "update rejects --tags", - [...BASE, "update", "--name", "x", "--vendor", "CoinbaseCDP", "--tags", "a=b"], - /--tags/, - ], + ["update with tags", ["update", ...COINBASE_FLAGS, "--tags", "a=b"], /--tags/], [ "CoinbaseCDP without --api-key-id", - [...BASE, "create", "--name", "x", "--vendor", "CoinbaseCDP", "--api-key-secret", "-"], + ["create", "--name", "x", "--vendor", "CoinbaseCDP"], "required option '--api-key-id ' not specified", ], [ "StripePrivy without --app-id", - [...BASE, "create", "--name", "x", "--vendor", "StripePrivy", "--authorization-id", "a"], + ["create", "--name", "x", "--vendor", "StripePrivy", "--authorization-id", "a"], "required option '--app-id ' not specified", ], [ "StripePrivy without --authorization-id", - [...BASE, "create", "--name", "x", "--vendor", "StripePrivy", "--app-id", "a"], + ["create", "--name", "x", "--vendor", "StripePrivy", "--app-id", "a"], "required option '--authorization-id ' not specified", ], - ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { - await expect(run([...args])).rejects.toThrow(message); - }); - - test.each([ [ "--app-id with CoinbaseCDP", - [ - ...BASE, - "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "k", - "--app-id", - "a", - ], + ["create", ...COINBASE_FLAGS, "--app-id", "a"], "--app-id is not valid with --vendor CoinbaseCDP", ], [ "Coinbase flags with StripePrivy", [ - ...BASE, "update", - "--name", - "x", - "--vendor", - "StripePrivy", + ...STRIPE_FLAGS, "--api-key-id", "k", "--wallet-secret-reference", @@ -348,43 +246,21 @@ describe("payment-credential-provider flag validation", () => { ], "--api-key-id, --wallet-secret-reference are not valid with --vendor StripePrivy", ], - ] as const)("rejects flags of the other vendor for `%s`", async (_label, args, message) => { - await expect(run([...args])).rejects.toThrow(message); - }); - - test.each([ [ "CoinbaseCDP without an api key secret", - [...BASE, "create", "--name", "x", "--vendor", "CoinbaseCDP", "--api-key-id", "k"], + ["create", ...COINBASE_FLAGS], "either --api-key-secret or --api-key-secret-reference is required", ], [ "CoinbaseCDP without a wallet secret", - [ - ...BASE, - "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "k", - "--api-key-secret", - "-", - ], + ["create", ...COINBASE_FLAGS, "--api-key-secret", "-"], "either --wallet-secret or --wallet-secret-reference is required", ], [ "CoinbaseCDP with both api key secret forms", [ - ...BASE, "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "k", + ...COINBASE_FLAGS, "--api-key-secret", "-", "--api-key-secret-reference", @@ -396,35 +272,14 @@ describe("payment-credential-provider flag validation", () => { ], [ "StripePrivy without an app secret", - [ - ...BASE, - "create", - "--name", - "x", - "--vendor", - "StripePrivy", - "--app-id", - "a", - "--authorization-id", - "b", - "--authorization-private-key-reference", - SECRET_REFERENCE_JSON, - ], + ["create", ...STRIPE_FLAGS, "--authorization-private-key-reference", SECRET_REFERENCE_JSON], "either --app-secret or --app-secret-reference is required", ], [ "StripePrivy with both authorization private key forms", [ - ...BASE, "update", - "--name", - "x", - "--vendor", - "StripePrivy", - "--app-id", - "a", - "--authorization-id", - "b", + ...STRIPE_FLAGS, "--app-secret-reference", SECRET_REFERENCE_JSON, "--authorization-private-key", @@ -437,14 +292,8 @@ describe("payment-credential-provider flag validation", () => { [ "inline api key secret value", [ - ...BASE, "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "k", + ...COINBASE_FLAGS, "--api-key-secret", API_KEY_SECRET, "--wallet-secret-reference", @@ -452,61 +301,27 @@ describe("payment-credential-provider flag validation", () => { ], /--api-key-secret must come from stdin \('-'\) or a file \('file:\/\/'\)/, ], - [ - "malformed secret reference", - [ - ...BASE, - "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "k", - "--api-key-secret-reference", - '{"jsonKey":"k"}', - "--wallet-secret-reference", - SECRET_REFERENCE_JSON, - ], - /--api-key-secret-reference must be a JSON object/, - ], - [ - "invalid api key id", - [ - ...BASE, - "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "bad key!", - "--api-key-secret-reference", - SECRET_REFERENCE_JSON, - "--wallet-secret-reference", - SECRET_REFERENCE_JSON, - ], - "--api-key-id must contain only alphanumeric characters, hyphens, and underscores", - ], - ] as const)("rejects invalid secret input for `%s`", async (_label, args, message) => { - const core = new TestCoreClient(); + ] as const)("rejects %s before Core", async (_label, args, message) => { + const core = createFixtureCore(); + const create = spyOn(core.identity, "createPaymentCredentialProvider"); + const update = spyOn(core.identity, "updatePaymentCredentialProvider"); - await expect(run([...args], undefined, core)).rejects.toThrow(message); - expect(core.identity.calls).toEqual([]); + try { + await expect(run([...args], undefined, core)).rejects.toThrow(message); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + } finally { + create.mockRestore(); + update.mockRestore(); + } }); test.each([ [ "api key secret that is not an Ed25519 key", [ - ...BASE, "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "k", + ...COINBASE_FLAGS, "--api-key-secret", "-", "--wallet-secret-reference", @@ -515,59 +330,11 @@ describe("payment-credential-provider flag validation", () => { "not-base64!", /Ed25519/, ], - [ - "wallet secret that is not a P-256 key", - [ - ...BASE, - "create", - "--name", - "x", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "k", - "--api-key-secret-reference", - SECRET_REFERENCE_JSON, - "--wallet-secret", - "-", - ], - API_KEY_SECRET, - /P-256/, - ], - [ - "authorization private key that is not base64", - [ - ...BASE, - "create", - "--name", - "x", - "--vendor", - "StripePrivy", - "--app-id", - "a", - "--authorization-id", - "b", - "--app-secret-reference", - SECRET_REFERENCE_JSON, - "--authorization-private-key", - "-", - ], - "wallet-auth:not-base64!", - /authorizationPrivateKey must be base64-encoded/, - ], [ "empty app secret", [ - ...BASE, "create", - "--name", - "x", - "--vendor", - "StripePrivy", - "--app-id", - "a", - "--authorization-id", - "b", + ...STRIPE_FLAGS, "--app-secret", "-", "--authorization-private-key-reference", @@ -577,77 +344,35 @@ describe("payment-credential-provider flag validation", () => { "--app-secret must not be empty", ], ] as const)("rejects a malformed %s", async (_label, args, stdin, message) => { - const core = new TestCoreClient(); + const core = createFixtureCore(); + const create = spyOn(core.identity, "createPaymentCredentialProvider"); - await expect(run([...args], stdin, core)).rejects.toThrow(message); - expect(core.identity.calls).toEqual([]); + try { + await expect(run([...args], stdin, core)).rejects.toThrow(message); + expect(create).not.toHaveBeenCalled(); + } finally { + create.mockRestore(); + } }); }); +// External-reference fixtures are unavailable; verify the consumer-owned Core contract here. describe("payment-credential-provider request mapping", () => { - test("creates a CoinbaseCDP provider with managed secrets and tags", async () => { - const { core } = await run( + test("creates a CoinbaseCDP provider with external secret references", async () => { + const core = new TestCoreClient(); + await run( [ - ...BASE, "create", - "--name", - "cdp", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - " cdp-key-1 ", - "--api-key-secret", - "-", - "--wallet-secret", - WALLET_SECRET_FILE, - "--tags", - "team=payments", - "--tags", - "env=test", + ...COINBASE_FLAGS, + "--api-key-secret-reference", + SECRET_REFERENCE_JSON, + "--wallet-secret-reference", + JSON.stringify({ ...SECRET_REFERENCE, jsonKey: "wallet" }), ], - API_KEY_SECRET, + undefined, + core, ); - expect(core.identity.calls).toEqual([ - { - method: "createPaymentCredentialProvider", - args: [ - { - name: "cdp", - credentialProviderVendor: "CoinbaseCDP", - providerConfigurationInput: { - coinbaseCdpConfiguration: { - apiKeyId: "cdp-key-1", - apiKeySecret: API_KEY_SECRET, - apiKeySecretSource: "MANAGED", - walletSecret: WALLET_SECRET, - walletSecretSource: "MANAGED", - }, - }, - tags: { team: "payments", env: "test" }, - }, - { region: REGION }, - ], - }, - ]); - }); - - test("creates a CoinbaseCDP provider with external secret references", async () => { - const { core } = await run([ - ...BASE, - "create", - "--name", - "cdp", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "cdp-key-1", - "--api-key-secret-reference", - SECRET_REFERENCE_JSON, - "--wallet-secret-reference", - JSON.stringify({ ...SECRET_REFERENCE, jsonKey: "wallet" }), - ]); - expect(core.identity.calls).toEqual([ { method: "createPaymentCredentialProvider", @@ -672,148 +397,34 @@ describe("payment-credential-provider request mapping", () => { }); test("mixes a managed api key secret with an external wallet secret", async () => { - const { core } = await run( + const core = new TestCoreClient(); + await run( [ - ...BASE, "create", - "--name", - "cdp", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "cdp-key-1", + ...COINBASE_FLAGS, "--api-key-secret", "-", "--wallet-secret-reference", SECRET_REFERENCE_JSON, ], API_KEY_SECRET, - ); - - expect(core.identity.calls[0]?.args[0]).toMatchObject({ - providerConfigurationInput: { - coinbaseCdpConfiguration: { - apiKeySecret: API_KEY_SECRET, - apiKeySecretSource: "MANAGED", - walletSecretSource: "EXTERNAL", - walletSecretConfig: SECRET_REFERENCE, - }, - }, - }); - }); - - test("creates a StripePrivy provider with managed secrets and strips the wallet-auth prefix", async () => { - const { core } = await run( - [ - ...BASE, - "create", - "--name", - "privy", - "--vendor", - "StripePrivy", - "--app-id", - "privy-app", - "--app-secret", - "-", - "--authorization-id", - "privy-auth", - "--authorization-private-key", - AUTHORIZATION_PRIVATE_KEY_FILE, - ], - APP_SECRET, + core, ); expect(core.identity.calls).toEqual([ { method: "createPaymentCredentialProvider", - args: [ - { - name: "privy", - credentialProviderVendor: "StripePrivy", - providerConfigurationInput: { - stripePrivyConfiguration: { - appId: "privy-app", - appSecret: APP_SECRET, - appSecretSource: "MANAGED", - authorizationPrivateKey: AUTHORIZATION_PRIVATE_KEY, - authorizationPrivateKeySource: "MANAGED", - authorizationId: "privy-auth", - }, - }, - }, - { region: REGION }, - ], - }, - ]); - }); - - test("creates a StripePrivy provider with external secret references", async () => { - const { core } = await run([ - ...BASE, - "create", - "--name", - "privy", - "--vendor", - "StripePrivy", - "--app-id", - "privy-app", - "--app-secret-reference", - SECRET_REFERENCE_JSON, - "--authorization-id", - "privy-auth", - "--authorization-private-key-reference", - JSON.stringify({ ...SECRET_REFERENCE, jsonKey: "authorization" }), - ]); - - expect(core.identity.calls[0]?.args[0]).toEqual({ - name: "privy", - credentialProviderVendor: "StripePrivy", - providerConfigurationInput: { - stripePrivyConfiguration: { - appId: "privy-app", - appSecretSource: "EXTERNAL", - appSecretConfig: SECRET_REFERENCE, - authorizationPrivateKeySource: "EXTERNAL", - authorizationPrivateKeyConfig: { ...SECRET_REFERENCE, jsonKey: "authorization" }, - authorizationId: "privy-auth", - }, - }, - }); - }); - - test("updates a CoinbaseCDP provider with a full replacement configuration", async () => { - const { core } = await run( - [ - ...BASE, - "update", - "--name", - "cdp", - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "cdp-key-2", - "--api-key-secret", - "-", - "--wallet-secret", - UPDATED_WALLET_SECRET_FILE, - ], - UPDATED_API_KEY_SECRET, - ); - - expect(core.identity.calls).toEqual([ - { - method: "updatePaymentCredentialProvider", args: [ { name: "cdp", credentialProviderVendor: "CoinbaseCDP", providerConfigurationInput: { coinbaseCdpConfiguration: { - apiKeyId: "cdp-key-2", - apiKeySecret: UPDATED_API_KEY_SECRET, + apiKeyId: "cdp-key-1", + apiKeySecret: API_KEY_SECRET, apiKeySecretSource: "MANAGED", - walletSecret: UPDATED_WALLET_SECRET, - walletSecretSource: "MANAGED", + walletSecretSource: "EXTERNAL", + walletSecretConfig: SECRET_REFERENCE, }, }, }, @@ -821,30 +432,29 @@ describe("payment-credential-provider request mapping", () => { ], }, ]); - expect(core.identity.calls[0]?.args[0]).not.toHaveProperty("tags"); }); - test("updates a StripePrivy provider with external secret references", async () => { - const { core } = await run([ - ...BASE, - "update", - "--name", - "privy", - "--vendor", - "StripePrivy", - "--app-id", - "privy-app", - "--app-secret-reference", - SECRET_REFERENCE_JSON, - "--authorization-id", - "privy-auth", - "--authorization-private-key-reference", - SECRET_REFERENCE_JSON, - ]); + test.each([ + ["create", "createPaymentCredentialProvider"], + ["update", "updatePaymentCredentialProvider"], + ] as const)("%s uses StripePrivy external references", async (command, method) => { + const core = new TestCoreClient(); + await run( + [ + command, + ...STRIPE_FLAGS, + "--app-secret-reference", + SECRET_REFERENCE_JSON, + "--authorization-private-key-reference", + JSON.stringify({ ...SECRET_REFERENCE, jsonKey: "authorization" }), + ], + undefined, + core, + ); expect(core.identity.calls).toEqual([ { - method: "updatePaymentCredentialProvider", + method, args: [ { name: "privy", @@ -855,7 +465,7 @@ describe("payment-credential-provider request mapping", () => { appSecretSource: "EXTERNAL", appSecretConfig: SECRET_REFERENCE, authorizationPrivateKeySource: "EXTERNAL", - authorizationPrivateKeyConfig: SECRET_REFERENCE, + authorizationPrivateKeyConfig: { ...SECRET_REFERENCE, jsonKey: "authorization" }, authorizationId: "privy-auth", }, }, @@ -865,40 +475,21 @@ describe("payment-credential-provider request mapping", () => { }, ]); }); - - test("passes --name through to get and delete", async () => { - const get = await run([...BASE, "get", "--name", "cdp"]); - const del = await run([...BASE, "delete", "--name", "cdp"]); - - expect(get.core.identity.calls).toEqual([ - { method: "getPaymentCredentialProvider", args: ["cdp", { region: REGION }] }, - ]); - expect(del.core.identity.calls).toEqual([ - { method: "deletePaymentCredentialProvider", args: ["cdp", { region: REGION }] }, - ]); - }); - - test("passes pagination flags through to list", async () => { - const { core } = await run([...BASE, "list", "--next-token", "token-1", "--max-results", "5"]); - - expect(core.identity.calls).toEqual([ - { method: "listPaymentCredentialProviders", args: ["token-1", 5, { region: REGION }] }, - ]); - }); }); -describe("payment-credential-provider CRUDL", () => { - test("creates a CoinbaseCDP payment credential provider", async () => { - const stdout = await runRecorded( +// Each command uses the real root and Core; fixture hashes verify the full SDK request, +// including resolved managed secrets. Providers must not exist before a fresh recording. +describe("payment-credential-provider write flow", () => { + test("creates a CoinbaseCDP provider with managed stdin/file secrets and tags", async () => { + const stdout = await run( [ - ...BASE, "create", "--name", FIXTURE_PROVIDER_NAME, "--vendor", "CoinbaseCDP", "--api-key-id", - "agentcore-cli-fixture-key", + " agentcore-cli-fixture-key ", "--api-key-secret", "-", "--wallet-secret", @@ -916,74 +507,9 @@ describe("payment-credential-provider CRUDL", () => { }); }); - test("creates a second CoinbaseCDP provider for pagination", async () => { - const stdout = await runRecorded( - [ - ...BASE, - "create", - "--name", - FIXTURE_PROVIDER_NAME_2, - "--vendor", - "CoinbaseCDP", - "--api-key-id", - "agentcore-cli-fixture-key-2", - "--api-key-secret", - "-", - "--wallet-secret", - WALLET_SECRET_FILE_2, - ], - API_KEY_SECRET_2, - ); - - matchGolden(FIXTURES, "create-2.golden.json", stdout); - expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME_2); - }); - - test("gets a payment credential provider", async () => { - const stdout = await runRecorded([...BASE, "get", "--name", FIXTURE_PROVIDER_NAME]); - - matchGolden(FIXTURES, "get.golden.json", stdout); - expect(JSON.parse(stdout)).toMatchObject({ - name: FIXTURE_PROVIDER_NAME, - credentialProviderVendor: "CoinbaseCDP", - }); - }); - - test("lists payment credential providers", async () => { - const stdout = await runRecorded([...BASE, "list", "--json"]); - - matchGolden(FIXTURES, "list.golden.json", stdout); - const names = JSON.parse(stdout).credentialProviders.map( - (provider: { name: string }) => provider.name, - ); - expect(names).toContain(FIXTURE_PROVIDER_NAME); - expect(names).toContain(FIXTURE_PROVIDER_NAME_2); - }); - - test("paginates the list with --max-results and --next-token", async () => { - const firstPage = await runRecorded([...BASE, "list", "--max-results", "1"]); - matchGolden(FIXTURES, "list-page-1.golden.json", firstPage); - - const first = JSON.parse(firstPage); - expect(first.credentialProviders).toHaveLength(1); - expect(first.nextToken).toBeString(); - - const secondPage = await runRecorded([ - ...BASE, - "list", - "--max-results", - "1", - "--next-token", - first.nextToken, - ]); - matchGolden(FIXTURES, "list-page-2.golden.json", secondPage); - expect(JSON.parse(secondPage).credentialProviders).toHaveLength(1); - }); - test("updates a payment credential provider with fresh keys", async () => { - const stdout = await runRecorded( + const stdout = await run( [ - ...BASE, "update", "--name", FIXTURE_PROVIDER_NAME, @@ -1003,10 +529,9 @@ describe("payment-credential-provider CRUDL", () => { expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); }); - test("creates a StripePrivy payment credential provider", async () => { - const stdout = await runRecorded( + test("creates a StripePrivy provider with managed secrets and strips wallet-auth", async () => { + const stdout = await run( [ - ...BASE, "create", "--name", FIXTURE_STRIPE_PROVIDER_NAME, @@ -1031,39 +556,18 @@ describe("payment-credential-provider CRUDL", () => { }); }); - test("deletes the StripePrivy payment credential provider", async () => { - const stdout = await runRecorded([...BASE, "delete", "--name", FIXTURE_STRIPE_PROVIDER_NAME]); - - matchGolden(FIXTURES, "delete-stripe.golden.json", stdout); - }); - - test("deletes the first payment credential provider", async () => { - const stdout = await runRecorded([...BASE, "delete", "--name", FIXTURE_PROVIDER_NAME]); - - matchGolden(FIXTURES, "delete.golden.json", stdout); - }); - - test("deletes the second payment credential provider", async () => { - const stdout = await runRecorded([...BASE, "delete", "--name", FIXTURE_PROVIDER_NAME_2]); - - matchGolden(FIXTURES, "delete-2.golden.json", stdout); - }); - - test("propagates ResourceNotFoundException from get once the provider is deleted", async () => { - await expect( - runRecorded([...BASE, "get", "--name", FIXTURE_PROVIDER_NAME_2]), - ).rejects.toMatchObject({ name: "ResourceNotFoundException" }); - }); - - test("no longer lists the deleted providers", async () => { - const stdout = await runRecorded([...BASE, "list", "--max-results", "20"]); - - matchGolden(FIXTURES, "list-after-delete.golden.json", stdout); - const names = JSON.parse(stdout).credentialProviders.map( - (provider: { name: string }) => provider.name, - ); - expect(names).not.toContain(FIXTURE_PROVIDER_NAME); - expect(names).not.toContain(FIXTURE_PROVIDER_NAME_2); - expect(names).not.toContain(FIXTURE_STRIPE_PROVIDER_NAME); + test.each([ + [FIXTURE_STRIPE_PROVIDER_NAME, "delete-stripe.golden.json"], + [FIXTURE_PROVIDER_NAME, "delete.golden.json"], + ])("deletes %s", async (name, golden) => { + const core = createFixtureCore(); + const call = spyOn(core.identity, "deletePaymentCredentialProvider"); + + try { + matchGolden(FIXTURES, golden, await run(["delete", "--name", name], undefined, core)); + expect(call.mock.calls).toEqual([[name, { region: REGION }]]); + } finally { + call.mockRestore(); + } }); }); diff --git a/src/handlers/payment/connector/connector.test.tsx b/src/handlers/payment/connector/connector.test.tsx index 45751edc3..deab2cc52 100644 --- a/src/handlers/payment/connector/connector.test.tsx +++ b/src/handlers/payment/connector/connector.test.tsx @@ -1,604 +1,209 @@ import { describe, expect, mock, spyOn, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; -import { - CreatePaymentConnectorCommand, - GetPaymentConnectorCommand, - GetPaymentCredentialProviderCommand, - UpdatePaymentConnectorCommand, - type BedrockAgentCoreControlClient, - type CreatePaymentConnectorResponse, - type GetPaymentConnectorResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; +import { GetPaymentConnectorCommand } from "@aws-sdk/client-bedrock-agentcore-control"; import { CoreClient } from "../../../core"; import type { ClientConfig } from "../../../core/types"; import { createRootHandler } from "../../index"; import { createSilentLogger, fixtureFactories, - isRecording, matchGolden, parse, TestGlobalConfigAccessor, testIO, } from "../../../testing"; import quickCreateFixture from "../__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json"; - -// End-to-end command-flow tests for the `payment connector` leaves. -// -// Each test builds the real root handler over a real CoreClient whose SDK -// clients are the fixture-backed fakes, then drives it through `route()` exactly -// as the CLI does, so one test covers parsing, middleware, the leaf handler, -// PaymentClient (including its credential-provider lookup in identity), and the -// rendered output. -// -// Record with: -// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/connector/connector.test.tsx -// The flows use a payment manager and a CoinbaseCDP payment credential provider -// that already exist in the test account (the account's manager quota is -// exhausted, so none is created here). The manual flow creates a connector named -// AgentCoreCliConnectorE2E from the named provider, updates it, and deletes it; -// the Quick Create flow creates AgentCoreCliQuickE2E and deletes it without -// completing the OAuth consent. +import connectorGetFixture from "../__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json"; const FIXTURES = join(import.meta.dir, "..", "__fixtures__", "connector"); -// A fixture is keyed by operation and request, and every `get` of one connector -// sends the same request, so the readiness polls and the post-delete not-found -// reads would overwrite each other. The post-delete reads record to a sibling -// directory so both settled states replay. -const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); const REGION = "us-west-2"; const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; const CREDENTIAL_PROVIDER = "MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp"; const MANUAL_NAME = "AgentCoreCliConnectorE2E"; const QUICK_NAME = "AgentCoreCliQuickE2E"; -// Generous timeouts: in record mode, readiness polls wait on real control-plane -// transitions. Replay never sleeps. -const FLOW_TIMEOUT = 600_000; +const scoped = ["--manager-id", MANAGER_ID]; +const quickArgs = ["create", ...scoped, "--name", QUICK_NAME, "--quick-create"]; -function createFixtureCore(dir = FIXTURES): CoreClient { - const { createControlClient, createDataClient, createIamClient, createLogsClient } = - fixtureFactories(dir); - return new CoreClient({ - createControlClient, - createDataClient, - createIamClient, - createLogsClient, - logger: createSilentLogger(), - }); +function createFixtureCore(fixtures = FIXTURES): CoreClient { + return new CoreClient({ ...fixtureFactories(fixtures), logger: createSilentLogger() }); } -// createFakedControlCore swaps the control plane's `.send()` for `send` while -// keeping the real CoreClient and PaymentClient in the loop. Used for connector -// states a recording cannot reach on demand (an expired consent window). -function createFakedControlCore(send: (command: unknown) => Promise): CoreClient { - const { createDataClient, createIamClient, createLogsClient } = fixtureFactories(FIXTURES); - return new CoreClient({ - createControlClient: () => ({ send }) as unknown as BedrockAgentCoreControlClient, - createDataClient, - createIamClient, - createLogsClient, - logger: createSilentLogger(), - }); -} - -function createRoot(core = createFixtureCore()) { +async function run( + args: string[], + { core = createFixtureCore(), regionArgs = ["--region", REGION] } = {}, +) { const io = testIO(); const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - return { root, io }; -} - -async function runCapturing( - args: string[], - core?: CoreClient, -): Promise<{ stdout: string; stderr: string }> { - const { root, io } = createRoot(core); - await root.route(["node", "agentcore", ...args, "--region", REGION]); - return { stdout: io.stdout(), stderr: io.stderr() }; -} - -async function run(args: string[], core?: CoreClient): Promise { - return (await runCapturing(args, core)).stdout; -} - -// pollUntil re-runs `command` until `done(parsed output)` is true. Polling only -// sleeps in record mode; in replay the fixture already holds the settled state -// (the last recorded poll), so the first read satisfies `done`. -async function pollUntil(command: string[], done: (output: any) => boolean): Promise { - for (let attempt = 0; attempt < 60; attempt++) { - const parsed = JSON.parse(await run(command)); - if (done(parsed)) return; - if (!isRecording()) { - throw new Error( - `Replayed fixture for \`${command.join(" ")}\` is not in the awaited state; re-record.`, - ); - } - await Bun.sleep(5_000); - } - throw new Error(`Timed out waiting for \`${command.join(" ")}\``); + await root.route(["node", "agentcore", "payment", "connector", ...args, ...regionArgs]); + return io; } -// pollUntilGone re-runs a `get` until the service reports the resource missing. -async function pollUntilGone(command: string[]): Promise { - for (let attempt = 0; attempt < 60; attempt++) { - try { - await run(command, createFixtureCore(AFTER_DELETE_FIXTURES)); - } catch (error) { - if (/ResourceNotFound|not found/i.test((error as Error).message)) return; - throw error; - } - if (!isRecording()) { - throw new Error(`Replayed fixture for \`${command.join(" ")}\` still exists; re-record.`); - } - await Bun.sleep(5_000); - } - throw new Error(`Timed out waiting for \`${command.join(" ")}\` to disappear`); -} - -describe("payment connector command hierarchy", () => { - test("registers the five connector leaves", () => { - const { root } = createRoot(); - const connector = root - .children() - .find((child) => child.name() === "payment") - ?.children() - .find((child) => child.name() === "connector"); - - expect(connector?.children().map((child) => child.name())).toEqual([ - "create", - "get", - "list", - "update", - "delete", - ]); - }); -}); - -describe("payment connector flag validation", () => { +describe("payment connector write inputs", () => { test.each([ - ["create", "name"], - ["create", "ARN"], - ["update", "name"], - ["update", "ARN"], - ] as const)("`%s` accepts a credential-provider %s", async (command, referenceType) => { - const providerArn = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/paymentcredentialprovider/provider"; - const commands: unknown[] = []; - const core = createFakedControlCore(async (request) => { - commands.push(request); - if (request instanceof GetPaymentCredentialProviderCommand) { - expect(request.input.name).toBe(CREDENTIAL_PROVIDER); - return { - credentialProviderArn: providerArn, - credentialProviderVendor: "CoinbaseCDP", - }; - } - if (request instanceof GetPaymentConnectorCommand) return connectorDetail("READY"); - if ( - request instanceof CreatePaymentConnectorCommand || - request instanceof UpdatePaymentConnectorCommand - ) { - return connectorDetail("READY"); - } - throw new Error("unexpected SDK command"); - }); - - await run( - [ - "payment", - "connector", - command, - "--manager-id", - MANAGER_ID, - ...(command === "create" ? ["--name", MANUAL_NAME] : ["--connector-id", "c-1"]), - ...(command === "create" && referenceType === "ARN" ? ["--type", "CoinbaseCDP"] : []), - "--credential-provider", - referenceType === "ARN" ? providerArn : CREDENTIAL_PROVIDER, - ], - core, + { args: [] }, + { args: ["--quick-create", "--credential-provider", CREDENTIAL_PROVIDER] }, + ])("requires exactly one credential source: $args", async ({ args }) => { + await expect(run(["create", ...scoped, "--name", MANUAL_NAME, ...args])).rejects.toThrow( + "specify exactly one of '--quick-create' or '--credential-provider'", ); - - expect(commands).toEqual([ - ...(command === "update" ? [expect.any(GetPaymentConnectorCommand)] : []), - ...(referenceType === "name" ? [expect.any(GetPaymentCredentialProviderCommand)] : []), - expect.any( - command === "create" ? CreatePaymentConnectorCommand : UpdatePaymentConnectorCommand, - ), - ]); - expect(commands.at(-1)).toMatchObject({ - input: { - credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: providerArn } }], - }, - }); }); test.each(["create", "update"] as const)( - "`%s` rejects empty --credential-provider before Core or SDK calls", + "%s rejects an empty credential reference before Core", async (command) => { - const factories = fixtureFactories(FIXTURES); - const sdk = mock(() => { - throw new Error("unexpected SDK client creation"); - }); - for (const name of Object.keys(factories) as (keyof typeof factories)[]) { - spyOn(factories, name).mockImplementation(sdk); - } - const core = new CoreClient({ ...factories, logger: createSilentLogger() }); + const core = createFixtureCore(); const call = spyOn( core.payment, command === "create" ? "createPaymentConnector" : "updatePaymentConnector", ); - const { root, io } = createRoot(core); - - try { - await expect( - root.route([ - "node", - "agentcore", - "payment", - "connector", + await expect( + run( + [ command, - "--manager-id", - MANAGER_ID, - ...(command === "create" ? ["--name", "EmptyReference"] : ["--connector-id", "c-1"]), + ...scoped, + ...(command === "create" ? ["--name", MANUAL_NAME] : ["--connector-id", "c-1"]), "--credential-provider", "", - "--region", - REGION, - ]), - ).rejects.toThrow("Invalid value for option '--credential-provider'"); - expect(call).not.toHaveBeenCalled(); - expect(sdk).not.toHaveBeenCalled(); - expect(io.stdout()).toBe(""); - expect(io.stderr()).toBe(""); - } finally { - call.mockRestore(); - } + ], + { core }, + ), + ).rejects.toThrow("Invalid value for option '--credential-provider'"); + expect(call).not.toHaveBeenCalled(); }, ); - // Each leaf declares its identifying flags optional (so a bare invocation can - // fall through to the TUI once one exists) but requires them at runtime. None - // of these reach the SDK, so no fixtures are involved. - test("`create` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "connector", "create", "--manager-id", "", "--name", "X", "--quick-create"]), - ).rejects.toThrow("required option '--manager-id ' not specified"); - }); - - test("`create` errors when --name is omitted", async () => { - await expect( - run([ - "payment", - "connector", - "create", - "--manager-id", - "m-1", - "--name", - "", - "--quick-create", - ]), - ).rejects.toThrow("required option '--name ' not specified"); - }); - - test("`create` rejects --quick-create together with --credential-provider", async () => { - await expect( - run([ - "payment", - "connector", - "create", - "--manager-id", - "m-1", - "--name", - "Both", - "--quick-create", - "--credential-provider", - "some-provider", - ]), - ).rejects.toThrow("specify exactly one of '--quick-create' or '--credential-provider'"); - }); - - test("`create` rejects neither --quick-create nor --credential-provider", async () => { - await expect( - run(["payment", "connector", "create", "--manager-id", "m-1", "--name", "Neither"]), - ).rejects.toThrow("specify exactly one of '--quick-create' or '--credential-provider'"); - }); - - test("`create` rejects an unsupported --type", async () => { - await expect( - run([ - "payment", - "connector", - "create", - "--manager-id", - "m-1", - "--name", - "Bad", - "--type", - "Paypal", - "--quick-create", - ]), - ).rejects.toThrow(/Invalid value for option '--type'/); - }); - - test("`create --type StripePrivy --quick-create` surfaces the Core validation error", async () => { - await expect( - run([ - "payment", - "connector", - "create", - "--manager-id", - "m-1", - "--name", - "Stripe", - "--type", - "StripePrivy", - "--quick-create", - ]), - ).rejects.toThrow("Quick Create is available only for CoinbaseCDP connectors, not StripePrivy"); - }); - - test("`get` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "connector", "get", "--manager-id", "", "--connector-id", "c-1"]), - ).rejects.toThrow("required option '--manager-id ' not specified"); - }); - - test("`get` errors when --connector-id is omitted", async () => { - await expect( - run(["payment", "connector", "get", "--manager-id", "m-1", "--connector-id", ""]), - ).rejects.toThrow("required option '--connector-id ' not specified"); - }); - - test("`list` errors when --manager-id is omitted", async () => { - await expect(run(["payment", "connector", "list", "--manager-id", ""])).rejects.toThrow( - "required option '--manager-id ' not specified", - ); - }); - - test("`update` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "connector", "update", "--manager-id", "", "--connector-id", "c-1"]), - ).rejects.toThrow("required option '--manager-id ' not specified"); - }); - - test("`update` errors when --connector-id is omitted", async () => { - await expect( - run(["payment", "connector", "update", "--manager-id", "m-1", "--connector-id", ""]), - ).rejects.toThrow("required option '--connector-id ' not specified"); - }); + test("update forwards a replacement credential reference, empty description, and client token", async () => { + const factories = fixtureFactories(FIXTURES); + const control = factories.createControlClient({ region: REGION }); + spyOn(control, "send") + .mockResolvedValueOnce(parse(JSON.stringify(connectorGetFixture))) + .mockImplementationOnce(async () => ({})); + const core = new CoreClient({ + ...factories, + createControlClient: () => control, + logger: createSilentLogger(), + }); + const update = spyOn(core.payment, "updatePaymentConnector"); + const providerArn = + connectorGetFixture.credentialProviderConfigurations[0]!.coinbaseCDP.credentialProviderArn; - test("`update` does not offer --type", async () => { - await expect( - run([ - "payment", - "connector", + await run( + [ "update", - "--manager-id", - "m-1", + ...scoped, "--connector-id", "c-1", - "--type", - "StripePrivy", - ]), - ).rejects.toThrow(/unknown option '--type'/); - }); - - test("`delete` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "connector", "delete", "--manager-id", "", "--connector-id", "c-1"]), - ).rejects.toThrow("required option '--manager-id ' not specified"); - }); - - test("`delete` errors when --connector-id is omitted", async () => { - await expect( - run(["payment", "connector", "delete", "--manager-id", "m-1", "--connector-id", ""]), - ).rejects.toThrow("required option '--connector-id ' not specified"); - }); -}); - -// ─── stderr hints against a faked control plane ────────────────────────────── -// -// The consent-window states are not reachable on demand in a recording (a -// Quick Create connector expires ten minutes after creation), so the control -// plane is faked at .send() while the real PaymentClient and handlers run. - -const AUTHORIZATION_URL = "https://login.coinbase.com/oauth2/auth?client_id=agentcore&state=abc"; - -function connectorDetail( - status: GetPaymentConnectorResponse["status"], -): GetPaymentConnectorResponse { - return { - paymentConnectorId: "quick-abc123", - name: QUICK_NAME, - type: "CoinbaseCDP", - credentialProviderConfigurations: [], - createdAt: new Date("2026-09-01T00:00:00.000Z"), - lastUpdatedAt: new Date("2026-09-01T00:00:00.000Z"), - status, - }; -} - -function coreReturningConnector(detail: GetPaymentConnectorResponse): CoreClient { - return createFakedControlCore(async (command) => { - if (command instanceof GetPaymentConnectorCommand) return detail; - throw new Error(`unexpected command ${(command as object).constructor.name}`); - }); -} - -function coreCreatingPendingConnector(): CoreClient { - const created: CreatePaymentConnectorResponse = { - ...connectorDetail("PENDING_AUTHENTICATION"), - paymentManagerId: MANAGER_ID, - authorizationUrl: AUTHORIZATION_URL, - }; - return createFakedControlCore(async (command) => { - if (command instanceof CreatePaymentConnectorCommand) return created; - throw new Error(`unexpected command ${(command as object).constructor.name}`); - }); -} - -describe("payment connector hints", () => { - const getArgs = [ - "payment", - "connector", - "get", - "--manager-id", - MANAGER_ID, - "--connector-id", - "quick-abc123", - ]; - - test("`get` prints a re-create hint on stderr for AUTHENTICATION_EXPIRED", async () => { - const { stdout, stderr } = await runCapturing( - getArgs, - coreReturningConnector(connectorDetail("AUTHENTICATION_EXPIRED")), - ); - expect(JSON.parse(stdout).status).toBe("AUTHENTICATION_EXPIRED"); - expect(stderr).toContain("cannot be renewed"); - expect(stderr).toContain("create it again with --quick-create"); - }); - - test("`get` prints the same hint for AUTHENTICATION_FAILED", async () => { - const { stdout, stderr } = await runCapturing( - getArgs, - coreReturningConnector(connectorDetail("AUTHENTICATION_FAILED")), - ); - expect(JSON.parse(stdout).status).toBe("AUTHENTICATION_FAILED"); - expect(stderr).toContain("create it again with --quick-create"); - }); - - test("`get` prints no hint for a READY connector", async () => { - const { stderr } = await runCapturing( - getArgs, - coreReturningConnector(connectorDetail("READY")), + "--credential-provider", + providerArn, + "--description", + "", + "--client-token", + "token-1", + ], + { core }, ); - expect(stderr).toBe(""); - }); - - test("`get --json` suppresses the hint", async () => { - const { stdout, stderr } = await runCapturing( - [...getArgs, "--json"], - coreReturningConnector(connectorDetail("AUTHENTICATION_EXPIRED")), + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + { + managerId: MANAGER_ID, + connectorId: "c-1", + credentialProvider: providerArn, + description: "", + clientToken: "token-1", + }, + { region: REGION }, ); - expect(JSON.parse(stdout).status).toBe("AUTHENTICATION_EXPIRED"); - expect(stderr).toBe(""); }); - const createArgs = [ - "payment", - "connector", - "create", - "--manager-id", - MANAGER_ID, - "--name", - QUICK_NAME, - "--quick-create", - ]; - - test("`create --quick-create` prints the authorization hint on stderr", async () => { - const { stdout, stderr } = await runCapturing(createArgs, coreCreatingPendingConnector()); - const parsed = JSON.parse(stdout); - expect(parsed.status).toBe("PENDING_AUTHENTICATION"); - expect(parsed.authorizationUrl).toBe(AUTHORIZATION_URL); - expect(stderr).toContain(AUTHORIZATION_URL); - expect(stderr).toContain("10 minutes"); - expect(stderr).toContain( - `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id quick-abc123`, - ); + test("update cannot change the connector type", async () => { + await expect( + run(["update", ...scoped, "--connector-id", "c-1", "--type", "StripePrivy"]), + ).rejects.toThrow("unknown option '--type'"); }); +}); +describe("payment connector Quick Create hints", () => { test.each([ { - label: "explicit region", + label: "explicit region over the environment", regionArgs: ["--region", "eu-west-1"], - environmentRegion: "us-east-1", - endpointUrl: undefined, + environment: "us-east-1", + endpoint: undefined, }, { - label: "resolved environment region", - regionArgs: [], - environmentRegion: "eu-west-1", - endpointUrl: undefined, - }, - { - label: "endpoint containing URL punctuation, spaces, and a quote", - regionArgs: ["--region", "eu-west-1"], - environmentRegion: "us-east-1", - endpointUrl: "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", + label: "environment region and shell-quoted endpoint", + regionArgs: [ + "--endpoint-url", + "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", + ], + environment: "eu-west-1", + endpoint: "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", }, ])( - "`create --quick-create` follow-up preserves $label", - async ({ regionArgs, environmentRegion, endpointUrl }) => { + "follow-up preserves $label after the environment changes", + async ({ regionArgs, environment, endpoint }) => { const savedRegion = process.env.AWS_REGION; - const configs: ClientConfig[] = []; - const getRequests: GetPaymentConnectorCommand["input"][] = []; const factories = fixtureFactories(FIXTURES); - const coreOptions = { - ...factories, - createControlClient: (config: ClientConfig) => { - configs.push(config); - return { - send: async (command: unknown) => { - if (command instanceof CreatePaymentConnectorCommand) { - return parse(JSON.stringify(quickCreateFixture)); - } - if (command instanceof GetPaymentConnectorCommand) { - getRequests.push(command.input); - return parse( - JSON.stringify({ - ...quickCreateFixture, - status: "READY", - lastUpdatedAt: quickCreateFixture.createdAt, - }), - ); - } - throw new Error("unexpected command in Quick Create hint test"); - }, - } as unknown as BedrockAgentCoreControlClient; - }, - logger: createSilentLogger(), - }; - try { - process.env.AWS_REGION = environmentRegion; - const created = createRoot(new CoreClient(coreOptions)); - await created.root.route([ - "node", - "agentcore", - ...createArgs, - ...regionArgs, - ...(endpointUrl === undefined ? [] : ["--endpoint-url", endpointUrl]), - ]); - expect(configs).toEqual([{ region: "eu-west-1", endpoint: endpointUrl }]); + const getRequests: GetPaymentConnectorCommand["input"][] = []; + const createControlClient = mock((config: ClientConfig) => { + const client = factories.createControlClient(config); + const replay = client.send.bind(client); + spyOn(client, "send").mockImplementation(async (command) => { + if (command instanceof GetPaymentConnectorCommand) { + getRequests.push(command.input); + // The recorded Quick Create flow never completes OAuth consent. + return parse( + JSON.stringify({ + ...quickCreateFixture, + status: "READY", + lastUpdatedAt: quickCreateFixture.createdAt, + }), + ); + } + return replay(command); + }); + return client; + }); + const coreOptions = { ...factories, createControlClient, logger: createSilentLogger() }; - const command = created.io.stderr().match(/`(agentcore payment connector get [^`]+)`/)?.[1]; + try { + process.env.AWS_REGION = environment; + const created = await run(quickArgs, { + core: new CoreClient(coreOptions), + regionArgs: [...regionArgs], + }); + const command = created.stderr().match(/`(agentcore payment connector get [^`]+)`/)?.[1]; expect(command).toBeDefined(); - // Parse the displayed command with a shell without invoking the installed CLI. + // Parse the displayed command without invoking the installed CLI. const argv = execFileSync("sh", ["-c", `set -- ${command}\nprintf '%s\\0' "$@"`], { encoding: "utf8", }) .split("\0") .slice(0, -1); + expect(argv.slice(0, 3)).toEqual(["agentcore", "payment", "connector"]); process.env.AWS_REGION = "us-east-1"; - const followUp = createRoot(new CoreClient(coreOptions)); - await followUp.root.route(["node", ...argv]); + const followUp = await run(argv.slice(3), { + core: new CoreClient(coreOptions), + regionArgs: [], + }); expect(getRequests).toEqual([ { paymentManagerId: MANAGER_ID, paymentConnectorId: quickCreateFixture.paymentConnectorId, }, ]); - expect(configs).toEqual([ - { region: "eu-west-1", endpoint: endpointUrl }, - { region: "eu-west-1", endpoint: endpointUrl }, + expect(createControlClient.mock.calls).toEqual([ + [{ region: "eu-west-1", endpoint }], + [{ region: "eu-west-1", endpoint }], ]); - expect(JSON.parse(followUp.io.stdout()).status).toBe("READY"); - expect(followUp.io.stderr()).toBe(""); - if (endpointUrl === undefined) expect(command).not.toContain("--endpoint-url"); + expect(JSON.parse(followUp.stdout()).status).toBe("READY"); + if (endpoint === undefined) expect(command).not.toContain("--endpoint-url"); } finally { if (savedRegion === undefined) delete process.env.AWS_REGION; else process.env.AWS_REGION = savedRegion; @@ -606,234 +211,66 @@ describe("payment connector hints", () => { }, ); - test.each([ - undefined, - "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", - ])("`create --quick-create --json` suppresses the hint with endpoint %j", async (endpointUrl) => { - const { stdout, stderr } = await runCapturing( - [ - ...createArgs, - ...(endpointUrl === undefined ? [] : ["--endpoint-url", endpointUrl]), - "--json", - ], - coreCreatingPendingConnector(), - ); - expect(JSON.parse(stdout).authorizationUrl).toBe(AUTHORIZATION_URL); - expect(stderr).toBe(""); + test("--json keeps the authorization URL in stdout without a stderr hint", async () => { + const io = await run([...quickArgs, "--json"]); + expect(JSON.parse(io.stdout()).authorizationUrl).toMatch(/^https:\/\//); + expect(io.stderr()).toBe(""); }); }); -// ─── manual flow (create → get → list → update → delete) ───────────────────── -// -// Drives the lifecycle of a real connector backed by an existing CoinbaseCDP -// payment credential provider, in order, through route(). In record mode it hits -// the live control plane and persists every exchange; replays are offline and -// instant. Later tests consume the id parsed from earlier output. - -const state: { connectorId?: string; quickConnectorId?: string } = {}; - -describe("payment connector manual flow", () => { - test( - "`create` infers the type from the named credential provider", - async () => { - const out = await run([ - "payment", - "connector", - "create", - "--manager-id", - MANAGER_ID, - "--name", - MANUAL_NAME, - "--description", - "Created by the agentcore CLI end-to-end test", - "--credential-provider", - CREDENTIAL_PROVIDER, - ]); - matchGolden(FIXTURES, "connector-create.golden.json", out); - - const parsed = JSON.parse(out); - expect(parsed.name).toBe(MANUAL_NAME); - // No --type was passed: the vendor of the named provider decided it. - expect(parsed.type).toBe("CoinbaseCDP"); - expect(parsed.paymentConnectorId).toBeDefined(); - state.connectorId = parsed.paymentConnectorId; - - await pollUntil( - [ - "payment", - "connector", - "get", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.connectorId!, - ], - (o) => o.status === "READY", - ); - }, - FLOW_TIMEOUT, - ); - - test("`list` includes the connector", async () => { - const out = await run(["payment", "connector", "list", "--manager-id", MANAGER_ID]); - matchGolden(FIXTURES, "connector-list.golden.json", out); - - const parsed = JSON.parse(out); - expect(Array.isArray(parsed.paymentConnectors)).toBe(true); - expect( - parsed.paymentConnectors.map((c: { paymentConnectorId: string }) => c.paymentConnectorId), - ).toContain(state.connectorId); - }); - - test( - "`update` changes the description", - async () => { - const out = await run([ - "payment", - "connector", - "update", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.connectorId!, - "--description", - "Updated by the agentcore CLI end-to-end test", - ]); - matchGolden(FIXTURES, "connector-update.golden.json", out); - expect(JSON.parse(out).paymentConnectorId).toBe(state.connectorId); - - await pollUntil( - [ - "payment", - "connector", - "get", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.connectorId!, - ], - (o) => o.status === "READY" && /Updated by/.test(o.description ?? ""), - ); - }, - FLOW_TIMEOUT, - ); - - // Sits after `update` on purpose: every `get` of this connector shares one - // fixture, which holds the last recorded (post-update) state. - test("`get` prints the connector detail as JSON", async () => { - const { stdout, stderr } = await runCapturing([ - "payment", - "connector", - "get", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.connectorId!, - ]); - matchGolden(FIXTURES, "connector-get.golden.json", stdout); - - const parsed = JSON.parse(stdout); - expect(parsed.paymentConnectorId).toBe(state.connectorId); - expect(parsed.status).toBe("READY"); - expect(parsed.description).toBe("Updated by the agentcore CLI end-to-end test"); - expect(parsed.credentialProviderConfigurations[0].coinbaseCDP.credentialProviderArn).toContain( - CREDENTIAL_PROVIDER, - ); - expect(stderr).toBe(""); +test("payment connector lifecycle replays named-provider creation, update, and deletion through root/Core", async () => { + const created = await run([ + "create", + ...scoped, + "--name", + MANUAL_NAME, + "--description", + "Created by the agentcore CLI end-to-end test", + "--credential-provider", + CREDENTIAL_PROVIDER, + ]); + matchGolden(FIXTURES, "connector-create.golden.json", created.stdout()); + const connector = JSON.parse(created.stdout()); + expect(connector.type).toBe("CoinbaseCDP"); + const connectorArgs = [...scoped, "--connector-id", connector.paymentConnectorId]; + expect(JSON.parse((await run(["get", ...connectorArgs])).stdout()).status).toBe("READY"); + + const description = "Updated by the agentcore CLI end-to-end test"; + const updated = await run(["update", ...connectorArgs, "--description", description]); + matchGolden(FIXTURES, "connector-update.golden.json", updated.stdout()); + const detail = await run(["get", ...connectorArgs]); + matchGolden(FIXTURES, "connector-get.golden.json", detail.stdout()); + expect(JSON.parse(detail.stdout())).toMatchObject({ + paymentConnectorId: connector.paymentConnectorId, + status: "READY", + description, }); - test( - "`delete` deletes the connector", - async () => { - const out = await run([ - "payment", - "connector", - "delete", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.connectorId!, - ]); - matchGolden(FIXTURES, "connector-delete.golden.json", out); - expect(JSON.parse(out).status).toBe("DELETING"); - - await pollUntilGone([ - "payment", - "connector", - "get", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.connectorId!, - ]); - }, - FLOW_TIMEOUT, - ); + const deleted = await run(["delete", ...connectorArgs]); + matchGolden(FIXTURES, "connector-delete.golden.json", deleted.stdout()); + expect(JSON.parse(deleted.stdout()).status).toBe("DELETING"); + await expect( + run(["get", ...connectorArgs], { core: createFixtureCore(join(FIXTURES, "after-delete")) }), + ).rejects.toThrow(/ResourceNotFound|not found/i); }); -// ─── Quick Create flow (create → delete) ───────────────────────────────────── -// -// Quick Create asks Coinbase to provision the credentials after OAuth consent. -// The consent is never completed here: the test only checks that the CLI hands -// back the authorization URL and cleans the pending connector up again. - -describe("payment connector quick create flow", () => { - test( - "`create --quick-create` returns a pending connector with an authorization URL", - async () => { - const { stdout, stderr } = await runCapturing([ - "payment", - "connector", - "create", - "--manager-id", - MANAGER_ID, - "--name", - QUICK_NAME, - "--quick-create", - ]); - matchGolden(FIXTURES, "connector-quick-create.golden.json", stdout); - - const parsed = JSON.parse(stdout); - expect(parsed.name).toBe(QUICK_NAME); - expect(parsed.type).toBe("CoinbaseCDP"); - expect(parsed.status).toBe("PENDING_AUTHENTICATION"); - expect(parsed.authorizationUrl).toMatch(/^https:\/\//); - expect(parsed.paymentConnectorId).toBeDefined(); - state.quickConnectorId = parsed.paymentConnectorId; - - expect(stderr).toContain(parsed.authorizationUrl); - expect(stderr).toContain( - `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${state.quickConnectorId}`, - ); - }, - FLOW_TIMEOUT, +test("payment connector Quick Create lifecycle returns consent instructions and deletes the pending connector", async () => { + const created = await run(quickArgs); + matchGolden(FIXTURES, "connector-quick-create.golden.json", created.stdout()); + const connector = JSON.parse(created.stdout()); + expect(connector.status).toBe("PENDING_AUTHENTICATION"); + expect(connector.authorizationUrl).toMatch(/^https:\/\//); + expect(created.stderr()).toContain(connector.authorizationUrl); + expect(created.stderr()).toContain("10 minutes"); + expect(created.stderr()).toContain( + `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${connector.paymentConnectorId}`, ); - test( - "`delete` removes the pending connector", - async () => { - const out = await run([ - "payment", - "connector", - "delete", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.quickConnectorId!, - ]); - matchGolden(FIXTURES, "connector-quick-delete.golden.json", out); - expect(JSON.parse(out).status).toBe("DELETING"); - - await pollUntilGone([ - "payment", - "connector", - "get", - "--manager-id", - MANAGER_ID, - "--connector-id", - state.quickConnectorId!, - ]); - }, - FLOW_TIMEOUT, - ); + const connectorArgs = [...scoped, "--connector-id", connector.paymentConnectorId]; + const deleted = await run(["delete", ...connectorArgs]); + matchGolden(FIXTURES, "connector-quick-delete.golden.json", deleted.stdout()); + expect(JSON.parse(deleted.stdout()).status).toBe("DELETING"); + await expect( + run(["get", ...connectorArgs], { core: createFixtureCore(join(FIXTURES, "after-delete")) }), + ).rejects.toThrow(/ResourceNotFound|not found/i); }); diff --git a/src/handlers/payment/instrument/instrument.test.tsx b/src/handlers/payment/instrument/instrument.test.tsx index 9faec35a3..a43893417 100644 --- a/src/handlers/payment/instrument/instrument.test.tsx +++ b/src/handlers/payment/instrument/instrument.test.tsx @@ -1,389 +1,91 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { join } from "node:path"; import type { - BedrockAgentCoreClient, CreatePaymentInstrumentRequest, EmbeddedCryptoWallet, } from "@aws-sdk/client-bedrock-agentcore"; -import { - GetPaymentManagerCommand, - type BedrockAgentCoreControlClient, -} from "@aws-sdk/client-bedrock-agentcore-control"; import { CoreClient } from "../../../core"; import { createRootHandler } from "../../index"; import { createSilentLogger, fixtureFactories, - isRecording, matchGolden, + parse, TestGlobalConfigAccessor, testIO, } from "../../../testing"; - -// End-to-end command-flow tests for the `payment instrument` leaves. -// -// Each test builds the real root handler over a real CoreClient whose SDK -// clients are the fixture-backed fakes, then drives it through `route()` exactly -// as the CLI does, so one test covers parsing, middleware, the leaf handler, -// PaymentClient, and the rendered output. -// -// Record with: -// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/instrument/instrument.test.tsx -// The flow uses an AWS_IAM payment manager and a READY CoinbaseCDP connector that -// already exist in the test account (the manager quota is exhausted, so none is -// created here) and leaves nothing behind: it creates one embedded wallet and -// deletes it again. +import instrumentCreateFixture from "../__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json"; const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); const FIXTURES = join(PAYMENT_FIXTURES, "instrument"); -// Fixtures are keyed by operation and input, so a `get` issued after the delete -// would overwrite the pre-delete `get` fixture. Reads that expect the instrument -// to be gone record into their own directory. -const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); const REGION = "us-west-2"; const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; -const MANAGER_ARN = - "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira"; const CONNECTOR_ID = "mycdpconnectoraidandal-okve8guw4y"; const USER_ID = "agentcore-cli-e2e"; const EMAIL = "agentcore-cli-e2e@example.com"; -const FLOW_TIMEOUT = 120_000; +const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; +const connectorScoped = [...scoped, "--connector-id", CONNECTOR_ID]; +const shorthand = ["--network", "ETHEREUM", "--email", EMAIL]; function createFixtureCore(fixtures = FIXTURES): CoreClient { - const { createControlClient, createIamClient, createLogsClient } = - fixtureFactories(PAYMENT_FIXTURES); - const { createDataClient } = fixtureFactories(fixtures); return new CoreClient({ - createControlClient, - createDataClient, - createIamClient, - createLogsClient, + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: fixtureFactories(fixtures).createDataClient, logger: createSilentLogger(), }); } -// createCapturingDataCore swaps the data plane's `.send()` for one that records -// the request it was handed, while keeping the real CoreClient and PaymentClient -// in the loop. Fixtures only key on the request hash, so this is how the tests -// assert the exact request each flag form builds. -function createCapturingDataCore(): { - core: CoreClient; - sent: unknown[]; - lookups: GetPaymentManagerCommand[]; -} { - const sent: unknown[] = []; - const lookups: GetPaymentManagerCommand[] = []; - const { createIamClient, createLogsClient } = fixtureFactories(PAYMENT_FIXTURES); - const core = new CoreClient({ - createControlClient: () => - ({ - send: async (command: GetPaymentManagerCommand) => { - expect(command).toBeInstanceOf(GetPaymentManagerCommand); - lookups.push(command); - return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; - }, - }) as unknown as BedrockAgentCoreControlClient, - createDataClient: () => - ({ - send: async (command: { input: unknown }) => { - sent.push(command.input); - return {}; - }, - }) as unknown as BedrockAgentCoreClient, - createIamClient, - createLogsClient, - logger: createSilentLogger(), - }); - return { core, sent, lookups }; -} - -function createRoot(core = createFixtureCore(), stdin?: string) { - const io = testIO({ stdin }); +async function run(args: string[], core = createFixtureCore(), io = testIO()): Promise { const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - return { root, io }; -} - -async function run( - args: string[], - { fixtures = FIXTURES, stdin }: { fixtures?: string; stdin?: string } = {}, -): Promise { - const { root, io } = createRoot(createFixtureCore(fixtures), stdin); - await root.route(["node", "agentcore", ...args, "--region", REGION]); + await root.route(["node", "agentcore", "payment", "instrument", ...args, "--region", REGION]); return io.stdout(); } async function capture(args: string[], stdin?: string): Promise { - const { core, sent, lookups } = createCapturingDataCore(); - const { root } = createRoot(core, stdin); - await root.route(["node", "agentcore", ...args, "--region", REGION]); - expect(lookups).toHaveLength(1); - expect(lookups[0]?.input).toEqual({ paymentManagerId: MANAGER_ID }); - expect(sent).toHaveLength(1); - expect(sent[0]).toHaveProperty("paymentManagerArn", MANAGER_ARN); - expect(sent[0]).not.toHaveProperty("managerId"); - return sent[0] as CreatePaymentInstrumentRequest; -} - -const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; -const connectorScoped = [...scoped, "--connector-id", CONNECTOR_ID]; -const shorthand = ["--network", "ETHEREUM", "--email", EMAIL]; -const walletJson = JSON.stringify({ - network: "ETHEREUM", - linkedAccounts: [{ email: { emailAddress: EMAIL } }], -}); - -describe("payment instrument command hierarchy", () => { - test("registers create, get, list, delete, and balance leaves", () => { - const { root } = createRoot(); - const instrument = root - .children() - .find((child) => child.name() === "payment") - ?.children() - .find((child) => child.name() === "instrument"); - - expect(instrument?.children().map((child) => child.name())).toEqual([ - "create", - "get", - "list", - "delete", - "balance", - ]); - }); -}); - -describe("payment instrument validation", () => { - test.each(["create", "get", "list", "delete"])( - "`%s` rejects the removed --manager-arn flag, including alongside --manager-id", - async (command) => { - for (const idArgs of [[], scoped]) { - await expect( - run(["payment", "instrument", command, ...idArgs, "--manager-arn", MANAGER_ARN]), - ).rejects.toThrow(/unknown option '--manager-arn'/); - } - }, + const data = fixtureFactories(FIXTURES).createDataClient({ region: REGION }); + const send = spyOn(data, "send").mockResolvedValue( + parse(JSON.stringify(instrumentCreateFixture)), ); + const core = new CoreClient({ + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: () => data, + logger: createSilentLogger(), + }); + await run(["create", ...connectorScoped, ...args], core, testIO({ stdin })); + expect(send).toHaveBeenCalledTimes(1); + return send.mock.calls[0]![0].input as CreatePaymentInstrumentRequest; +} - test.each(["create", "get", "list", "delete"])( - "`%s` rejects an explicitly empty --manager-id", - async (command) => { +describe("payment instrument wallet inputs", () => { + test.each([{ flags: shorthand }, { flags: ["--phone-number", "+15555550100"] }])( + "rejects shorthand $flags alongside JSON before reading stdin", + async ({ flags }) => { + const core = createFixtureCore(); + const create = spyOn(core.payment, "createPaymentInstrument"); + const io = testIO({ stdin: "{}" }); await expect( - run(["payment", "instrument", command, "--manager-id", "", "--user-id", USER_ID]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); + run(["create", ...connectorScoped, ...flags, "--instrument-details", "-"], core, io), + ).rejects.toThrow("--instrument-details is mutually exclusive with"); + expect(create).not.toHaveBeenCalled(); + expect(io.io.stdin.readableLength).toBe(2); }, ); - // Every leaf declares its identifying flags optional (so a bare invocation can - // fall through to the TUI once one exists) but requires them at runtime. None - // of these reach the SDK, so no fixtures are involved. - test("`create` errors when --manager-id is omitted", async () => { - await expect( - run([ - "payment", - "instrument", - "create", - "--user-id", - USER_ID, - "--connector-id", - CONNECTOR_ID, - ...shorthand, - ]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); - }); - - test("`create` errors when --user-id is omitted", async () => { - await expect( - run([ - "payment", - "instrument", - "create", - "--manager-id", - MANAGER_ID, - "--connector-id", - CONNECTOR_ID, - ...shorthand, - ]), - ).rejects.toThrow(/required option '--user-id ' not specified/); - }); - - test("`create` errors when --connector-id is omitted", async () => { - await expect(run(["payment", "instrument", "create", ...scoped, ...shorthand])).rejects.toThrow( - /required option '--connector-id ' not specified/, - ); - }); - - test("`create` rejects shorthand flags together with --instrument-details", async () => { - await expect( - run([ - "payment", - "instrument", - "create", - ...connectorScoped, - ...shorthand, - "--instrument-details", - walletJson, - ]), - ).rejects.toThrow(/--instrument-details is mutually exclusive with/); - }); - - test("`create` rejects --phone-number together with --instrument-details", async () => { - await expect( - run([ - "payment", - "instrument", - "create", - ...connectorScoped, - "--phone-number", - "+15555550100", - "--instrument-details", - walletJson, - ]), - ).rejects.toThrow(/--instrument-details is mutually exclusive with/); - }); - - test("`create` errors when the shorthand form omits --network", async () => { - await expect( - run(["payment", "instrument", "create", ...connectorScoped, "--email", EMAIL]), - ).rejects.toThrow(/required option '--network ' not specified/); - }); - - test("`create` errors when no wallet details are given at all", async () => { - await expect(run(["payment", "instrument", "create", ...connectorScoped])).rejects.toThrow( - /required option '--network ' not specified/, + test("shorthand needs a network and at least one linked account", async () => { + await expect(run(["create", ...connectorScoped, "--email", EMAIL])).rejects.toThrow( + "required option '--network ' not specified", ); - }); - - test("`create` errors when the shorthand form has no linked account", async () => { - await expect( - run(["payment", "instrument", "create", ...connectorScoped, "--network", "ETHEREUM"]), - ).rejects.toThrow(/at least one --email or --phone-number/); - }); - - test("`create` rejects an unsupported --network", async () => { - await expect( - run([ - "payment", - "instrument", - "create", - ...connectorScoped, - "--network", - "BITCOIN", - "--email", - EMAIL, - ]), - ).rejects.toThrow(/Invalid value for option '--network'/); - }); - - test("`create` rejects an unsupported --type", async () => { - await expect( - run(["payment", "instrument", "create", ...connectorScoped, ...shorthand, "--type", "CARD"]), - ).rejects.toThrow(/Invalid value for option '--type'/); - }); - - test("`create` rejects malformed --instrument-details JSON", async () => { - await expect( - run([ - "payment", - "instrument", - "create", - ...connectorScoped, - "--instrument-details", - "{not json", - ]), - ).rejects.toThrow(/Invalid JSON for option '--instrument-details'/); - }); - - test("`create` rejects --instrument-details that is not a JSON object", async () => { - await expect( - run(["payment", "instrument", "create", ...connectorScoped, "--instrument-details", "[]"]), - ).rejects.toThrow(/Option '--instrument-details' must be a JSON object/); - }); - - test("`get` errors when --instrument-id is omitted", async () => { - await expect(run(["payment", "instrument", "get", ...scoped])).rejects.toThrow( - /required option '--instrument-id ' not specified/, + await expect(run(["create", ...connectorScoped, "--network", "ETHEREUM"])).rejects.toThrow( + "at least one --email or --phone-number", ); }); - test("`get` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "instrument", "get", "--user-id", USER_ID, "--instrument-id", "i-1"]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); - }); - - test("`get` errors when --user-id is omitted", async () => { - await expect( - run(["payment", "instrument", "get", "--manager-id", MANAGER_ID, "--instrument-id", "i-1"]), - ).rejects.toThrow(/required option '--user-id ' not specified/); - }); - - test("`list` errors when --manager-id is omitted", async () => { - await expect(run(["payment", "instrument", "list", "--user-id", USER_ID])).rejects.toThrow( - /required option '--manager-id ' not specified/, - ); - }); - - test("`list` errors when --user-id is omitted", async () => { - await expect( - run(["payment", "instrument", "list", "--manager-id", MANAGER_ID]), - ).rejects.toThrow(/required option '--user-id ' not specified/); - }); - - test("`delete` errors when --connector-id is omitted", async () => { - await expect( - run(["payment", "instrument", "delete", ...scoped, "--instrument-id", "i-1"]), - ).rejects.toThrow(/required option '--connector-id ' not specified/); - }); - - test("`delete` errors when --instrument-id is omitted", async () => { - await expect(run(["payment", "instrument", "delete", ...connectorScoped])).rejects.toThrow( - /required option '--instrument-id ' not specified/, - ); - }); - - test("`delete` errors when --manager-id is omitted", async () => { - await expect( - run([ - "payment", - "instrument", - "delete", - "--user-id", - USER_ID, - "--connector-id", - CONNECTOR_ID, - "--instrument-id", - "i-1", - ]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); - }); - - test("`delete` errors when --user-id is omitted", async () => { - await expect( - run([ - "payment", - "instrument", - "delete", - "--manager-id", - MANAGER_ID, - "--connector-id", - CONNECTOR_ID, - "--instrument-id", - "i-1", - ]), - ).rejects.toThrow(/required option '--user-id ' not specified/); - }); -}); - -describe("payment instrument create request mapping", () => { - test("shorthand flags build one linked account per --email and --phone-number", async () => { + test("shorthand preserves repeated email/SMS accounts and omits unset metadata", async () => { const request = await capture([ - "payment", - "instrument", - "create", - ...connectorScoped, "--network", "SOLANA", "--email", @@ -393,208 +95,75 @@ describe("payment instrument create request mapping", () => { "--phone-number", "+15555550100", ]); - - expect(request).toEqual({ - paymentManagerArn: MANAGER_ARN, - userId: USER_ID, - paymentConnectorId: CONNECTOR_ID, - paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET", - paymentInstrumentDetails: { - embeddedCryptoWallet: { - network: "SOLANA", - linkedAccounts: [ - { email: { emailAddress: "one@example.com" } }, - { email: { emailAddress: "two@example.com" } }, - { sms: { phoneNumber: "+15555550100" } }, - ], - }, - }, - }); - }); - - test("--instrument-details passes the wallet through, reaching every field", async () => { - const wallet: EmbeddedCryptoWallet = { - network: "ETHEREUM", - linkedAccounts: [ - { developerJwt: { kid: "key-1", sub: "user-1" } }, - { oAuth2: { google: { sub: "google-sub", emailAddress: "g@example.com" } } }, - ], - walletAddress: "0x1234567890abcdef1234567890abcdef12345678", - redirectUrl: "https://example.test/return", - }; - const request = await capture([ - "payment", - "instrument", - "create", - ...connectorScoped, - "--instrument-details", - JSON.stringify(wallet), - ]); - - expect(request.paymentInstrumentDetails).toEqual({ embeddedCryptoWallet: wallet }); expect(request.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); - }); - - test("--instrument-details - reads the wallet from stdin", async () => { - const request = await capture( - ["payment", "instrument", "create", ...connectorScoped, "--instrument-details", "-"], - walletJson, - ); - expect(request.paymentInstrumentDetails).toEqual({ - embeddedCryptoWallet: JSON.parse(walletJson), + embeddedCryptoWallet: { + network: "SOLANA", + linkedAccounts: [ + { email: { emailAddress: "one@example.com" } }, + { email: { emailAddress: "two@example.com" } }, + { sms: { phoneNumber: "+15555550100" } }, + ], + }, }); - }); - - test("optional --agent-name and --client-token are forwarded only when set", async () => { - const bare = await capture([ - "payment", - "instrument", - "create", - ...connectorScoped, - ...shorthand, - ]); - expect(bare).not.toHaveProperty("agentName"); - expect(bare).not.toHaveProperty("clientToken"); - - const full = await capture([ - "payment", - "instrument", - "create", - ...connectorScoped, - ...shorthand, - "--agent-name", - "my-agent", - "--client-token", - "token-1", - ]); - expect(full.agentName).toBe("my-agent"); - expect(full.clientToken).toBe("token-1"); - }); -}); - -// ─── instrument flow (create → get → list → delete → get) ──────────────────── -// -// Drives the lifecycle of a real embedded crypto wallet, in order, through -// route(). In record mode it hits the live data plane and persists every -// exchange; replays are offline and instant. Later tests consume the id parsed -// from earlier output. - -const state: { instrumentId?: string } = {}; - -// pollUntilSettled re-runs `get` until the instrument is ACTIVE or the polling -// budget runs out, and returns the last observed status. The CoinbaseCDP -// connector has provisioned the wallet as ACTIVE within the create call itself; -// the poll guards a re-record against a slower INITIATED → ACTIVE transition. -// The fixture ends up holding the last poll; in replay the first read is final. -async function pollUntilSettled(command: string[]): Promise { - let status = ""; - for (let attempt = 0; attempt < 12; attempt++) { - status = JSON.parse(await run(command)).paymentInstrument.status; - if (status === "ACTIVE" || !isRecording()) return status; - await Bun.sleep(5_000); - } - return status; -} - -describe("payment instrument flow", () => { - test( - "`create` provisions an embedded wallet from the shorthand flags", - async () => { - const out = await run(["payment", "instrument", "create", ...connectorScoped, ...shorthand]); - matchGolden(FIXTURES, "instrument-create.golden.json", out); - - const { paymentInstrument } = JSON.parse(out); - expect(paymentInstrument.paymentInstrumentId).toBeDefined(); - expect(paymentInstrument.paymentManagerArn).toBe(MANAGER_ARN); - expect(paymentInstrument.paymentConnectorId).toBe(CONNECTOR_ID); - expect(paymentInstrument.userId).toBe(USER_ID); - expect(paymentInstrument.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); - expect(paymentInstrument.paymentInstrumentDetails.embeddedCryptoWallet.network).toBe( - "ETHEREUM", + expect(request).not.toHaveProperty("agentName"); + expect(request).not.toHaveProperty("clientToken"); + }); + + test.each(["inline", "stdin"])( + "passes the full wallet and optional metadata from %s", + async (source) => { + const wallet: EmbeddedCryptoWallet = { + network: "ETHEREUM", + linkedAccounts: [ + { developerJwt: { kid: "key-1", sub: "user-1" } }, + { oAuth2: { google: { sub: "google-sub", emailAddress: "g@example.com" } } }, + ], + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + redirectUrl: "https://example.test/return", + }; + const json = JSON.stringify(wallet); + const request = await capture( + [ + "--instrument-details", + source === "stdin" ? "-" : json, + "--agent-name", + "my-agent", + "--client-token", + "token-1", + ], + source === "stdin" ? json : undefined, ); - state.instrumentId = paymentInstrument.paymentInstrumentId; - - const status = await pollUntilSettled([ - "payment", - "instrument", - "get", - ...scoped, - "--instrument-id", - state.instrumentId!, - ]); - expect(status).toBe("ACTIVE"); + expect(request.paymentInstrumentDetails).toEqual({ embeddedCryptoWallet: wallet }); + expect(request.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); + expect(request.agentName).toBe("my-agent"); + expect(request.clientToken).toBe("token-1"); }, - FLOW_TIMEOUT, ); +}); - test( - "`get` returns the instrument", - async () => { - const out = await run([ - "payment", - "instrument", - "get", - ...scoped, - "--instrument-id", - state.instrumentId!, - ]); - matchGolden(FIXTURES, "instrument-get.golden.json", out); - - const { paymentInstrument } = JSON.parse(out); - expect(paymentInstrument.paymentInstrumentId).toBe(state.instrumentId); - expect(paymentInstrument.paymentConnectorId).toBe(CONNECTOR_ID); - expect(paymentInstrument.status).toBe("ACTIVE"); - expect(paymentInstrument.paymentInstrumentDetails.embeddedCryptoWallet.walletAddress).toMatch( - /^0x[0-9a-fA-F]{40}$/, - ); - }, - FLOW_TIMEOUT, - ); - - test( - "`list` includes the instrument", - async () => { - const out = await run(["payment", "instrument", "list", ...connectorScoped]); - matchGolden(FIXTURES, "instrument-list.golden.json", out); - - const parsed = JSON.parse(out); - expect(Array.isArray(parsed.paymentInstruments)).toBe(true); - expect( - parsed.paymentInstruments.map( - (instrument: { paymentInstrumentId: string }) => instrument.paymentInstrumentId, - ), - ).toContain(state.instrumentId); - }, - FLOW_TIMEOUT, +test("payment instrument lifecycle replays wallet provisioning and deletion through root/Core", async () => { + const created = await run(["create", ...connectorScoped, ...shorthand]); + matchGolden(FIXTURES, "instrument-create.golden.json", created); + const { paymentInstrument } = JSON.parse(created); + const instrumentArgs = ["--instrument-id", paymentInstrument.paymentInstrumentId]; + + const detail = await run(["get", ...scoped, ...instrumentArgs]); + matchGolden(FIXTURES, "instrument-get.golden.json", detail); + const wallet = JSON.parse(detail).paymentInstrument; + expect(wallet.paymentInstrumentId).toBe(paymentInstrument.paymentInstrumentId); + expect(wallet.paymentConnectorId).toBe(CONNECTOR_ID); + expect(wallet.status).toBe("ACTIVE"); + expect(wallet.paymentInstrumentDetails.embeddedCryptoWallet.walletAddress).toMatch( + /^0x[0-9a-fA-F]{40}$/, ); - test( - "`delete` deletes the instrument", - async () => { - const out = await run([ - "payment", - "instrument", - "delete", - ...connectorScoped, - "--instrument-id", - state.instrumentId!, - ]); - matchGolden(FIXTURES, "instrument-delete.golden.json", out); - expect(JSON.parse(out).status).toBe("DELETED"); - }, - FLOW_TIMEOUT, - ); + const deleted = await run(["delete", ...connectorScoped, ...instrumentArgs]); + matchGolden(FIXTURES, "instrument-delete.golden.json", deleted); + expect(JSON.parse(deleted).status).toBe("DELETED"); - test( - "`get` after delete reports the instrument gone", - async () => { - await expect( - run(["payment", "instrument", "get", ...scoped, "--instrument-id", state.instrumentId!], { - fixtures: AFTER_DELETE_FIXTURES, - }), - ).rejects.toThrow(/ResourceNotFound|not found/i); - }, - FLOW_TIMEOUT, - ); + // The same Get request has a separate post-delete fixture. + await expect( + run(["get", ...scoped, ...instrumentArgs], createFixtureCore(join(FIXTURES, "after-delete"))), + ).rejects.toThrow(/ResourceNotFound|not found/i); }); diff --git a/src/handlers/payment/payment.test.tsx b/src/handlers/payment/payment.test.tsx index 4e2211f42..05b6943b0 100644 --- a/src/handlers/payment/payment.test.tsx +++ b/src/handlers/payment/payment.test.tsx @@ -1,197 +1,92 @@ -import { describe, expect, mock, spyOn, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { join } from "node:path"; -import { Readable } from "node:stream"; -import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; import { CoreClient } from "../../core"; import { paymentServiceRoleName } from "../../core/paymentServiceRole"; import { createRootHandler } from "../index"; import { createSilentLogger, fixtureFactories, - isRecording, matchGolden, TestGlobalConfigAccessor, testIO, } from "../../testing"; -// End-to-end command-flow tests for the `payment` subtree's manager leaves. -// -// Each test builds the real root handler over a real CoreClient whose SDK -// clients are the fixture-backed fakes, then drives it through `route()` exactly -// as the CLI does, so one test covers parsing, middleware, the leaf handler, -// PaymentClient, and the rendered output. -// -// Record with: -// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/payment.test.tsx -// The read-only goldens use a manager that already exists in the test account. -// The write flow creates a manager named AgentCoreCliPaymentE2E, provisions its -// default service role, updates it, and deletes the manager again (the role is -// intentionally left in place, as the harness flow leaves its execution role). - const FIXTURES = join(import.meta.dir, "__fixtures__"); -const REGION = "us-west-2"; -const EXISTING_MANAGER_ID = "mypaymentmanager-o4ks3qfgtb"; -// The write flow records in a second region: the test account's us-west-2 -// payment-manager quota is used up by long-lived bug-bash managers. Fixtures are -// keyed by operation and input, not region, so the two regions never collide. -const WRITE_REGION = "us-east-1"; -// Fixtures are keyed by operation and input, so a `get` issued after the delete -// would overwrite the READY response the earlier readiness poll replays. Reads -// that expect the resource to be gone record into their own directory. -const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); +// The recorded manager lifecycle uses us-east-1; read fixtures use us-west-2. +const REGION = "us-east-1"; const E2E_NAME = "AgentCoreCliPaymentE2E"; -// Generous timeouts: in record mode, readiness polls wait on real control-plane -// transitions. Replay never sleeps. -const FLOW_TIMEOUT = 600_000; function createFixtureCore(fixtures = FIXTURES): CoreClient { - const { createControlClient, createDataClient, createIamClient, createLogsClient } = - fixtureFactories(fixtures); - return new CoreClient({ - createControlClient, - createDataClient, - createIamClient, - createLogsClient, - logger: createSilentLogger(), - }); + return new CoreClient({ ...fixtureFactories(fixtures), logger: createSilentLogger() }); } -function createRoot(core = createFixtureCore(), io = testIO()) { +async function run(args: string[], core = createFixtureCore(), io = testIO()): Promise { const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - return { root, io }; -} - -async function run(args: string[], region = REGION, fixtures = FIXTURES): Promise { - const { root, io } = createRoot(createFixtureCore(fixtures)); - await root.route(["node", "agentcore", ...args, "--region", region]); + await root.route(["node", "agentcore", "payment", "manager", ...args, "--region", REGION]); return io.stdout(); } -// pollUntil re-runs `command` until `done(parsed output)` is true. Polling only -// sleeps in record mode; in replay the fixture already holds the settled state -// (the last recorded poll), so the first read satisfies `done`. -async function pollUntil(command: string[], done: (output: any) => boolean): Promise { - for (let attempt = 0; attempt < 60; attempt++) { - const parsed = JSON.parse(await run(command, WRITE_REGION)); - if (done(parsed)) return; - if (!isRecording()) { - throw new Error( - `Replayed fixture for \`${command.join(" ")}\` is not in the awaited state; re-record.`, - ); - } - await Bun.sleep(5_000); - } - throw new Error(`Timed out waiting for \`${command.join(" ")}\``); -} - -// pollUntilGone re-runs a `get` until the service reports the resource missing. -async function pollUntilGone(command: string[]): Promise { - for (let attempt = 0; attempt < 60; attempt++) { - try { - await run(command, WRITE_REGION, AFTER_DELETE_FIXTURES); - } catch (error) { - if (/ResourceNotFound|not found/i.test((error as Error).message)) return; - throw error; - } - if (!isRecording()) { - throw new Error(`Replayed fixture for \`${command.join(" ")}\` still exists; re-record.`); - } - await Bun.sleep(5_000); - } - throw new Error(`Timed out waiting for \`${command.join(" ")}\` to disappear`); -} - -describe("payment command hierarchy", () => { - test("registers manager, connector, session, and instrument sub-routers", () => { - const { root } = createRoot(); - const payment = root.children().find((child) => child.name() === "payment"); - - expect(payment?.children().map((child) => child.name())).toEqual([ - "manager", - "connector", - "session", - "instrument", - ]); - expect( - payment - ?.children() - .find((child) => child.name() === "manager") - ?.children() - .map((child) => child.name()), - ).toEqual(["create", "get", "list", "update", "delete"]); - }); -}); - -describe("payment manager list", () => { - test("prints the listed payment managers as JSON", async () => { - const out = await run(["payment", "manager", "list", "--json"]); - matchGolden(FIXTURES, "manager-list.golden.json", out); - }); - - test("output is valid JSON containing a paymentManagers array", async () => { - const parsed = JSON.parse(await run(["payment", "manager", "list", "--json"])); - expect(Array.isArray(parsed.paymentManagers)).toBe(true); - }); -}); - -describe("payment manager get", () => { - test("prints the manager detail as JSON for a given id", async () => { - const out = await run(["payment", "manager", "get", "--id", EXISTING_MANAGER_ID]); - matchGolden(FIXTURES, "manager-get.golden.json", out); - expect(JSON.parse(out).paymentManagerId).toBe(EXISTING_MANAGER_ID); - }); - - test("errors when --id is omitted", async () => { - await expect(run(["payment", "manager", "get", "--id", ""])).rejects.toThrow(/--id/); - }); -}); - -describe("payment manager write validation", () => { +describe("payment manager write inputs", () => { test.each(["create", "update"] as const)( - "`%s` preserves explicit role and KMS references", + "%s preserves explicit references and JWT configuration from stdin", async (command) => { const factories = fixtureFactories(FIXTURES); - const sdk = mock(() => { - throw new Error("unexpected SDK client creation"); + const control = factories.createControlClient({ region: REGION }); + spyOn(control, "send").mockImplementation(async () => ({})); + const core = new CoreClient({ + ...factories, + createControlClient: () => control, + logger: createSilentLogger(), }); - for (const name of Object.keys(factories) as (keyof typeof factories)[]) { - spyOn(factories, name).mockImplementation(sdk); - } - const send = mock(async () => ({})); - spyOn(factories, "createControlClient").mockReturnValue({ - send, - } as unknown as BedrockAgentCoreControlClient); - const { root } = createRoot(new CoreClient({ ...factories, logger: createSilentLogger() })); + const call = spyOn( + core.payment, + command === "create" ? "createPaymentManager" : "updatePaymentManager", + ); const roleArn = "arn:aws:iam::123456789012:role/PaymentRole"; const kmsKeyArn = - "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"; - - await root.route([ - "node", - "agentcore", - "payment", - "manager", - command, - ...(command === "create" - ? ["--name", "ExplicitReferences"] - : ["--id", EXISTING_MANAGER_ID]), - "--role-arn", - roleArn, - "--kms-key-arn", - kmsKeyArn, - "--region", - REGION, - ]); + "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"; + const authorizerConfiguration = { + customJWTAuthorizer: { + discoveryUrl: "https://example.test/.well-known/openid-configuration", + }, + }; + + await run( + [ + command, + ...(command === "create" + ? ["--name", "ExplicitReferences", "--authorizer-type", "CUSTOM_JWT"] + : ["--id", "manager-1", "--description", ""]), + "--role-arn", + roleArn, + "--kms-key-arn", + kmsKeyArn, + "--authorizer-configuration", + "-", + "--client-token", + "token-1", + ], + core, + testIO({ stdin: JSON.stringify(authorizerConfiguration) }), + ); - expect(send).toHaveBeenCalledTimes(1); - expect(send).toHaveBeenCalledWith( - expect.objectContaining({ input: expect.objectContaining({ roleArn, kmsKeyArn }) }), + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + { + ...(command === "create" + ? { name: "ExplicitReferences", authorizerType: "CUSTOM_JWT" } + : { paymentManagerId: "manager-1", description: "" }), + roleArn, + kmsKeyArn, + authorizerConfiguration, + clientToken: "token-1", + }, + { region: REGION }, ); - expect(sdk).not.toHaveBeenCalled(); }, ); @@ -200,214 +95,87 @@ describe("payment manager write validation", () => { ["create", "kms-key-arn"], ["update", "role-arn"], ["update", "kms-key-arn"], - ] as const)("`%s` rejects empty --%s before Core or IO", async (command, flagName) => { - const factories = fixtureFactories(FIXTURES); - const sdk = mock(() => { - throw new Error("unexpected SDK client creation"); - }); - for (const name of Object.keys(factories) as (keyof typeof factories)[]) { - spyOn(factories, name).mockImplementation(sdk); - } - const core = new CoreClient({ ...factories, logger: createSilentLogger() }); + ] as const)("%s rejects empty --%s before Core or stdin", async (command, flag) => { + const core = createFixtureCore(); const call = spyOn( core.payment, command === "create" ? "createPaymentManager" : "updatePaymentManager", ); - const read = mock(() => { - throw new Error("unexpected stdin read"); - }); - const stdin = new Readable({ read }); - const io = testIO(); - io.io.stdin = stdin as NodeJS.ReadStream; - const { root } = createRoot(core, io); + const io = testIO({ stdin: "{}" }); - try { - await expect( - root.route([ - "node", - "agentcore", - "payment", - "manager", + await expect( + run( + [ command, - ...(command === "create" ? ["--name", "EmptyReference"] : ["--id", EXISTING_MANAGER_ID]), - `--${flagName}`, + ...(command === "create" ? ["--name", "EmptyReference"] : ["--id", "manager-1"]), + `--${flag}`, "", "--authorizer-type", "CUSTOM_JWT", "--authorizer-configuration", "-", - "--region", - REGION, - ]), - ).rejects.toThrow(`Invalid value for option '--${flagName}'`); - expect(call).not.toHaveBeenCalled(); - expect(sdk).not.toHaveBeenCalled(); - expect(read).not.toHaveBeenCalled(); - expect(io.stdout()).toBe(""); - expect(io.stderr()).toBe(""); - } finally { - call.mockRestore(); - stdin.destroy(); - } - }); - - // Each write leaf declares its identifying flags optional (so a bare - // invocation can fall through to the TUI once one exists) but requires them - // at runtime. None of these reach the SDK, so no fixtures are involved. - test("`create` errors when --name is omitted", async () => { - await expect(run(["payment", "manager", "create", "--name", ""])).rejects.toThrow(/--name/); - }); - - test("`create` requires --authorizer-configuration for CUSTOM_JWT", async () => { - await expect( - run(["payment", "manager", "create", "--name", "Jwt", "--authorizer-type", "CUSTOM_JWT"]), - ).rejects.toThrow(/CUSTOM_JWT requires --authorizer-configuration/); - }); - - test("`create` rejects --authorizer-configuration for AWS_IAM", async () => { - await expect( - run([ - "payment", - "manager", - "create", - "--name", - "Iam", - "--authorizer-configuration", - '{"customJWTAuthorizer":{"discoveryUrl":"https://example.test/.well-known/openid-configuration"}}', - ]), - ).rejects.toThrow(/valid only with CUSTOM_JWT/); + ], + core, + io, + ), + ).rejects.toThrow(`Invalid value for option '--${flag}'`); + expect(call).not.toHaveBeenCalled(); + expect(io.io.stdin.readableLength).toBe(2); + expect(io.stdout()).toBe(""); }); - test("`create` rejects a malformed --authorizer-configuration", async () => { + test("enforces JWT configuration combinations for create and update", async () => { await expect( - run([ - "payment", - "manager", - "create", - "--name", - "Jwt", - "--authorizer-type", - "CUSTOM_JWT", - "--authorizer-configuration", - "{not json", - ]), - ).rejects.toThrow(/Invalid JSON for option '--authorizer-configuration'/); - }); - - test("`create` rejects a malformed tag", async () => { + run(["create", "--name", "Jwt", "--authorizer-type", "CUSTOM_JWT"]), + ).rejects.toThrow("CUSTOM_JWT requires --authorizer-configuration"); await expect( - run(["payment", "manager", "create", "--name", "Tagged", "--tags", "novalue"]), - ).rejects.toThrow(/Invalid tag/); - }); - - test("`update` errors when --id is omitted", async () => { - await expect(run(["payment", "manager", "update", "--id", ""])).rejects.toThrow(/--id/); - }); - - test("`update` rejects --authorizer-configuration together with AWS_IAM", async () => { + run(["create", "--name", "Iam", "--authorizer-configuration", "{}"]), + ).rejects.toThrow("--authorizer-configuration is valid only with CUSTOM_JWT"); await expect( run([ - "payment", - "manager", "update", "--id", - "m-1", + "manager-1", "--authorizer-type", "AWS_IAM", "--authorizer-configuration", "{}", ]), - ).rejects.toThrow(/valid only with CUSTOM_JWT/); - }); - - test("`delete` errors when --id is omitted", async () => { - await expect(run(["payment", "manager", "delete", "--id", ""])).rejects.toThrow(/--id/); + ).rejects.toThrow("--authorizer-configuration is valid only with CUSTOM_JWT"); }); }); -// ─── write flow (create → update → delete) ─────────────────────────────────── -// -// Drives the lifecycle of a real payment manager, in order, through route(). -// In record mode it hits the live control plane (and IAM for the default -// service role) and persists every exchange; replays are offline and instant. -// Later tests consume the id parsed from earlier output. - -const state: { managerId?: string } = {}; - -describe("payment manager write flow", () => { - test( - "`create` provisions a default service role and creates the manager", - async () => { - const out = await run( - [ - "payment", - "manager", - "create", - "--name", - E2E_NAME, - "--description", - "Created by the agentcore CLI end-to-end test", - "--tags", - "created-by=agentcore-cli-e2e", - ], - WRITE_REGION, - ); - matchGolden(FIXTURES, "manager-create.golden.json", out); - - const parsed = JSON.parse(out); - expect(parsed.name).toBe(E2E_NAME); - expect(parsed.authorizerType).toBe("AWS_IAM"); - // No --role-arn was passed: the default service role was provisioned. - expect(parsed.roleArn).toContain(paymentServiceRoleName(E2E_NAME, WRITE_REGION)); - expect(parsed.paymentManagerId).toBeDefined(); - state.managerId = parsed.paymentManagerId; - - await pollUntil( - ["payment", "manager", "get", "--id", state.managerId!], - (o) => o.status === "READY", - ); - }, - FLOW_TIMEOUT, - ); - - test( - "`update` changes the description", - async () => { - const out = await run( - [ - "payment", - "manager", - "update", - "--id", - state.managerId!, - "--description", - "Updated by the agentcore CLI end-to-end test", - ], - WRITE_REGION, - ); - matchGolden(FIXTURES, "manager-update.golden.json", out); - expect(JSON.parse(out).paymentManagerId).toBe(state.managerId); - - await pollUntil( - ["payment", "manager", "get", "--id", state.managerId!], - (o) => o.status === "READY" && /Updated by/.test(o.description ?? ""), - ); - }, - FLOW_TIMEOUT, - ); - - test( - "`delete` deletes the manager", - async () => { - const out = await run( - ["payment", "manager", "delete", "--id", state.managerId!], - WRITE_REGION, - ); - matchGolden(FIXTURES, "manager-delete.golden.json", out); - expect(JSON.parse(out).status).toBe("DELETING"); - - await pollUntilGone(["payment", "manager", "get", "--id", state.managerId!]); - }, - FLOW_TIMEOUT, - ); +test("payment manager lifecycle replays default-role creation, update, and deletion through root/Core", async () => { + const created = await run([ + "create", + "--name", + E2E_NAME, + "--description", + "Created by the agentcore CLI end-to-end test", + "--tags", + "created-by=agentcore-cli-e2e", + ]); + matchGolden(FIXTURES, "manager-create.golden.json", created); + const manager = JSON.parse(created); + expect(manager.authorizerType).toBe("AWS_IAM"); + expect(manager.roleArn).toContain(paymentServiceRoleName(E2E_NAME, REGION)); + const scoped = ["--id", manager.paymentManagerId]; + expect(JSON.parse(await run(["get", ...scoped])).status).toBe("READY"); + + const description = "Updated by the agentcore CLI end-to-end test"; + const updated = await run(["update", ...scoped, "--description", description]); + matchGolden(FIXTURES, "manager-update.golden.json", updated); + expect(JSON.parse(await run(["get", ...scoped]))).toMatchObject({ + status: "READY", + description, + }); + + const deleted = await run(["delete", ...scoped]); + matchGolden(FIXTURES, "manager-delete.golden.json", deleted); + expect(JSON.parse(deleted).status).toBe("DELETING"); + + // The same Get request has a separate post-delete fixture. + await expect( + run(["get", ...scoped], createFixtureCore(join(FIXTURES, "after-delete"))), + ).rejects.toThrow(/ResourceNotFound|not found/i); }); diff --git a/src/handlers/payment/session/session.test.tsx b/src/handlers/payment/session/session.test.tsx index ac8020bba..87798aa6d 100644 --- a/src/handlers/payment/session/session.test.tsx +++ b/src/handlers/payment/session/session.test.tsx @@ -1,14 +1,5 @@ import { describe, expect, spyOn, test } from "bun:test"; import { join } from "node:path"; -import { - CreatePaymentSessionCommand, - type BedrockAgentCoreClient, - type CreatePaymentSessionRequest, -} from "@aws-sdk/client-bedrock-agentcore"; -import { - GetPaymentManagerCommand, - type BedrockAgentCoreControlClient, -} from "@aws-sdk/client-bedrock-agentcore-control"; import { CoreClient } from "../../../core"; import { createRootHandler } from "../../index"; import { @@ -21,417 +12,108 @@ import { } from "../../../testing"; import sessionCreateFixture from "../__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json"; -// End-to-end command-flow tests for the `payment session` leaves. -// -// Each test builds the real root handler over a real CoreClient whose SDK -// clients are the fixture-backed fakes, then drives it through `route()` exactly -// as the CLI does, so one test covers parsing, middleware, the leaf handler, -// PaymentClient, and the rendered output. -// -// Record with: -// RECORD=1 AWS_PROFILE=deploy bun test src/handlers/payment/session/session.test.tsx -// The flow uses an AWS_IAM payment manager that already exists in the test -// account (the manager quota is exhausted, so none is created here) and leaves -// nothing behind: it creates one session and deletes it again. - const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); const FIXTURES = join(PAYMENT_FIXTURES, "session"); -// Fixtures are keyed by operation and input, so a `get` issued after the delete -// would overwrite the pre-delete `get` fixture. Reads that expect the session to -// be gone record into their own directory. -const AFTER_DELETE_FIXTURES = join(FIXTURES, "after-delete"); const REGION = "us-west-2"; const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; -const MANAGER_ARN = - "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira"; const USER_ID = "agentcore-cli-e2e"; -const FLOW_TIMEOUT = 120_000; +const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; +const createArgs = ["create", ...scoped, "--expiry-minutes", "15"]; function createFixtureCore(fixtures = FIXTURES): CoreClient { - const { createControlClient, createIamClient, createLogsClient } = - fixtureFactories(PAYMENT_FIXTURES); - const { createDataClient } = fixtureFactories(fixtures); return new CoreClient({ - createControlClient, - createDataClient, - createIamClient, - createLogsClient, + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: fixtureFactories(fixtures).createDataClient, logger: createSilentLogger(), }); } -function createRoot(core = createFixtureCore()) { +async function run(args: string[], core = createFixtureCore()): Promise { const io = testIO(); const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); - return { root, io }; -} - -async function run(args: string[], fixtures = FIXTURES): Promise { - const { root, io } = createRoot(createFixtureCore(fixtures)); - await root.route(["node", "agentcore", ...args, "--region", REGION]); + await root.route(["node", "agentcore", "payment", "session", ...args, "--region", REGION]); return io.stdout(); } -const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; - -describe("payment session command hierarchy", () => { - test("registers create, get, list, and delete leaves", () => { - const { root } = createRoot(); - const session = root - .children() - .find((child) => child.name() === "payment") - ?.children() - .find((child) => child.name() === "session"); - - expect(session?.children().map((child) => child.name())).toEqual([ - "create", - "get", - "list", - "delete", - ]); - }); -}); - -describe("payment session validation", () => { - test.each(["create", "get", "list", "delete"])( - "`%s` rejects the removed --manager-arn flag, including alongside --manager-id", - async (command) => { - for (const idArgs of [[], scoped]) { - await expect( - run(["payment", "session", command, ...idArgs, "--manager-arn", MANAGER_ARN]), - ).rejects.toThrow(/unknown option '--manager-arn'/); - } - }, - ); - - test.each(["create", "get", "list", "delete"])( - "`%s` rejects an explicitly empty --manager-id", - async (command) => { - await expect( - run(["payment", "session", command, "--manager-id", "", "--user-id", USER_ID]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); - }, - ); - - // Every leaf declares its identifying flags optional (so a bare invocation can - // fall through to the TUI once one exists) but requires them at runtime. None - // of these reach the SDK, so no fixtures are involved. - test("`create` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "session", "create", "--user-id", USER_ID, "--expiry-minutes", "15"]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); - }); - - test("`create` errors when --user-id is omitted", async () => { - await expect( - run(["payment", "session", "create", "--manager-id", MANAGER_ID, "--expiry-minutes", "15"]), - ).rejects.toThrow(/required option '--user-id ' not specified/); - }); - - test("`create` errors when --expiry-minutes is omitted", async () => { - await expect(run(["payment", "session", "create", ...scoped])).rejects.toThrow( - /required option '--expiry-minutes ' not specified/, - ); - }); - - test("`create` rejects --expiry-minutes below 15", async () => { - await expect( - run(["payment", "session", "create", ...scoped, "--expiry-minutes", "14"]), - ).rejects.toThrow(/Invalid value for option '--expiry-minutes'/); - }); - - test("`create` rejects --expiry-minutes above 480", async () => { - await expect( - run(["payment", "session", "create", ...scoped, "--expiry-minutes", "481"]), - ).rejects.toThrow(/Invalid value for option '--expiry-minutes'/); - }); - - test("`create` rejects a fractional --expiry-minutes", async () => { - await expect( - run(["payment", "session", "create", ...scoped, "--expiry-minutes", "15.5"]), - ).rejects.toThrow(/Invalid value for option '--expiry-minutes'/); - }); - - test("`create` rejects --currency without --max-spend", async () => { - await expect( - run([ - "payment", - "session", - "create", - ...scoped, - "--expiry-minutes", - "15", - "--currency", - "USD", - ]), - ).rejects.toThrow(/--currency requires --max-spend/); - }); - - test.each(["", " \t\n "])( - "`create` rejects blank --max-spend %j before calling Core", - async (maxSpend) => { - const core = createFixtureCore(); - const createSession = spyOn(core.payment, "createPaymentSession").mockRejectedValue( - new Error("unexpected session creation during validation"), +describe("payment session create", () => { + test.each(["14", "481", "15.5"])( + "rejects expiry outside whole minutes 15..480: %s", + async (value) => { + await expect(run(["create", ...scoped, "--expiry-minutes", value])).rejects.toThrow( + "Invalid value for option '--expiry-minutes'", ); - try { - for (const currencyArgs of [[], ["--currency", "USD"]]) { - const { root } = createRoot(core); - await expect( - root.route([ - "node", - "agentcore", - "payment", - "session", - "create", - ...scoped, - "--expiry-minutes", - "15", - "--max-spend", - maxSpend, - ...currencyArgs, - "--region", - REGION, - ]), - ).rejects.toThrow("--max-spend must not be empty or whitespace"); - expect(createSession).not.toHaveBeenCalled(); - } - } finally { - createSession.mockRestore(); - } }, ); test.each([ - { label: "omitted", args: [], limits: undefined }, + { args: ["--currency", "USD"], error: "--currency requires --max-spend" }, + { args: ["--max-spend", ""], error: "--max-spend must not be empty or whitespace" }, { - label: "zero", - args: ["--max-spend", "0"], - limits: { maxSpendAmount: { value: "0", currency: "USD" } }, + args: ["--max-spend", " \t\n ", "--currency", "USD"], + error: "--max-spend must not be empty or whitespace", }, { - label: "exact decimal text", - args: ["--max-spend", "10.00"], - limits: { maxSpendAmount: { value: "10.00", currency: "USD" } }, + args: ["--max-spend", "1.00", "--currency", "EUR"], + error: "Invalid value for option '--currency'", }, - ])("`create` preserves $label --max-spend in the SDK request", async ({ args, limits }) => { - const requests: CreatePaymentSessionRequest[] = []; - const lookups: GetPaymentManagerCommand[] = []; - const core = new CoreClient({ - ...fixtureFactories(PAYMENT_FIXTURES), - createControlClient: () => - ({ - send: async (command: GetPaymentManagerCommand) => { - expect(command).toBeInstanceOf(GetPaymentManagerCommand); - lookups.push(command); - return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; - }, - }) as unknown as BedrockAgentCoreControlClient, - createDataClient: () => - ({ - send: async (command: CreatePaymentSessionCommand) => { - expect(command).toBeInstanceOf(CreatePaymentSessionCommand); - requests.push(command.input); - return parse(JSON.stringify(sessionCreateFixture)); - }, - }) as unknown as BedrockAgentCoreClient, - logger: createSilentLogger(), - }); - const { root, io } = createRoot(core); - await root.route([ - "node", - "agentcore", - "payment", - "session", - "create", - ...scoped, - "--expiry-minutes", - "15", - ...args, - "--region", - REGION, - ]); - expect(lookups).toHaveLength(1); - expect(lookups[0]?.input).toEqual({ paymentManagerId: MANAGER_ID }); - expect(requests).toHaveLength(1); - expect(requests[0]?.paymentManagerArn).toBe(MANAGER_ARN); - expect(requests[0]).not.toHaveProperty("managerId"); - expect(requests[0]?.limits).toEqual(limits); - expect(JSON.parse(io.stdout()).paymentSession.paymentSessionId).toBe( - sessionCreateFixture.paymentSession.paymentSessionId, - ); - }); - - test("`create` rejects an unsupported --currency", async () => { - await expect( - run([ - "payment", - "session", - "create", - ...scoped, - "--expiry-minutes", - "15", - "--max-spend", - "1.00", - "--currency", - "EUR", - ]), - ).rejects.toThrow(/Invalid value for option '--currency'/); - }); - - test("`get` errors when --session-id is omitted", async () => { - await expect(run(["payment", "session", "get", ...scoped])).rejects.toThrow( - /required option '--session-id ' not specified/, - ); - }); - - test("`get` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "session", "get", "--user-id", USER_ID, "--session-id", "s-1"]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); - }); - - test("`get` errors when --user-id is omitted", async () => { - await expect( - run(["payment", "session", "get", "--manager-id", MANAGER_ID, "--session-id", "s-1"]), - ).rejects.toThrow(/required option '--user-id ' not specified/); - }); - - test("`list` errors when --manager-id is omitted", async () => { - await expect(run(["payment", "session", "list", "--user-id", USER_ID])).rejects.toThrow( - /required option '--manager-id ' not specified/, - ); - }); - - test("`list` errors when --user-id is omitted", async () => { - await expect(run(["payment", "session", "list", "--manager-id", MANAGER_ID])).rejects.toThrow( - /required option '--user-id ' not specified/, - ); - }); - - test("`delete` errors when --session-id is omitted", async () => { - await expect(run(["payment", "session", "delete", ...scoped])).rejects.toThrow( - /required option '--session-id ' not specified/, - ); - }); - - test("`delete` errors when --manager-id is omitted", async () => { - await expect( - run(["payment", "session", "delete", "--user-id", USER_ID, "--session-id", "s-1"]), - ).rejects.toThrow(/required option '--manager-id ' not specified/); - }); - - test("`delete` errors when --user-id is omitted", async () => { - await expect( - run(["payment", "session", "delete", "--manager-id", MANAGER_ID, "--session-id", "s-1"]), - ).rejects.toThrow(/required option '--user-id ' not specified/); - }); -}); - -// ─── session flow (create → get → list → delete → get) ─────────────────────── -// -// Drives the lifecycle of a real payment session, in order, through route(). -// In record mode it hits the live data plane and persists every exchange; -// replays are offline and instant. Later tests consume the id parsed from -// earlier output. - -const state: { sessionId?: string } = {}; - -describe("payment session flow", () => { - test( - "`create` opens a session with a spend limit", - async () => { - const out = await run([ - "payment", - "session", - "create", - ...scoped, - "--expiry-minutes", - "15", - "--max-spend", - "1.00", - "--currency", - "USD", - ]); - matchGolden(FIXTURES, "session-create.golden.json", out); - - const { paymentSession } = JSON.parse(out); - expect(paymentSession.paymentSessionId).toBeDefined(); - expect(paymentSession.paymentManagerArn).toBe(MANAGER_ARN); - expect(paymentSession.userId).toBe(USER_ID); - expect(paymentSession.expiryTimeInMinutes).toBe(15); - expect(paymentSession.limits.maxSpendAmount.currency).toBe("USD"); - expect(Number(paymentSession.limits.maxSpendAmount.value)).toBe(1); - state.sessionId = paymentSession.paymentSessionId; - }, - FLOW_TIMEOUT, - ); - - test( - "`get` returns the session", - async () => { - const out = await run([ - "payment", - "session", - "get", - ...scoped, - "--session-id", - state.sessionId!, - ]); - matchGolden(FIXTURES, "session-get.golden.json", out); - expect(JSON.parse(out).paymentSession.paymentSessionId).toBe(state.sessionId); - }, - FLOW_TIMEOUT, - ); - - test( - "`list` includes the session", - async () => { - const out = await run(["payment", "session", "list", ...scoped]); - matchGolden(FIXTURES, "session-list.golden.json", out); - - const parsed = JSON.parse(out); - expect(Array.isArray(parsed.paymentSessions)).toBe(true); - expect( - parsed.paymentSessions.map( - (session: { paymentSessionId: string }) => session.paymentSessionId, - ), - ).toContain(state.sessionId); - }, - FLOW_TIMEOUT, - ); - - test( - "`delete` deletes the session", - async () => { - const out = await run([ - "payment", - "session", - "delete", - ...scoped, - "--session-id", - state.sessionId!, - ]); - matchGolden(FIXTURES, "session-delete.golden.json", out); - expect(JSON.parse(out).status).toBe("DELETED"); + ])("rejects invalid spend flags $args before Core", async ({ args, error }) => { + const core = createFixtureCore(); + const create = spyOn(core.payment, "createPaymentSession"); + await expect(run([...createArgs, ...args], core)).rejects.toThrow(error); + expect(create).not.toHaveBeenCalled(); + }); + + test.each([undefined, "0", "10.00"])( + "preserves spend %j without numeric coercion", + async (value) => { + const data = fixtureFactories(FIXTURES).createDataClient({ region: REGION }); + const send = spyOn(data, "send").mockResolvedValue( + parse(JSON.stringify(sessionCreateFixture)), + ); + const core = new CoreClient({ + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: () => data, + logger: createSilentLogger(), + }); + + await run([...createArgs, ...(value === undefined ? [] : ["--max-spend", value])], core); + + expect(send).toHaveBeenCalledTimes(1); + const request = send.mock.calls[0]![0].input; + if (value === undefined) { + expect(request).not.toHaveProperty("limits"); + } else { + expect(request).toHaveProperty("limits", { + maxSpendAmount: { value, currency: "USD" }, + }); + } }, - FLOW_TIMEOUT, ); +}); - test( - "`get` after delete reports the session missing", - async () => { - await expect( - run( - ["payment", "session", "get", ...scoped, "--session-id", state.sessionId!], - AFTER_DELETE_FIXTURES, - ), - ).rejects.toThrow(/ResourceNotFound|not found/i); - }, - FLOW_TIMEOUT, - ); +test("payment session lifecycle replays create, read-back, and delete through root/Core", async () => { + const created = await run([...createArgs, "--max-spend", "1.00", "--currency", "USD"]); + matchGolden(FIXTURES, "session-create.golden.json", created); + const { paymentSession } = JSON.parse(created); + expect(paymentSession.expiryTimeInMinutes).toBe(15); + expect(paymentSession.limits.maxSpendAmount.currency).toBe("USD"); + expect(Number(paymentSession.limits.maxSpendAmount.value)).toBe(1); + + const sessionArgs = [...scoped, "--session-id", paymentSession.paymentSessionId]; + const detail = await run(["get", ...sessionArgs]); + matchGolden(FIXTURES, "session-get.golden.json", detail); + expect(JSON.parse(detail).paymentSession.paymentSessionId).toBe(paymentSession.paymentSessionId); + + const deleted = await run(["delete", ...sessionArgs]); + matchGolden(FIXTURES, "session-delete.golden.json", deleted); + expect(JSON.parse(deleted).status).toBe("DELETED"); + + // The same Get request has a separate post-delete fixture. + await expect( + run(["get", ...sessionArgs], createFixtureCore(join(FIXTURES, "after-delete"))), + ).rejects.toThrow(/ResourceNotFound|not found/i); }); From 10e4dacf57859b02875903a167aeae3e602a33fb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 21:40:51 +0000 Subject: [PATCH 08/10] test(payment): remove platform-specific test subprocesses --- src/core/paymentServiceRole.test.ts | 34 +----- .../payment/connector/connector.test.tsx | 104 +++++------------- 2 files changed, 36 insertions(+), 102 deletions(-) diff --git a/src/core/paymentServiceRole.test.ts b/src/core/paymentServiceRole.test.ts index 6469e83f9..e8548edd2 100644 --- a/src/core/paymentServiceRole.test.ts +++ b/src/core/paymentServiceRole.test.ts @@ -1,9 +1,4 @@ import { expect, mock, test } from "bun:test"; -import { execFileSync } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; import { CreateRoleCommand, GetRoleCommand, @@ -32,6 +27,12 @@ test("prefixes the manager name and stays within IAM's 64-character cap", () => expect(longest.startsWith("AgentCorePayments-")).toBe(true); }); +test("uses a stable SHA-256 suffix across runtime distributions", () => { + expect(paymentServiceRoleName("x".repeat(48), REGION)).toBe( + "AgentCorePayments-us-west-2-xxxxxxxxxxxxxxxxxxxxxxx-c4e3d724a0b2", + ); +}); + // Truncating alone would let two long names share one role, and provisioning is // idempotent by name, so the second create would silently reuse the first's. test("keeps overflowing role names distinct", () => { @@ -50,29 +51,6 @@ test("uses distinct role names for the same manager in different regions", () => } }); -test("long role names work in the Node distribution", async () => { - const directory = await mkdtemp(join(tmpdir(), "payment-role-node-")); - try { - await Bun.build({ - entrypoints: [join(import.meta.dir, "paymentServiceRole.ts")], - target: "node", - outdir: directory, - naming: "role.mjs", - }); - const source = [ - `import { paymentServiceRoleName } from ${JSON.stringify(pathToFileURL(join(directory, "role.mjs")).href)};`, - `console.log(paymentServiceRoleName("x".repeat(48), "${REGION}"));`, - ].join("\n"); - const name = execFileSync("node", ["--input-type=module", "--eval", source], { - encoding: "utf8", - }).trim(); - expect(name).toHaveLength(64); - expect(name).toBe(paymentServiceRoleName("x".repeat(48), REGION)); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); - const ownershipTags = (region: string) => [ { Key: "agentcore:managed-by", Value: "agentcore-cli" }, { Key: "agentcore:payment-manager", Value: "Checkout" }, diff --git a/src/handlers/payment/connector/connector.test.tsx b/src/handlers/payment/connector/connector.test.tsx index db43bd48d..11712ca86 100644 --- a/src/handlers/payment/connector/connector.test.tsx +++ b/src/handlers/payment/connector/connector.test.tsx @@ -1,12 +1,6 @@ -import { describe, expect, mock, spyOn, test } from "bun:test"; -import { execFileSync } from "node:child_process"; +import { describe, expect, spyOn, test } from "bun:test"; import { join } from "node:path"; -import { - CreatePaymentConnectorCommand, - GetPaymentConnectorCommand, -} from "@aws-sdk/client-bedrock-agentcore-control"; import { CoreClient } from "../../../core"; -import type { ClientConfig } from "../../../core/types"; import { createRootHandler } from "../../index"; import { createSilentLogger, @@ -165,74 +159,36 @@ describe("payment connector Quick Create hints", () => { environment: "eu-west-1", endpoint: "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", }, - ])( - "follow-up preserves $label after the environment changes", - async ({ regionArgs, environment, endpoint }) => { - const savedRegion = process.env.AWS_REGION; - const factories = fixtureFactories(FIXTURES); - const getRequests: GetPaymentConnectorCommand["input"][] = []; - const createControlClient = mock((config: ClientConfig) => { - const client = factories.createControlClient(config); - spyOn(client, "send").mockImplementation(async (command) => { - if (command instanceof GetPaymentConnectorCommand) { - getRequests.push(command.input); - // The recorded Quick Create flow never completes OAuth consent. - return parse( - JSON.stringify({ - ...quickCreateFixture, - status: "READY", - lastUpdatedAt: quickCreateFixture.createdAt, - }), - ); - } - if (command instanceof CreatePaymentConnectorCommand) { - return parse(JSON.stringify(quickCreateFixture)); - } - throw new Error("Unexpected SDK command in hint test"); - }); - return client; - }); - const coreOptions = { ...factories, createControlClient, logger: createSilentLogger() }; - - try { - process.env.AWS_REGION = environment; - const created = await run(quickArgs, { - core: new CoreClient(coreOptions), - regionArgs: [...regionArgs], - }); - const command = created.stderr().match(/`(agentcore payment connector get [^`]+)`/)?.[1]; - expect(command).toBeDefined(); - // Parse the displayed command without invoking the installed CLI. - const argv = execFileSync("sh", ["-c", `set -- ${command}\nprintf '%s\\0' "$@"`], { - encoding: "utf8", - }) - .split("\0") - .slice(0, -1); - expect(argv.slice(0, 3)).toEqual(["agentcore", "payment", "connector"]); + ])("hint includes the resolved $label", async ({ regionArgs, environment, endpoint }) => { + const savedRegion = process.env.AWS_REGION; + const core = createFixtureCore(); + const create = spyOn(core.payment, "createPaymentConnector").mockResolvedValue( + parse(JSON.stringify(quickCreateFixture)), + ); - process.env.AWS_REGION = "us-east-1"; - const followUp = await run(argv.slice(3), { - core: new CoreClient(coreOptions), - regionArgs: [], - }); - expect(getRequests).toEqual([ - { - paymentManagerId: MANAGER_ID, - paymentConnectorId: quickCreateFixture.paymentConnectorId, - }, - ]); - expect(createControlClient.mock.calls).toEqual([ - [{ region: "eu-west-1", endpoint }], - [{ region: "eu-west-1", endpoint }], - ]); - expect(JSON.parse(followUp.stdout()).status).toBe("READY"); - if (endpoint === undefined) expect(command).not.toContain("--endpoint-url"); - } finally { - if (savedRegion === undefined) delete process.env.AWS_REGION; - else process.env.AWS_REGION = savedRegion; - } - }, - ); + try { + process.env.AWS_REGION = environment; + const created = await run(quickArgs, { + core, + regionArgs: [...regionArgs], + }); + const command = created.stderr().match(/`(agentcore payment connector get [^`]+)`/)?.[1]; + const endpointFlag = + endpoint === undefined + ? "" + : " --endpoint-url 'https://payments.example.test/control path?mode=quick&label=O'\\''Reilly#consent'"; + expect(command).toBe( + `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${quickCreateFixture.paymentConnectorId} --region eu-west-1${endpointFlag}`, + ); + expect(create.mock.calls[0]?.[1]).toEqual({ + region: "eu-west-1", + ...(endpoint === undefined ? {} : { endpointUrl: endpoint }), + }); + } finally { + if (savedRegion === undefined) delete process.env.AWS_REGION; + else process.env.AWS_REGION = savedRegion; + } + }); test("--json keeps the authorization URL in stdout without a stderr hint", async () => { const core = createFixtureCore(); From 4733f2be5f1e4a669cac1702b45a5ac4417ff2e2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 11 Sep 2026 14:16:27 +0000 Subject: [PATCH 09/10] refactor(payment): isolate identity mutations in first CUD layer --- README.md | 57 ++- src/core/harness.tsx | 23 +- src/core/index.tsx | 4 +- src/core/payment.read.test.ts | 8 +- src/core/payment.test.ts | 207 ----------- src/core/payment.tsx | 336 +----------------- src/core/paymentServiceRole.test.ts | 188 ---------- src/core/paymentServiceRole.ts | 155 -------- src/core/roleRetry.ts | 27 -- ...aymentManagerCommand.46791fae9fbbe940.json | 17 - .../CreateRoleCommand.4deb88176aa9ec28.json | 26 -- ...aymentManagerCommand.894895e0c24c9098.json | 4 - ...aymentManagerCommand.894895e0c24c9098.json | 18 - .../GetRoleCommand.6894a19eac9ccc52.json | 6 - ...PutRolePolicyCommand.5b1970701f13b039.json | 1 - ...aymentManagerCommand.d2e5471084d393e8.json | 14 - ...aymentManagerCommand.894895e0c24c9098.json | 6 - ...mentConnectorCommand.3a23138a2103205b.json | 12 - ...mentConnectorCommand.8ee89f4fdcbd5119.json | 17 - ...mentConnectorCommand.1c6bed13c7d0db3a.json | 4 - ...ymentConnectorCommand.9f8dfd59b8af870.json | 4 - ...ntialProviderCommand.9b6249ebbbb54d1a.json | 24 -- ...mentConnectorCommand.f54471b4c372f9aa.json | 17 - ...mentConnectorCommand.1c6bed13c7d0db3a.json | 6 - ...ymentConnectorCommand.9f8dfd59b8af870.json | 6 - .../connector/connector-create.golden.json | 15 - .../connector/connector-delete.golden.json | 4 - .../connector-quick-create.golden.json | 10 - .../connector-quick-delete.golden.json | 4 - .../connector/connector-update.golden.json | 15 - ...entInstrumentCommand.7b6d22c9eab936d3.json | 23 -- ...entInstrumentCommand.4b151101264cb8f8.json | 3 - ...entInstrumentCommand.1f6c9a8ae4328e61.json | 6 - .../instrument/instrument-create.golden.json | 19 - .../instrument/instrument-delete.golden.json | 3 - .../__fixtures__/manager-create.golden.json | 15 - .../__fixtures__/manager-delete.golden.json | 4 - .../__fixtures__/manager-update.golden.json | 12 - ...aymentSessionCommand.902bade07933ebb1.json | 29 -- ...aymentSessionCommand.86f3e58b4b886322.json | 3 - ...aymentSessionCommand.86f3e58b4b886322.json | 6 - .../session/session-create.golden.json | 23 -- .../session/session-delete.golden.json | 3 - .../payment/connector/connector.test.tsx | 267 -------------- .../payment/connector/create/index.tsx | 98 ----- .../payment/connector/delete/index.tsx | 38 -- src/handlers/payment/connector/get/index.tsx | 3 +- src/handlers/payment/connector/index.tsx | 8 +- .../payment/connector/update/index.tsx | 51 --- .../payment/instrument/create/index.tsx | 144 -------- .../payment/instrument/delete/index.tsx | 58 --- src/handlers/payment/instrument/index.tsx | 4 - .../payment/instrument/instrument.test.tsx | 169 --------- src/handlers/payment/manager/create/index.tsx | 86 ----- src/handlers/payment/manager/delete/index.tsx | 31 -- src/handlers/payment/manager/index.tsx | 8 +- src/handlers/payment/manager/update/index.tsx | 70 ---- src/handlers/payment/payment.read.test.tsx | 10 +- src/handlers/payment/payment.test.tsx | 201 ----------- src/handlers/payment/session/create/index.tsx | 88 ----- src/handlers/payment/session/delete/index.tsx | 45 --- src/handlers/payment/session/index.tsx | 6 +- src/handlers/payment/session/session.test.tsx | 119 ------- src/handlers/payment/types.tsx | 101 ------ src/testing/TestCoreClient.tsx | 40 --- 65 files changed, 55 insertions(+), 2974 deletions(-) delete mode 100644 src/core/payment.test.ts delete mode 100644 src/core/paymentServiceRole.test.ts delete mode 100644 src/core/paymentServiceRole.ts delete mode 100644 src/core/roleRetry.ts delete mode 100644 src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json delete mode 100644 src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json delete mode 100644 src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json delete mode 100644 src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json delete mode 100644 src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json delete mode 100644 src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json delete mode 100644 src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json delete mode 100644 src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json delete mode 100644 src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json delete mode 100644 src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json delete mode 100644 src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json delete mode 100644 src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json delete mode 100644 src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json delete mode 100644 src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json delete mode 100644 src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json delete mode 100644 src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json delete mode 100644 src/handlers/payment/__fixtures__/connector/connector-create.golden.json delete mode 100644 src/handlers/payment/__fixtures__/connector/connector-delete.golden.json delete mode 100644 src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json delete mode 100644 src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json delete mode 100644 src/handlers/payment/__fixtures__/connector/connector-update.golden.json delete mode 100644 src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json delete mode 100644 src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json delete mode 100644 src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json delete mode 100644 src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json delete mode 100644 src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json delete mode 100644 src/handlers/payment/__fixtures__/manager-create.golden.json delete mode 100644 src/handlers/payment/__fixtures__/manager-delete.golden.json delete mode 100644 src/handlers/payment/__fixtures__/manager-update.golden.json delete mode 100644 src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json delete mode 100644 src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json delete mode 100644 src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json delete mode 100644 src/handlers/payment/__fixtures__/session/session-create.golden.json delete mode 100644 src/handlers/payment/__fixtures__/session/session-delete.golden.json delete mode 100644 src/handlers/payment/connector/connector.test.tsx delete mode 100644 src/handlers/payment/connector/create/index.tsx delete mode 100644 src/handlers/payment/connector/delete/index.tsx delete mode 100644 src/handlers/payment/connector/update/index.tsx delete mode 100644 src/handlers/payment/instrument/create/index.tsx delete mode 100644 src/handlers/payment/instrument/delete/index.tsx delete mode 100644 src/handlers/payment/instrument/instrument.test.tsx delete mode 100644 src/handlers/payment/manager/create/index.tsx delete mode 100644 src/handlers/payment/manager/delete/index.tsx delete mode 100644 src/handlers/payment/manager/update/index.tsx delete mode 100644 src/handlers/payment/payment.test.tsx delete mode 100644 src/handlers/payment/session/create/index.tsx delete mode 100644 src/handlers/payment/session/delete/index.tsx delete mode 100644 src/handlers/payment/session/session.test.tsx diff --git a/README.md b/README.md index fe347637a..5a2844324 100644 --- a/README.md +++ b/README.md @@ -113,29 +113,19 @@ agentcore # interactive TUI │ │ └── list # list Rules under a Gateway │ └── policy │ └── generate # generate Cedar for a Gateway from a prompt (TUI when run bare) -├── payment # manage AgentCore Payments (command line only for now) +├── payment # inspect AgentCore Payments (command line only for now) │ ├── manager -│ │ ├── create # create a payment manager (auto-provisions a service role if none given) │ │ ├── get # get a payment manager by id -│ │ ├── list # list payment managers (server-side paginated) -│ │ ├── update # update a payment manager -│ │ └── delete # delete a payment manager (delete its connectors first) +│ │ └── list # list payment managers (server-side paginated) │ ├── connector # connectors under a payment manager -│ │ ├── create # create a connector from a credential provider, or --quick-create for Coinbase │ │ ├── get # get a connector (shows the Quick Create authorization URL while pending) -│ │ ├── list # list a manager's connectors -│ │ ├── update # update a connector's description or credential provider -│ │ └── delete # delete a connector +│ │ └── list # list a manager's connectors │ ├── session # budget-limited payment contexts (data plane) -│ │ ├── create # create a session with an expiry and optional spend limit │ │ ├── get -│ │ ├── list -│ │ └── delete +│ │ └── list │ └── instrument # embedded crypto wallets (data plane) -│ ├── create # create a wallet for a user on a connector │ ├── get │ ├── list -│ ├── delete │ └── balance # read token balance on an explicit chain (default token: USDC) ├── eval # evaluate and optimize AgentCore agents │ └── evaluator # manage AgentCore evaluators @@ -216,39 +206,32 @@ agentcore project invoke harness \ Use `--target` to select a deployment target. When a project declares exactly one resource of the requested type, `--name` may be omitted. -### Manage AgentCore Payments +### Inspect AgentCore Payments The `payment` commands call the Payments control and data planes directly, with -no project involved. A manager created without `--role-arn` gets a default -service role named `AgentCorePayments--` (long names have a stable -hash suffix). Default roles are tagged with their CLI owner, manager name, and -region; only matching roles are reused and have their service policy refreshed. -An unowned role with the same name is not modified. Use `--role-arn` to supply an -existing role, which the CLI never edits. Old regionless default roles are not -migrated automatically, and manager deletion does not delete IAM roles. - -Default role provisioning requires IAM role read/create, tagging, and inline -policy permissions, in addition to the service's role-passing requirements. -For centrally managed IAM policies or stricter per-credential permissions, -provision the service role separately and pass `--role-arn`. +no project involved. This command family currently provides read-only inspection +of existing managers, connectors, sessions, and instruments. It does not create IAM +roles. The separate `identity payment-credential-provider` commands can create, +inspect, replace, or delete stored Coinbase CDP and Stripe/Privy credentials. ```bash -# Create a manager, then a Coinbase connector through Quick Create. The create -# returns PENDING_AUTHENTICATION and an authorizationUrl: open it within ten -# minutes, then confirm the connector reached READY. -agentcore payment manager create --name Checkout -agentcore payment connector create --manager-id --name Coinbase --quick-create +# Inspect managers and their connectors. +agentcore payment manager list --json +agentcore payment manager get --id +agentcore payment connector list --manager-id agentcore payment connector get --manager-id --connector-id -# Or bring your own provider credentials, stored in AgentCore Identity, and -# reference the provider by name (its vendor selects the connector type). +# Inspect provider metadata stored in AgentCore Identity. +agentcore identity payment-credential-provider list --json +agentcore identity payment-credential-provider get --name + +# Store provider credentials from files, not inline command arguments. agentcore identity payment-credential-provider create --name cdp-creds --vendor CoinbaseCDP \ --api-key-id --api-key-secret file://api-key-secret.txt --wallet-secret file://wallet-secret.txt -agentcore payment connector create --manager-id --name Coinbase --credential-provider cdp-creds # Session and instrument commands take the parent manager ID and a user id. -agentcore payment session create --manager-id --user-id alice \ - --expiry-minutes 60 --max-spend 10.00 --currency USD +agentcore payment session list --manager-id --user-id alice +agentcore payment instrument list --manager-id --user-id alice # Check funding on one chain. USDC is the default token. agentcore payment instrument balance --manager-id --user-id alice \ diff --git a/src/core/harness.tsx b/src/core/harness.tsx index 5356ec0d3..0d88822ec 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -44,7 +44,6 @@ import { InputValidationError } from "../errors"; import type { AwsClients, CoreOptions } from "./types"; import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; -import { retryWhileRoleUnassumable } from "./roleRetry"; import { toClientConfig } from "./utils"; // HarnessClient implements the harness-facing operations on top of the shared AWS @@ -244,3 +243,25 @@ export function harnessRuntimeFromResponse( runtimeName: runtime.agentRuntimeName, }; } + +// retryWhileRoleUnassumable retries `operation` while it fails with the +// validation error AgentCore raises for an execution role it cannot yet assume +// (fresh IAM roles propagate over several seconds). Any other failure — or +// exhausting the attempts — rethrows. +async function retryWhileRoleUnassumable( + operation: () => Promise, + attempts = 8, + delayMs = 2000, +): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await operation(); + } catch (error) { + const retryable = + (error as Error).name === "ValidationException" && + /role|assume|trust/i.test((error as Error).message ?? ""); + if (!retryable || attempt >= attempts) throw error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +} diff --git a/src/core/index.tsx b/src/core/index.tsx index f9f6c21c8..bff250bd0 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -103,9 +103,7 @@ export class CoreClient implements AwsClients { ); this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" })); this.policy = new PolicyClient(this, this.logger.child({ module: "policy" })); - // Payment connectors resolve their credential provider through identity, so - // PaymentClient borrows the identity sub-client alongside the shared AWS clients. - this.payment = new PaymentClient(this, this.identity); + this.payment = new PaymentClient(this); // EvalClient shares the injected fetch: dataset content is served from a // presigned S3 URL, outside the SDK seam the other operations use. The logger // is used for batch-evaluation result-log diagnostics. diff --git a/src/core/payment.read.test.ts b/src/core/payment.read.test.ts index c824a541e..66ad240fa 100644 --- a/src/core/payment.read.test.ts +++ b/src/core/payment.read.test.ts @@ -27,14 +27,8 @@ function setup() { const data = mock( (_config: ClientConfig) => ({ send: dataSend }) as unknown as ReturnType, ); - const unexpected = () => { - throw new Error("Unexpected mutation dependency"); - }; return { - client: new PaymentClient( - { control, data, iam: unexpected }, - { getPaymentCredentialProvider: unexpected }, - ), + client: new PaymentClient({ control, data }), control, data, controlSend, diff --git a/src/core/payment.test.ts b/src/core/payment.test.ts deleted file mode 100644 index ed9d32a6f..000000000 --- a/src/core/payment.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { expect, mock, test } from "bun:test"; -import { - CreatePaymentManagerCommand, - GetPaymentConnectorCommand, - UpdatePaymentConnectorCommand, - type GetPaymentCredentialProviderResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { GetRoleCommand } from "@aws-sdk/client-iam"; -import { PaymentClient } from "./payment"; -import type { AwsClients } from "./types"; - -const options = { region: "us-west-2" }; -const ROLE_ARN = "arn:aws:iam::123456789012:role/AgentCorePayments-us-west-2-Checkout"; -const PROVIDER_ARN = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/paymentcredentialprovider/cdp-creds"; -const connector = { managerId: "manager", name: "Coinbase" }; -const provider = { - credentialProviderArn: PROVIDER_ARN, - credentialProviderVendor: "CoinbaseCDP", -} as GetPaymentCredentialProviderResponse; -type Send = (command: { input: unknown }) => Promise; - -function setup(controlSend: Send = async () => ({}), iamSend?: Send) { - const unexpected = () => { - throw new Error("Unexpected SDK call"); - }; - const send = mock(controlSend); - const identity = { getPaymentCredentialProvider: mock(async (_name: string) => provider) }; - const clients = { - control: () => ({ send }), - data: unexpected, - iam: iamSend ? () => ({ send: iamSend }) : unexpected, - } as unknown as AwsClients; - return { client: new PaymentClient(clients, identity), send, identity }; -} - -test("an explicit manager role bypasses IAM provisioning", async () => { - const { client, send } = setup(); - const input = { name: "Checkout", authorizerType: "AWS_IAM" as const, roleArn: ROLE_ARN }; - await client.createPaymentManager(input, options); - expect(send.mock.calls[0]?.[0]).toBeInstanceOf(CreatePaymentManagerCommand); - expect(send.mock.calls[0]?.[0].input).toEqual(input); -}); - -test("a caller's access denial is not mistaken for service-role propagation", async () => { - const error = Object.assign( - new Error( - "User: arn:aws:sts::123456789012:assumed-role/Admin/session is not authorized to perform: bedrock-agentcore:CreatePaymentManager", - ), - { name: "AccessDeniedException" }, - ); - const { client, send } = setup( - async () => { - throw error; - }, - async (command) => - command instanceof GetRoleCommand - ? { - Role: { - Arn: ROLE_ARN, - Tags: [ - { Key: "agentcore:managed-by", Value: "agentcore-cli" }, - { Key: "agentcore:payment-manager", Value: "Checkout" }, - { Key: "agentcore:region", Value: options.region }, - ], - }, - } - : {}, - ); - await expect( - client.createPaymentManager({ name: "Checkout", authorizerType: "AWS_IAM" }, options), - ).rejects.toBe(error); - expect(send).toHaveBeenCalledTimes(1); -}); - -test("IAM receives explicit credentials but not the AgentCore endpoint override", async () => { - const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; - const iam = mock(() => { - throw new Error("captured IAM configuration"); - }); - const control = mock(() => ({ send: async () => ({}) })); - const client = new PaymentClient({ iam, control } as unknown as AwsClients, { - getPaymentCredentialProvider: async () => provider, - }); - await expect( - client.createPaymentManager( - { name: "Checkout", authorizerType: "AWS_IAM" }, - { ...options, endpointUrl: "https://payments.example.test", credentials }, - ), - ).rejects.toThrow("captured IAM configuration"); - expect(iam).toHaveBeenCalledWith({ ...options, credentials }); - expect(control).toHaveBeenCalledWith({ - ...options, - endpoint: "https://payments.example.test", - credentials, - }); -}); - -test("a named provider supplies its ARN and vendor; a conflicting vendor is rejected", async () => { - const { client, send, identity } = setup(); - await client.createPaymentConnector({ ...connector, credentialProvider: "cdp-creds" }, options); - expect(identity.getPaymentCredentialProvider).toHaveBeenCalledWith("cdp-creds", options); - expect(send.mock.calls[0]?.[0].input).toEqual({ - paymentManagerId: "manager", - name: "Coinbase", - type: "CoinbaseCDP", - credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }], - provisionMode: undefined, - }); - await expect( - client.createPaymentConnector( - { - ...connector, - credentialProvider: "cdp-creds", - type: "StripePrivy", - }, - options, - ), - ).rejects.toThrow("cannot back a StripePrivy connector"); - expect(send).toHaveBeenCalledTimes(1); -}); - -test("a provider ARN requires a vendor and skips name resolution", async () => { - const { client, send, identity } = setup(); - await expect( - client.createPaymentConnector( - { - ...connector, - credentialProvider: PROVIDER_ARN, - }, - options, - ), - ).rejects.toThrow("--type is required"); - await client.createPaymentConnector( - { - ...connector, - credentialProvider: PROVIDER_ARN, - type: "StripePrivy", - }, - options, - ); - expect(send.mock.calls[0]?.[0].input).toMatchObject({ - type: "StripePrivy", - credentialProviderConfigurations: [{ stripePrivy: { credentialProviderArn: PROVIDER_ARN } }], - }); - expect(identity.getPaymentCredentialProvider).not.toHaveBeenCalled(); -}); - -test("Quick Create rejects vendors other than Coinbase before sending a request", async () => { - const { client, send } = setup(); - await expect( - client.createPaymentConnector( - { - ...connector, - quickCreate: true, - type: "StripePrivy", - }, - options, - ), - ).rejects.toThrow("Quick Create is available only for CoinbaseCDP"); - expect(send).not.toHaveBeenCalled(); -}); - -test("connector updates resolve replacement credentials but preserve omitted credentials", async () => { - const { client, send, identity } = setup(async (command) => - command instanceof GetPaymentConnectorCommand ? { type: "CoinbaseCDP" } : {}, - ); - const input = { managerId: "manager", connectorId: "connector", description: "updated" }; - await client.updatePaymentConnector({ ...input, credentialProvider: "cdp-creds" }, options); - expect(send.mock.calls[0]?.[0]).toMatchObject({ - input: { paymentManagerId: "manager", paymentConnectorId: "connector" }, - }); - expect(send.mock.calls[1]?.[0]).toBeInstanceOf(UpdatePaymentConnectorCommand); - expect(send.mock.calls[1]?.[0].input).toEqual({ - paymentManagerId: "manager", - paymentConnectorId: "connector", - description: "updated", - credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }], - clientToken: undefined, - }); - await client.updatePaymentConnector(input, options); - expect(send).toHaveBeenCalledTimes(3); - expect(send.mock.calls[2]?.[0]).toBeInstanceOf(UpdatePaymentConnectorCommand); - expect(send.mock.calls[2]?.[0].input).toEqual({ - paymentManagerId: "manager", - paymentConnectorId: "connector", - description: "updated", - credentialProviderConfigurations: undefined, - clientToken: undefined, - }); - expect(identity.getPaymentCredentialProvider).toHaveBeenCalledTimes(1); -}); - -test("Marketplace errors retain the subscription URL and product name", async () => { - const error = Object.assign(new Error("Subscription required"), { - name: "SubscriptionRequiredException", - subscriptionUrl: "https://aws.amazon.com/marketplace/pp/prodview-example", - productName: "Coinbase Wallets", - }); - const { client } = setup(async () => { - throw error; - }); - const result = client.createPaymentConnector({ ...connector, quickCreate: true }, options); - await expect(result).rejects.toThrow("Coinbase Wallets"); - await expect(result).rejects.toThrow(error.subscriptionUrl); - await expect(result).rejects.toMatchObject({ cause: error, name: error.name }); -}); diff --git a/src/core/payment.tsx b/src/core/payment.tsx index 1460ac674..e9ac0affe 100644 --- a/src/core/payment.tsx +++ b/src/core/payment.tsx @@ -1,124 +1,46 @@ import { - CreatePaymentConnectorCommand, - CreatePaymentManagerCommand, - DeletePaymentConnectorCommand, - DeletePaymentManagerCommand, GetPaymentConnectorCommand, GetPaymentManagerCommand, ListPaymentConnectorsCommand, ListPaymentManagersCommand, - UpdatePaymentConnectorCommand, - UpdatePaymentManagerCommand, - type CreatePaymentConnectorResponse, - type CreatePaymentManagerResponse, - type CredentialsProviderConfiguration, - type DeletePaymentConnectorRequest, - type DeletePaymentConnectorResponse, - type DeletePaymentManagerRequest, - type DeletePaymentManagerResponse, type GetPaymentConnectorResponse, type GetPaymentManagerResponse, type ListPaymentConnectorsResponse, type ListPaymentManagersResponse, - type PaymentConnectorType, - type UpdatePaymentConnectorResponse, - type UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { - CreatePaymentInstrumentCommand, - CreatePaymentSessionCommand, - DeletePaymentInstrumentCommand, - DeletePaymentSessionCommand, GetPaymentInstrumentBalanceCommand, GetPaymentInstrumentCommand, GetPaymentSessionCommand, ListPaymentInstrumentsCommand, ListPaymentSessionsCommand, type BedrockAgentCoreClient, - type CreatePaymentInstrumentResponse, - type CreatePaymentSessionResponse, - type DeletePaymentInstrumentResponse, - type DeletePaymentSessionResponse, type GetPaymentInstrumentResponse, type GetPaymentInstrumentBalanceResponse, type GetPaymentSessionResponse, type ListPaymentInstrumentsResponse, type ListPaymentSessionsResponse, } from "@aws-sdk/client-bedrock-agentcore"; -import { - AgentCoreCLIError, - ERROR_SOURCE, - InputValidationError, - MalformedServiceResponseError, -} from "../errors"; -import type { CoreIdentityClient } from "../handlers/identity/types"; +import { InputValidationError, MalformedServiceResponseError } from "../errors"; import type { CorePaymentClient, - CreatePaymentConnectorInput, - CreatePaymentManagerInput, - CreatePaymentSessionInput, GetPaymentSessionInput, ListPaymentSessionsInput, - DeletePaymentSessionInput, - CreatePaymentInstrumentInput, GetPaymentInstrumentInput, GetPaymentInstrumentBalanceInput, ListPaymentInstrumentsInput, - DeletePaymentInstrumentInput, - UpdatePaymentConnectorInput, - UpdatePaymentManagerInput, } from "../handlers/payment/types"; -import { ensurePaymentServiceRole } from "./paymentServiceRole"; -import { isRoleUnassumableValidation, retryWhileRoleUnassumable } from "./roleRetry"; import type { AwsClients, CoreOptions } from "./types"; import { toClientConfig } from "./utils"; -const QUICK_CREATE_TYPE: PaymentConnectorType = "CoinbaseCDP"; - // PaymentClient implements the payment-facing operations on top of the shared // AWS clients provided by CoreClient. Managers and connectors live on the control // plane; sessions and instruments on the data plane. export class PaymentClient implements CorePaymentClient { - constructor( - private readonly clients: Pick, - // Payment credential providers live in AgentCore Identity. Connector create - // and update resolve a provider name to its ARN and vendor through the - // identity client rather than re-implementing that lookup here. - private readonly identity: Pick, - ) {} + constructor(private readonly clients: Pick) {} // ─── payment managers ─────────────────────────────────────────────────────── - async createPaymentManager( - input: CreatePaymentManagerInput, - options: CoreOptions, - ): Promise { - const control = this.clients.control(toClientConfig(options)); - const { roleArn, ...request } = input; - if (roleArn) { - return control.send(new CreatePaymentManagerCommand({ ...request, roleArn })); - } - - // No role supplied: provision (or reuse) the default service role, then - // create the manager with it. IAM is eventually consistent — a role created - // moments ago may not yet be assumable by the service principal — so retry - // the create while the service reports the role as unusable. - const defaultRoleArn = await ensurePaymentServiceRole( - // IAM is a global service; the region only selects the endpoint, and the - // agentcore endpoint override must not leak onto it. - this.clients.iam({ - region: options.region, - ...(options.credentials ? { credentials: options.credentials } : {}), - }), - input.name!, - options.region, - ); - return retryWhileRoleUnassumable( - () => control.send(new CreatePaymentManagerCommand({ ...request, roleArn: defaultRoleArn })), - isServiceRoleUnusable(defaultRoleArn), - ); - } - async getPaymentManager(id: string, options: CoreOptions): Promise { return this.clients .control(toClientConfig(options)) @@ -135,51 +57,8 @@ export class PaymentClient implements CorePaymentClient { .send(new ListPaymentManagersCommand({ nextToken, maxResults })); } - async updatePaymentManager( - input: UpdatePaymentManagerInput, - options: CoreOptions, - ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new UpdatePaymentManagerCommand({ ...input })); - } - - async deletePaymentManager( - request: DeletePaymentManagerRequest, - options: CoreOptions, - ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new DeletePaymentManagerCommand({ ...request })); - } - // ─── payment connectors ───────────────────────────────────────────────────── - async createPaymentConnector( - input: CreatePaymentConnectorInput, - options: CoreOptions, - ): Promise { - const { type, credentialProviderConfigurations } = await this.resolveConnectorCredentials( - input, - options, - ); - try { - return await this.clients.control(toClientConfig(options)).send( - new CreatePaymentConnectorCommand({ - paymentManagerId: input.managerId, - name: input.name, - ...(input.description !== undefined ? { description: input.description } : {}), - type, - credentialProviderConfigurations, - provisionMode: input.quickCreate ? "QUICK_CREATE" : undefined, - ...(input.clientToken !== undefined ? { clientToken: input.clientToken } : {}), - }), - ); - } catch (error) { - throw subscriptionRequired(error); - } - } - async getPaymentConnector( managerId: string, connectorId: string, @@ -206,72 +85,8 @@ export class PaymentClient implements CorePaymentClient { ); } - async updatePaymentConnector( - input: UpdatePaymentConnectorInput, - options: CoreOptions, - ): Promise { - const control = this.clients.control(toClientConfig(options)); - - // A replacement credential provider has to land in the union member matching - // the connector's type, and the type is not changeable, so read it first. - let credentialProviderConfigurations: CredentialsProviderConfiguration[] | undefined; - if (input.credentialProvider !== undefined) { - const current = await control.send( - new GetPaymentConnectorCommand({ - paymentManagerId: input.managerId, - paymentConnectorId: input.connectorId, - }), - ); - if (!current.type) { - throw new AgentCoreCLIError( - `payment connector "${input.connectorId}" returned no type; cannot choose a credential configuration`, - { source: ERROR_SOURCE.SERVICE }, - ); - } - const resolved = await this.resolveCredentialProvider( - input.credentialProvider, - current.type, - options, - ); - credentialProviderConfigurations = [credentialConfiguration(current.type, resolved.arn)]; - } - - try { - return await control.send( - new UpdatePaymentConnectorCommand({ - paymentManagerId: input.managerId, - paymentConnectorId: input.connectorId, - description: input.description, - credentialProviderConfigurations, - clientToken: input.clientToken, - }), - ); - } catch (error) { - throw subscriptionRequired(error); - } - } - - async deletePaymentConnector( - request: DeletePaymentConnectorRequest, - options: CoreOptions, - ): Promise { - return this.clients - .control(toClientConfig(options)) - .send(new DeletePaymentConnectorCommand({ ...request })); - } - // ─── payment sessions (data plane) ────────────────────────────────────────── - async createPaymentSession( - input: CreatePaymentSessionInput, - options: CoreOptions, - ): Promise { - const { managerId, ...request } = input; - return this.sendData(managerId, options, (data, paymentManagerArn) => - data.send(new CreatePaymentSessionCommand({ paymentManagerArn, ...request })), - ); - } - async getPaymentSession( input: GetPaymentSessionInput, options: CoreOptions, @@ -292,28 +107,8 @@ export class PaymentClient implements CorePaymentClient { ); } - async deletePaymentSession( - input: DeletePaymentSessionInput, - options: CoreOptions, - ): Promise { - const { managerId, ...request } = input; - return this.sendData(managerId, options, (data, paymentManagerArn) => - data.send(new DeletePaymentSessionCommand({ paymentManagerArn, ...request })), - ); - } - // ─── payment instruments (data plane) ─────────────────────────────────────── - async createPaymentInstrument( - input: CreatePaymentInstrumentInput, - options: CoreOptions, - ): Promise { - const { managerId, ...request } = input; - return this.sendData(managerId, options, (data, paymentManagerArn) => - data.send(new CreatePaymentInstrumentCommand({ paymentManagerArn, ...request })), - ); - } - async getPaymentInstrument( input: GetPaymentInstrumentInput, options: CoreOptions, @@ -344,90 +139,8 @@ export class PaymentClient implements CorePaymentClient { ); } - async deletePaymentInstrument( - input: DeletePaymentInstrumentInput, - options: CoreOptions, - ): Promise { - const { managerId, ...request } = input; - return this.sendData(managerId, options, (data, paymentManagerArn) => - data.send(new DeletePaymentInstrumentCommand({ paymentManagerArn, ...request })), - ); - } - // ─── helpers ──────────────────────────────────────────────────────────────── - private async resolveConnectorCredentials( - input: Pick, - options: CoreOptions, - ): Promise<{ - type: PaymentConnectorType; - credentialProviderConfigurations: CredentialsProviderConfiguration[]; - }> { - if (input.quickCreate && input.credentialProvider !== undefined) { - throw new InputValidationError( - "Quick Create and a credential provider are mutually exclusive; specify one", - ); - } - if (input.quickCreate) { - const type = input.type ?? QUICK_CREATE_TYPE; - if (type !== QUICK_CREATE_TYPE) { - throw new InputValidationError( - `Quick Create is available only for ${QUICK_CREATE_TYPE} connectors, not ${type}`, - ); - } - return { type, credentialProviderConfigurations: [] }; - } - if (input.credentialProvider === undefined) { - throw new InputValidationError( - "a payment connector needs a credential provider, or Quick Create for CoinbaseCDP", - ); - } - const resolved = await this.resolveCredentialProvider( - input.credentialProvider, - input.type, - options, - ); - return { - type: resolved.type, - credentialProviderConfigurations: [credentialConfiguration(resolved.type, resolved.arn)], - }; - } - - // resolveCredentialProvider turns a provider reference into an ARN plus the - // connector type it backs. An ARN carries no vendor, so the type must come from - // the caller; a name is looked up in identity and its vendor is the type, - // which an explicit type must agree with. - private async resolveCredentialProvider( - reference: string, - type: PaymentConnectorType | undefined, - options: CoreOptions, - ): Promise<{ arn: string; type: PaymentConnectorType }> { - if (reference.startsWith("arn:")) { - if (!type) { - throw new InputValidationError( - "--type is required when --credential-provider is an ARN (the ARN does not name the vendor)", - ); - } - return { arn: reference, type }; - } - - const provider = await this.identity.getPaymentCredentialProvider(reference, options); - const arn = provider.credentialProviderArn; - const vendor = provider.credentialProviderVendor as PaymentConnectorType | undefined; - if (!arn || !vendor) { - throw new AgentCoreCLIError( - `payment credential provider "${reference}" returned no ARN or vendor`, - { source: ERROR_SOURCE.SERVICE }, - ); - } - if (type && type !== vendor) { - throw new InputValidationError( - `credential provider "${reference}" is a ${vendor} provider and cannot back a ${type} connector`, - ); - } - return { arn, type: vendor }; - } - private async sendData( managerId: string, options: CoreOptions, @@ -450,48 +163,3 @@ export class PaymentClient implements CorePaymentClient { return send(this.clients.data(toClientConfig(options)), manager.paymentManagerArn); } } - -function credentialConfiguration( - type: PaymentConnectorType, - credentialProviderArn: string, -): CredentialsProviderConfiguration { - return type === "CoinbaseCDP" - ? { coinbaseCDP: { credentialProviderArn } } - : { stripePrivy: { credentialProviderArn } }; -} - -// isServiceRoleUnusable widens the harness predicate: the payments control plane -// assumes the role during the create itself, so a not-yet-propagated role can -// also surface as an access-denied failure. Only an access denial that names the -// provisioned role counts; a caller's own permission denial also says -// "assumed-role/... is not authorized" and must surface immediately. -function isServiceRoleUnusable(roleArn: string): (error: Error) => boolean { - const roleName = roleArn.split("/").pop() ?? roleArn; - return (error) => - isRoleUnassumableValidation(error) || - (error.name === "AccessDeniedException" && - ((error.message ?? "").includes(roleArn) || (error.message ?? "").includes(roleName))); -} - -// Connector creation and updates fail with SubscriptionRequiredException when the -// account has not subscribed to the provider's AWS Marketplace listing. The SDK -// error carries the listing URL and product name; surface both so the fix is one -// click away instead of a support search. -function subscriptionRequired(error: unknown): unknown { - if (!(error instanceof Error) || error.name !== "SubscriptionRequiredException") return error; - const { subscriptionUrl, productName } = error as Error & { - subscriptionUrl?: string; - productName?: string; - }; - const product = productName ? ` to "${productName}"` : ""; - const where = subscriptionUrl ? ` Subscribe at ${subscriptionUrl}, then retry.` : ""; - return new AgentCoreCLIError( - `${error.message} An active AWS Marketplace subscription${product} is required.${where}`, - { - cause: error, - source: ERROR_SOURCE.USER, - name: error.name, - meta: { subscriptionUrl, productName }, - }, - ); -} diff --git a/src/core/paymentServiceRole.test.ts b/src/core/paymentServiceRole.test.ts deleted file mode 100644 index e8548edd2..000000000 --- a/src/core/paymentServiceRole.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { expect, mock, test } from "bun:test"; -import { - CreateRoleCommand, - GetRoleCommand, - PutRolePolicyCommand, - type IAMClient, -} from "@aws-sdk/client-iam"; -import { - ensurePaymentServiceRole, - paymentServiceRoleName, - servicePolicy, - trustPolicy, -} from "./paymentServiceRole"; - -const REGION = "us-west-2"; -const ACCOUNT = "123456789012"; - -function statements(policy: string): { Sid?: string; Action?: unknown; Resource?: unknown }[] { - return JSON.parse(policy).Statement; -} - -test("prefixes the manager name and stays within IAM's 64-character cap", () => { - expect(paymentServiceRoleName("Checkout", REGION)).toBe("AgentCorePayments-us-west-2-Checkout"); - - const longest = paymentServiceRoleName("a".repeat(48), REGION); - expect(longest.length).toBe(64); - expect(longest.startsWith("AgentCorePayments-")).toBe(true); -}); - -test("uses a stable SHA-256 suffix across runtime distributions", () => { - expect(paymentServiceRoleName("x".repeat(48), REGION)).toBe( - "AgentCorePayments-us-west-2-xxxxxxxxxxxxxxxxxxxxxxx-c4e3d724a0b2", - ); -}); - -// Truncating alone would let two long names share one role, and provisioning is -// idempotent by name, so the second create would silently reuse the first's. -test("keeps overflowing role names distinct", () => { - const a = paymentServiceRoleName("x".repeat(44) + "AAAA", REGION); - const b = paymentServiceRoleName("x".repeat(44) + "BBBB", REGION); - expect(a.length).toBeLessThanOrEqual(64); - expect(b.length).toBeLessThanOrEqual(64); - expect(a).not.toBe(b); -}); - -test("uses distinct role names for the same manager in different regions", () => { - for (const name of ["Checkout", "x".repeat(48)]) { - expect(paymentServiceRoleName(name, "us-east-1")).not.toBe( - paymentServiceRoleName(name, "us-west-2"), - ); - } -}); - -const ownershipTags = (region: string) => [ - { Key: "agentcore:managed-by", Value: "agentcore-cli" }, - { Key: "agentcore:payment-manager", Value: "Checkout" }, - { Key: "agentcore:region", Value: region }, -]; - -test("creates a tagged default role and grants its regional policy", async () => { - const send = mock(async (command: unknown) => { - if (command instanceof GetRoleCommand) { - throw Object.assign(new Error("not found"), { name: "NoSuchEntityException" }); - } - if (command instanceof CreateRoleCommand) { - expect(command.input.Tags).toEqual(ownershipTags(REGION)); - expect(command.input.RoleName).toBe("AgentCorePayments-us-west-2-Checkout"); - return { Role: { Arn: `arn:aws:iam::${ACCOUNT}:role/${command.input.RoleName}` } }; - } - expect(command).toBeInstanceOf(PutRolePolicyCommand); - return {}; - }); - const arn = await ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION); - expect(arn).toBe(`arn:aws:iam::${ACCOUNT}:role/AgentCorePayments-us-west-2-Checkout`); - expect(send).toHaveBeenCalledTimes(3); -}); - -test("reusing an owned role in another region cannot overwrite the first region's policy", async () => { - const policies = new Map(); - let region = "us-east-1"; - const send = mock(async (command: unknown) => { - if (command instanceof GetRoleCommand) { - return { - Role: { - Arn: `arn:aws:iam::${ACCOUNT}:role/${command.input.RoleName}`, - Tags: ownershipTags(region), - }, - }; - } - expect(command).toBeInstanceOf(PutRolePolicyCommand); - const { RoleName, PolicyName, PolicyDocument } = (command as PutRolePolicyCommand).input; - policies.set(`${RoleName}/${PolicyName}`, PolicyDocument!); - return {}; - }); - const iam = { send } as unknown as IAMClient; - await ensurePaymentServiceRole(iam, "Checkout", region); - region = "us-west-2"; - await ensurePaymentServiceRole(iam, "Checkout", region); - expect(policies.size).toBe(2); - expect([...policies.values()]).toEqual([ - servicePolicy("us-east-1", ACCOUNT), - servicePolicy("us-west-2", ACCOUNT), - ]); -}); - -test.each([ - { Tags: undefined }, - { Tags: [] }, - { Tags: [{ Key: "agentcore:managed-by", Value: "another-tool" }] }, - { Tags: ownershipTags("us-east-1") }, - { - Tags: ownershipTags(REGION).map((tag) => - tag.Key === "agentcore:payment-manager" ? { ...tag, Value: "OtherManager" } : tag, - ), - }, -])("refuses a role without matching ownership tags: %j", async ({ Tags }) => { - const send = mock(async (command: unknown) => { - if (command instanceof GetRoleCommand) { - return { - Role: { Arn: `arn:aws:iam::${ACCOUNT}:role/default-role`, Tags }, - }; - } - throw new Error("must not mutate an unrelated role"); - }); - await expect( - ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION), - ).rejects.toThrow(/--role-arn/); - expect(send).toHaveBeenCalledTimes(1); -}); - -test("a caller's GetRole denial is surfaced without attempting creation", async () => { - const error = Object.assign(new Error("denied"), { name: "AccessDeniedException" }); - const send = mock(async () => { - throw error; - }); - await expect( - ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION), - ).rejects.toBe(error); - expect(send).toHaveBeenCalledTimes(1); -}); - -test("trusts the AgentCore service principal", () => { - const statement = JSON.parse(trustPolicy()).Statement[0]; - expect(statement.Effect).toBe("Allow"); - expect(statement.Principal).toEqual({ Service: "bedrock-agentcore.amazonaws.com" }); - expect(statement.Action).toBe("sts:AssumeRole"); -}); - -// The action list mirrors the ResourceRetrievalRole the L3 CDK construct grants: -// the service assumes this role to mint workload tokens, read the connector's -// credential provider, and fetch payment tokens for every data-plane call. -test("grants the identity, workload token, and payment token actions", () => { - const identity = statements(servicePolicy(REGION, ACCOUNT)).find( - (s) => s.Sid === "AgentCoreIdentityAndTokens", - ); - expect(identity?.Action).toEqual([ - "bedrock-agentcore:RetrieveToken", - "bedrock-agentcore:GetWorkloadIdentity", - "bedrock-agentcore:CreateWorkloadIdentity", - "bedrock-agentcore:GetPaymentCredentialProvider", - "bedrock-agentcore:TagResource", - "bedrock-agentcore:GetWorkloadAccessToken", - "bedrock-agentcore:GetWorkloadAccessTokenForUserId", - "bedrock-agentcore:GetWorkloadAccessTokenForJWT", - "bedrock-agentcore:GetResourcePaymentToken", - ]); - expect(identity?.Resource).toBe("*"); -}); - -// Every AgentCore-managed credential secret lives under the -// `bedrock-agentcore-identity!` prefix, so scoping to it covers the connector -// secrets without exposing unrelated account secrets. Granting the prefix up -// front also means adding a connector never has to mutate the role. -test("scopes secret reads to AgentCore Identity managed secrets in the region and account", () => { - const secrets = statements(servicePolicy(REGION, ACCOUNT)).find( - (s) => s.Sid === "IdentityManagedSecrets", - ); - expect(secrets?.Action).toEqual(["secretsmanager:GetSecretValue"]); - expect(secrets?.Resource).toBe( - `arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:bedrock-agentcore-identity!*`, - ); -}); - -test("allows sts:SetContext for workload identity tagging", () => { - const sts = statements(servicePolicy(REGION, ACCOUNT)).find((s) => s.Sid === "StsSetContext"); - expect(sts?.Action).toEqual(["sts:SetContext"]); - expect(sts?.Resource).toBe("*"); -}); diff --git a/src/core/paymentServiceRole.ts b/src/core/paymentServiceRole.ts deleted file mode 100644 index 86b9a1634..000000000 --- a/src/core/paymentServiceRole.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - CreateRoleCommand, - GetRoleCommand, - PutRolePolicyCommand, - type IAMClient, -} from "@aws-sdk/client-iam"; -import { createHash } from "node:crypto"; -import { InputValidationError } from "../errors"; -import { parseArn } from "./arn"; - -// Default payment service role provisioning. -// -// CreatePaymentManager requires an IAM role the AgentCore Payments service assumes -// at runtime to mint workload tokens, read the connector's credential provider, -// and fetch payment tokens. When the caller doesn't bring one, PaymentClient -// provisions a per-manager default here, mirroring core/executionRole.ts for -// harnesses: a role trusting bedrock-agentcore.amazonaws.com with one inline -// policy carrying the actions the AgentCore L3 CDK construct grants its -// ResourceRetrievalRole. Only CLI-owned roles for the same manager and region -// are reused and have their inline policy refreshed. - -const POLICY_NAME = "AgentCorePaymentsServicePolicy"; - -const ROLE_NAME_PREFIX = "AgentCorePayments-"; -const ROLE_NAME_MAX = 64; -const NAME_HASH_LENGTH = 12; - -// IAM names are account-global; the policy is regional. Hash the full identity -// before truncation so long manager names cannot collapse onto the same role. -export function paymentServiceRoleName(managerName: string, region: string): string { - const full = `${ROLE_NAME_PREFIX}${region}-${managerName}`; - if (full.length <= ROLE_NAME_MAX) return full; - - const hash = createHash("sha256").update(full).digest("hex").slice(0, NAME_HASH_LENGTH); - return `${full.slice(0, ROLE_NAME_MAX - NAME_HASH_LENGTH - 1)}-${hash}`; -} - -// trustPolicy allows the AgentCore service principal to assume the role. -export function trustPolicy(): string { - return JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Effect: "Allow", - Principal: { Service: "bedrock-agentcore.amazonaws.com" }, - Action: "sts:AssumeRole", - }, - ], - }); -} - -// servicePolicy is the permissions document, parameterized on the caller's -// region and account so the secret grant stays inside them. Every -// AgentCore-managed credential secret is stored under the -// `bedrock-agentcore-identity!` prefix, so granting the prefix covers each -// connector's credentials up front and adding a connector never has to mutate -// the role. -export function servicePolicy(region: string, accountId: string): string { - return JSON.stringify({ - Version: "2012-10-17", - Statement: [ - { - Sid: "AgentCoreIdentityAndTokens", - Effect: "Allow", - Action: [ - "bedrock-agentcore:RetrieveToken", - "bedrock-agentcore:GetWorkloadIdentity", - "bedrock-agentcore:CreateWorkloadIdentity", - "bedrock-agentcore:GetPaymentCredentialProvider", - "bedrock-agentcore:TagResource", - "bedrock-agentcore:GetWorkloadAccessToken", - "bedrock-agentcore:GetWorkloadAccessTokenForUserId", - "bedrock-agentcore:GetWorkloadAccessTokenForJWT", - "bedrock-agentcore:GetResourcePaymentToken", - ], - Resource: "*", - }, - { - Sid: "IdentityManagedSecrets", - Effect: "Allow", - Action: ["secretsmanager:GetSecretValue"], - Resource: `arn:aws:secretsmanager:${region}:${accountId}:secret:bedrock-agentcore-identity!*`, - }, - { - Sid: "StsSetContext", - Effect: "Allow", - Action: ["sts:SetContext"], - Resource: "*", - }, - ], - }); -} - -// accountIdFromRoleArn extracts the account id from a role ARN -// (arn:aws:iam:::role/), which saves an STS lookup. -function accountIdFromRoleArn(arn: string): string { - const accountId = parseArn(arn)?.account; - if (!accountId) { - throw new Error(`Cannot extract an account id from role ARN "${arn}"`); - } - return accountId; -} - -// ensurePaymentServiceRole returns the ARN of the default service role for -// `managerName`, creating the role if it doesn't exist and (re)attaching the -// inline policy either way. -export async function ensurePaymentServiceRole( - iam: IAMClient, - managerName: string, - region: string, -): Promise { - const roleName = paymentServiceRoleName(managerName, region); - const tags = [ - { Key: "agentcore:managed-by", Value: "agentcore-cli" }, - { Key: "agentcore:payment-manager", Value: managerName }, - { Key: "agentcore:region", Value: region }, - ]; - - let roleArn: string; - try { - const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); - if ( - !tags.every(({ Key, Value }) => - existing.Role?.Tags?.some((tag) => tag.Key === Key && tag.Value === Value), - ) - ) { - throw new InputValidationError( - `IAM role "${roleName}" already exists but is not owned by this CLI payment manager in ${region}. ` + - "Use --role-arn to supply a role explicitly, or choose a different manager name; the existing role was not changed.", - ); - } - roleArn = existing.Role!.Arn!; - } catch (error) { - if ((error as Error).name !== "NoSuchEntityException") throw error; - const created = await iam.send( - new CreateRoleCommand({ - RoleName: roleName, - AssumeRolePolicyDocument: trustPolicy(), - Tags: tags, - Description: `Default service role for the AgentCore payment manager "${managerName}" (created by the agentcore CLI)`, - }), - ); - roleArn = created.Role!.Arn!; - } - - await iam.send( - new PutRolePolicyCommand({ - RoleName: roleName, - PolicyName: POLICY_NAME, - PolicyDocument: servicePolicy(region, accountIdFromRoleArn(roleArn)), - }), - ); - - return roleArn; -} diff --git a/src/core/roleRetry.ts b/src/core/roleRetry.ts deleted file mode 100644 index adc087af0..000000000 --- a/src/core/roleRetry.ts +++ /dev/null @@ -1,27 +0,0 @@ -// isRoleUnassumableValidation is the harness predicate: AgentCore rejects a -// freshly created execution role with a ValidationException whose message names -// the role, the assume, or the trust relationship. -export function isRoleUnassumableValidation(error: Error): boolean { - return error.name === "ValidationException" && /role|assume|trust/i.test(error.message ?? ""); -} - -// retryWhileRoleUnassumable retries `operation` while it fails with the error -// AgentCore raises for a role it cannot yet assume (fresh IAM roles propagate -// over several seconds). Any other failure — or exhausting the attempts — -// rethrows. `isRetryable` decides which errors count; it defaults to the -// harness ValidationException shape. -export async function retryWhileRoleUnassumable( - operation: () => Promise, - isRetryable: (error: Error) => boolean = isRoleUnassumableValidation, - attempts = 8, - delayMs = 2000, -): Promise { - for (let attempt = 1; ; attempt++) { - try { - return await operation(); - } catch (error) { - if (!isRetryable(error as Error) || attempt >= attempts) throw error; - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } -} diff --git a/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json b/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json deleted file mode 100644 index 6ca49e4fa..000000000 --- a/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", - "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", - "name": "AgentCoreCliPaymentE2E", - "authorizerType": "AWS_IAM", - "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", - "createdAt": { - "$date": "2026-09-09T00:05:18.383Z" - }, - "status": "READY", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" - }, - "tags": { - "created-by": "agentcore-cli-e2e" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json b/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json deleted file mode 100644 index 5c8c84e63..000000000 --- a/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "Role": { - "Path": "/", - "RoleName": "AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", - "RoleId": "AROAYY3QB54NRWRDQPR7D", - "Arn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", - "CreateDate": { - "$date": "2026-09-09T00:05:06.000Z" - }, - "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22bedrock-agentcore.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", - "Tags": [ - { - "Key": "agentcore:managed-by", - "Value": "agentcore-cli" - }, - { - "Key": "agentcore:payment-manager", - "Value": "AgentCoreCliPaymentE2E" - }, - { - "Key": "agentcore:region", - "Value": "us-east-1" - } - ] - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json deleted file mode 100644 index 302c0f350..000000000 --- a/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "DELETING", - "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json deleted file mode 100644 index 16c918232..000000000 --- a/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", - "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", - "name": "AgentCoreCliPaymentE2E", - "authorizerType": "AWS_IAM", - "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", - "createdAt": { - "$date": "2026-09-09T00:05:18.383Z" - }, - "lastUpdatedAt": { - "$date": "2026-09-09T00:05:18.752Z" - }, - "status": "READY", - "description": "Updated by the agentcore CLI end-to-end test", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json b/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json deleted file mode 100644 index 87a87aa39..000000000 --- a/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$error": { - "name": "NoSuchEntityException", - "message": "The role with name AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E cannot be found." - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json b/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json b/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json deleted file mode 100644 index afcc5c32a..000000000 --- a/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", - "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", - "name": "AgentCoreCliPaymentE2E", - "authorizerType": "AWS_IAM", - "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", - "lastUpdatedAt": { - "$date": "2026-09-09T00:05:18.752Z" - }, - "status": "READY", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json deleted file mode 100644 index 9064c1c9f..000000000 --- a/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$error": { - "name": "ResourceNotFoundException", - "message": "Payment manager not found: agentcoreclipaymente2e-ktdwha51g1" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json deleted file mode 100644 index df9a2ae99..000000000 --- a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni", - "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", - "name": "AgentCoreCliQuickE2E", - "type": "CoinbaseCDP", - "credentialProviderConfigurations": [], - "createdAt": { - "$date": "2026-09-08T20:14:33.909Z" - }, - "status": "PENDING_AUTHENTICATION", - "authorizationUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3ANmI3ZmI4ZGMtZTVhNi00YTVlLWE5NzctMjY1MjQ0MGE1NWIy" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json deleted file mode 100644 index ffa9e78e2..000000000 --- a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", - "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", - "name": "AgentCoreCliConnectorE2E", - "type": "CoinbaseCDP", - "credentialProviderConfigurations": [ - { - "coinbaseCDP": { - "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" - } - } - ], - "createdAt": { - "$date": "2026-09-08T20:14:31.151Z" - }, - "status": "READY" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json deleted file mode 100644 index d4c2477cb..000000000 --- a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "DELETING", - "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json deleted file mode 100644 index 9633a3865..000000000 --- a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "DELETING", - "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json b/src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json deleted file mode 100644 index 55e0e51e0..000000000 --- a/src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp", - "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp", - "credentialProviderVendor": "CoinbaseCDP", - "providerConfigurationOutput": { - "coinbaseCdpConfiguration": { - "apiKeyId": "e0813a2f-8c27-4a6c-8a7b-8202c019938f", - "apiKeySecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp-dc62a3e5/apikey-1N7phA" - }, - "walletSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp-dc62a3e5/wallet-MNXiN7" - }, - "apiKeySecretSource": "MANAGED", - "walletSecretSource": "MANAGED" - } - }, - "createdTime": { - "$date": "2026-06-08T18:10:19.508Z" - }, - "lastUpdatedTime": { - "$date": "2026-06-08T18:10:19.508Z" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json b/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json deleted file mode 100644 index 033f90717..000000000 --- a/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", - "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", - "name": "AgentCoreCliConnectorE2E", - "type": "CoinbaseCDP", - "credentialProviderConfigurations": [ - { - "coinbaseCDP": { - "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" - } - } - ], - "lastUpdatedAt": { - "$date": "2026-09-08T20:14:32.139Z" - }, - "status": "READY" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json deleted file mode 100644 index d79a3c462..000000000 --- a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$error": { - "name": "ResourceNotFoundException", - "message": "Payment connector not found: agentcorecliquicke2e-wolx3aywni" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json deleted file mode 100644 index bfadee775..000000000 --- a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$error": { - "name": "ResourceNotFoundException", - "message": "Payment connector not found: agentcorecliconnectore2e-6rodjuiuig" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-create.golden.json b/src/handlers/payment/__fixtures__/connector/connector-create.golden.json deleted file mode 100644 index 878ab3090..000000000 --- a/src/handlers/payment/__fixtures__/connector/connector-create.golden.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", - "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", - "name": "AgentCoreCliConnectorE2E", - "type": "CoinbaseCDP", - "credentialProviderConfigurations": [ - { - "coinbaseCDP": { - "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" - } - } - ], - "createdAt": "2026-09-08T20:14:31.151Z", - "status": "READY" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json b/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json deleted file mode 100644 index 9633a3865..000000000 --- a/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "DELETING", - "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json b/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json deleted file mode 100644 index 4fd5f5006..000000000 --- a/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni", - "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", - "name": "AgentCoreCliQuickE2E", - "type": "CoinbaseCDP", - "credentialProviderConfigurations": [], - "createdAt": "2026-09-08T20:14:33.909Z", - "status": "PENDING_AUTHENTICATION", - "authorizationUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3ANmI3ZmI4ZGMtZTVhNi00YTVlLWE5NzctMjY1MjQ0MGE1NWIy" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json b/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json deleted file mode 100644 index d4c2477cb..000000000 --- a/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "DELETING", - "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-update.golden.json b/src/handlers/payment/__fixtures__/connector/connector-update.golden.json deleted file mode 100644 index 4d369b25f..000000000 --- a/src/handlers/payment/__fixtures__/connector/connector-update.golden.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", - "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", - "name": "AgentCoreCliConnectorE2E", - "type": "CoinbaseCDP", - "credentialProviderConfigurations": [ - { - "coinbaseCDP": { - "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" - } - } - ], - "lastUpdatedAt": "2026-09-08T20:14:32.139Z", - "status": "READY" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json b/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json deleted file mode 100644 index 045ee3a27..000000000 --- a/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "paymentInstrument": { - "paymentInstrumentId": "payment-instrument-CG2Tl7U1HnCGfHW", - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", - "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", - "userId": "agentcore-cli-e2e", - "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", - "paymentInstrumentDetails": { - "embeddedCryptoWallet": { - "network": "ETHEREUM", - "walletAddress": "0x93581aB831Cc862aA451E91fBf8365e098930859", - "redirectUrl": "https://hub.cdp.coinbase.com/e3eae6406a52" - } - }, - "createdAt": { - "$date": "2026-09-08T20:24:40.849Z" - }, - "status": "ACTIVE", - "updatedAt": { - "$date": "2026-09-08T20:24:41.673Z" - } - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json b/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json deleted file mode 100644 index a0cce6623..000000000 --- a/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "status": "DELETED" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json b/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json deleted file mode 100644 index e9e7d7731..000000000 --- a/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$error": { - "name": "ResourceNotFoundException", - "message": "Payment instrument not found: payment-instrument-CG2Tl7U1HnCGfHW for the given user and manager." - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json deleted file mode 100644 index 468d5d808..000000000 --- a/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "paymentInstrument": { - "paymentInstrumentId": "payment-instrument-CG2Tl7U1HnCGfHW", - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", - "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", - "userId": "agentcore-cli-e2e", - "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", - "paymentInstrumentDetails": { - "embeddedCryptoWallet": { - "network": "ETHEREUM", - "walletAddress": "0x93581aB831Cc862aA451E91fBf8365e098930859", - "redirectUrl": "https://hub.cdp.coinbase.com/e3eae6406a52" - } - }, - "createdAt": "2026-09-08T20:24:40.849Z", - "status": "ACTIVE", - "updatedAt": "2026-09-08T20:24:41.673Z" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json deleted file mode 100644 index a0cce6623..000000000 --- a/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "status": "DELETED" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-create.golden.json b/src/handlers/payment/__fixtures__/manager-create.golden.json deleted file mode 100644 index 3950ee0d5..000000000 --- a/src/handlers/payment/__fixtures__/manager-create.golden.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", - "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", - "name": "AgentCoreCliPaymentE2E", - "authorizerType": "AWS_IAM", - "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", - "createdAt": "2026-09-09T00:05:18.383Z", - "status": "READY", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" - }, - "tags": { - "created-by": "agentcore-cli-e2e" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-delete.golden.json b/src/handlers/payment/__fixtures__/manager-delete.golden.json deleted file mode 100644 index 302c0f350..000000000 --- a/src/handlers/payment/__fixtures__/manager-delete.golden.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "DELETING", - "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-update.golden.json b/src/handlers/payment/__fixtures__/manager-update.golden.json deleted file mode 100644 index 57a6a1ae5..000000000 --- a/src/handlers/payment/__fixtures__/manager-update.golden.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", - "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", - "name": "AgentCoreCliPaymentE2E", - "authorizerType": "AWS_IAM", - "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", - "lastUpdatedAt": "2026-09-09T00:05:18.752Z", - "status": "READY", - "workloadIdentityDetails": { - "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json b/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json deleted file mode 100644 index a0a7107be..000000000 --- a/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "paymentSession": { - "paymentSessionId": "payment-session-nq812U4e1BJIfw1", - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", - "userId": "agentcore-cli-e2e", - "expiryTimeInMinutes": 15, - "createdAt": { - "$date": "2026-09-08T20:20:28.618Z" - }, - "updatedAt": { - "$date": "2026-09-08T20:20:28.618Z" - }, - "limits": { - "maxSpendAmount": { - "value": "1.00", - "currency": "USD" - } - }, - "availableLimits": { - "availableSpendAmount": { - "value": "1.00", - "currency": "USD" - }, - "updatedAt": { - "$date": "2026-09-08T20:20:28.697Z" - } - } - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json b/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json deleted file mode 100644 index a0cce6623..000000000 --- a/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "status": "DELETED" -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json b/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json deleted file mode 100644 index aedf25702..000000000 --- a/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$error": { - "name": "ResourceNotFoundException", - "message": "Payment session not found: payment-session-nq812U4e1BJIfw1" - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-create.golden.json b/src/handlers/payment/__fixtures__/session/session-create.golden.json deleted file mode 100644 index c6d24d46a..000000000 --- a/src/handlers/payment/__fixtures__/session/session-create.golden.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "paymentSession": { - "paymentSessionId": "payment-session-nq812U4e1BJIfw1", - "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", - "userId": "agentcore-cli-e2e", - "expiryTimeInMinutes": 15, - "createdAt": "2026-09-08T20:20:28.618Z", - "updatedAt": "2026-09-08T20:20:28.618Z", - "limits": { - "maxSpendAmount": { - "value": "1.00", - "currency": "USD" - } - }, - "availableLimits": { - "availableSpendAmount": { - "value": "1.00", - "currency": "USD" - }, - "updatedAt": "2026-09-08T20:20:28.697Z" - } - } -} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-delete.golden.json b/src/handlers/payment/__fixtures__/session/session-delete.golden.json deleted file mode 100644 index a0cce6623..000000000 --- a/src/handlers/payment/__fixtures__/session/session-delete.golden.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "status": "DELETED" -} \ No newline at end of file diff --git a/src/handlers/payment/connector/connector.test.tsx b/src/handlers/payment/connector/connector.test.tsx deleted file mode 100644 index 11712ca86..000000000 --- a/src/handlers/payment/connector/connector.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, expect, spyOn, test } from "bun:test"; -import { join } from "node:path"; -import { CoreClient } from "../../../core"; -import { createRootHandler } from "../../index"; -import { - createSilentLogger, - fixtureFactories, - isRecording, - matchGolden, - parse, - TestGlobalConfigAccessor, - testIO, - waitFor, -} from "../../../testing"; -import quickCreateFixture from "../__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json"; -import connectorGetFixture from "../__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json"; - -const FIXTURES = join(import.meta.dir, "..", "__fixtures__", "connector"); -const REGION = "us-west-2"; -const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; -const CREDENTIAL_PROVIDER = "MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp"; -const MANUAL_NAME = "AgentCoreCliConnectorE2E"; -const QUICK_NAME = "AgentCoreCliQuickE2E"; -const scoped = ["--manager-id", MANAGER_ID]; -const quickArgs = ["create", ...scoped, "--name", QUICK_NAME, "--quick-create"]; - -function createFixtureCore(fixtures = FIXTURES): CoreClient { - return new CoreClient({ ...fixtureFactories(fixtures), logger: createSilentLogger() }); -} - -async function run( - args: string[], - { core = createFixtureCore(), regionArgs = ["--region", REGION] } = {}, -) { - const io = testIO(); - const root = createRootHandler(core, { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - await root.route(["node", "agentcore", "payment", "connector", ...args, ...regionArgs]); - return io; -} - -async function waitForDeletion(args: string[]) { - await waitFor( - async () => { - try { - await run(["get", ...args], { core: createFixtureCore(join(FIXTURES, "after-delete")) }); - return false; - } catch (error) { - expect(error).toMatchObject({ name: "ResourceNotFoundException" }); - return true; - } - }, - isRecording() ? 300_000 : 0, - 5_000, - ); -} - -describe("payment connector write inputs", () => { - test.each([ - { args: [] }, - { args: ["--quick-create", "--credential-provider", CREDENTIAL_PROVIDER] }, - ])("requires exactly one credential source: $args", async ({ args }) => { - await expect(run(["create", ...scoped, "--name", MANUAL_NAME, ...args])).rejects.toThrow( - "specify exactly one of '--quick-create' or '--credential-provider'", - ); - }); - - test.each(["create", "update"] as const)( - "%s rejects an empty credential reference before Core", - async (command) => { - const core = createFixtureCore(); - const call = spyOn( - core.payment, - command === "create" ? "createPaymentConnector" : "updatePaymentConnector", - ); - await expect( - run( - [ - command, - ...scoped, - ...(command === "create" ? ["--name", MANUAL_NAME] : ["--connector-id", "c-1"]), - "--credential-provider", - "", - ], - { core }, - ), - ).rejects.toThrow("Invalid value for option '--credential-provider'"); - expect(call).not.toHaveBeenCalled(); - }, - ); - - test("update forwards a replacement credential reference, empty description, and client token", async () => { - const factories = fixtureFactories(FIXTURES); - const control = factories.createControlClient({ region: REGION }); - spyOn(control, "send") - .mockResolvedValueOnce(parse(JSON.stringify(connectorGetFixture))) - .mockImplementationOnce(async () => ({})); - const core = new CoreClient({ - ...factories, - createControlClient: () => control, - logger: createSilentLogger(), - }); - const update = spyOn(core.payment, "updatePaymentConnector"); - const providerArn = - connectorGetFixture.credentialProviderConfigurations[0]!.coinbaseCDP.credentialProviderArn; - - await run( - [ - "update", - ...scoped, - "--connector-id", - "c-1", - "--credential-provider", - providerArn, - "--description", - "", - "--client-token", - "token-1", - ], - { core }, - ); - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith( - { - managerId: MANAGER_ID, - connectorId: "c-1", - credentialProvider: providerArn, - description: "", - clientToken: "token-1", - }, - { region: REGION }, - ); - }); - - test("update cannot change the connector type", async () => { - await expect( - run(["update", ...scoped, "--connector-id", "c-1", "--type", "StripePrivy"]), - ).rejects.toThrow("unknown option '--type'"); - }); -}); - -describe("payment connector Quick Create hints", () => { - test.each([ - { - label: "explicit region over the environment", - regionArgs: ["--region", "eu-west-1"], - environment: "us-east-1", - endpoint: undefined, - }, - { - label: "environment region and shell-quoted endpoint", - regionArgs: [ - "--endpoint-url", - "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", - ], - environment: "eu-west-1", - endpoint: "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", - }, - ])("hint includes the resolved $label", async ({ regionArgs, environment, endpoint }) => { - const savedRegion = process.env.AWS_REGION; - const core = createFixtureCore(); - const create = spyOn(core.payment, "createPaymentConnector").mockResolvedValue( - parse(JSON.stringify(quickCreateFixture)), - ); - - try { - process.env.AWS_REGION = environment; - const created = await run(quickArgs, { - core, - regionArgs: [...regionArgs], - }); - const command = created.stderr().match(/`(agentcore payment connector get [^`]+)`/)?.[1]; - const endpointFlag = - endpoint === undefined - ? "" - : " --endpoint-url 'https://payments.example.test/control path?mode=quick&label=O'\\''Reilly#consent'"; - expect(command).toBe( - `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${quickCreateFixture.paymentConnectorId} --region eu-west-1${endpointFlag}`, - ); - expect(create.mock.calls[0]?.[1]).toEqual({ - region: "eu-west-1", - ...(endpoint === undefined ? {} : { endpointUrl: endpoint }), - }); - } finally { - if (savedRegion === undefined) delete process.env.AWS_REGION; - else process.env.AWS_REGION = savedRegion; - } - }); - - test("--json keeps the authorization URL in stdout without a stderr hint", async () => { - const core = createFixtureCore(); - spyOn(core.payment, "createPaymentConnector").mockResolvedValue( - parse(JSON.stringify(quickCreateFixture)), - ); - const io = await run([...quickArgs, "--json"], { core }); - expect(JSON.parse(io.stdout()).authorizationUrl).toMatch(/^https:\/\//); - expect(io.stderr()).toBe(""); - }); -}); - -test("payment connector lifecycle replays named-provider creation, update, and deletion through root/Core", async () => { - const created = await run([ - "create", - ...scoped, - "--name", - MANUAL_NAME, - "--description", - "Created by the agentcore CLI end-to-end test", - "--credential-provider", - CREDENTIAL_PROVIDER, - ]); - matchGolden(FIXTURES, "connector-create.golden.json", created.stdout()); - const connector = JSON.parse(created.stdout()); - expect(connector.type).toBe("CoinbaseCDP"); - const connectorArgs = [...scoped, "--connector-id", connector.paymentConnectorId]; - await waitFor( - async () => JSON.parse((await run(["get", ...connectorArgs])).stdout()).status === "READY", - isRecording() ? 300_000 : 0, - 5_000, - ); - - const description = "Updated by the agentcore CLI end-to-end test"; - const updated = await run(["update", ...connectorArgs, "--description", description]); - matchGolden(FIXTURES, "connector-update.golden.json", updated.stdout()); - await waitFor( - async () => { - const result = JSON.parse((await run(["get", ...connectorArgs])).stdout()); - return result.status === "READY" && result.description === description; - }, - isRecording() ? 300_000 : 0, - 5_000, - ); - const detail = await run(["get", ...connectorArgs]); - matchGolden(FIXTURES, "connector-get.golden.json", detail.stdout()); - expect(JSON.parse(detail.stdout())).toMatchObject({ - paymentConnectorId: connector.paymentConnectorId, - status: "READY", - description, - }); - - const deleted = await run(["delete", ...connectorArgs]); - matchGolden(FIXTURES, "connector-delete.golden.json", deleted.stdout()); - expect(JSON.parse(deleted.stdout()).status).toBe("DELETING"); - await waitForDeletion(connectorArgs); -}, 1_800_000); - -test("payment connector Quick Create lifecycle returns consent instructions and deletes the pending connector", async () => { - const created = await run(quickArgs); - matchGolden(FIXTURES, "connector-quick-create.golden.json", created.stdout()); - const connector = JSON.parse(created.stdout()); - expect(connector.status).toBe("PENDING_AUTHENTICATION"); - expect(connector.authorizationUrl).toMatch(/^https:\/\//); - expect(created.stderr()).toContain(connector.authorizationUrl); - expect(created.stderr()).toContain("10 minutes"); - expect(created.stderr()).toContain( - `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${connector.paymentConnectorId}`, - ); - - const connectorArgs = [...scoped, "--connector-id", connector.paymentConnectorId]; - const deleted = await run(["delete", ...connectorArgs]); - matchGolden(FIXTURES, "connector-quick-delete.golden.json", deleted.stdout()); - expect(JSON.parse(deleted.stdout()).status).toBe("DELETING"); - await waitForDeletion(connectorArgs); -}, 600_000); diff --git a/src/handlers/payment/connector/create/index.tsx b/src/handlers/payment/connector/create/index.tsx deleted file mode 100644 index 9dbcf31d7..000000000 --- a/src/handlers/payment/connector/create/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import type { AppIO } from "../../../../io"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import { JsonKey } from "../../../keys"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -import type { CreatePaymentConnectorInput } from "../../types"; - -export const createCreatePaymentConnectorHandler = (core: Core, io: AppIO) => - createHandler({ - name: "create", - description: "create a payment connector under a payment manager", - flags: [ - flag("manager-id", "the parent payment manager id", z.string().optional()), - flag("name", "the payment connector name", z.string().optional()), - flag("description", "payment connector description", z.string().optional()), - flag( - "type", - "connector type: CoinbaseCDP or StripePrivy (inferred from a credential provider name; required with an ARN)", - z.enum(["CoinbaseCDP", "StripePrivy"]).optional(), - ), - flag( - "credential-provider", - "payment credential provider name or ARN that backs the connector", - z.string().min(1).optional(), - ), - flag( - "quick-create", - "let Coinbase provision the credentials after OAuth consent (CoinbaseCDP only)", - z.boolean().default(false), - ), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - // Required at runtime but declared optional so that a bare invocation can - // fall through to the TUI once a screen exists. - if (!flags["manager-id"]) { - throw new InputValidationError("required option '--manager-id ' not specified"); - } - if (!flags.name) { - throw new InputValidationError("required option '--name ' not specified"); - } - if (flags["quick-create"] === (flags["credential-provider"] !== undefined)) { - throw new InputValidationError( - "specify exactly one of '--quick-create' or '--credential-provider'", - ); - } - - const input: CreatePaymentConnectorInput = { - managerId: flags["manager-id"], - name: flags.name, - ...(flags.description ? { description: flags.description } : {}), - ...(flags.type ? { type: flags.type } : {}), - ...(flags["credential-provider"] - ? { credentialProvider: flags["credential-provider"] } - : {}), - ...(flags["quick-create"] ? { quickCreate: true } : {}), - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }; - - const options = coreOptsFromCtx(ctx); - const response = await core.payment.createPaymentConnector(input, options); - ctx.require(JsonRendererKey).renderJson(response); - - // Quick Create leaves the connector waiting on OAuth consent; the URL is - // in the JSON, but a scripted caller does not need the walkthrough. - if ( - !ctx.require(JsonKey) && - response.status === "PENDING_AUTHENTICATION" && - response.authorizationUrl - ) { - const command = [ - "agentcore", - "payment", - "connector", - "get", - "--manager-id", - flags["manager-id"], - "--connector-id", - response.paymentConnectorId ?? "", - "--region", - options.region, - ...(options.endpointUrl !== undefined ? ["--endpoint-url", options.endpointUrl] : []), - ] - .map((value) => - /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`, - ) - .join(" "); - io.stderr.write( - `Open ${response.authorizationUrl} within about 10 minutes to authorize with Coinbase, then run ` + - `\`${command}\` ` + - "to confirm the connector is READY.\n", - ); - } - }, - }); diff --git a/src/handlers/payment/connector/delete/index.tsx b/src/handlers/payment/connector/delete/index.tsx deleted file mode 100644 index 8f44c0b84..000000000 --- a/src/handlers/payment/connector/delete/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; - -export const createDeletePaymentConnectorHandler = (core: Core) => - createHandler({ - name: "delete", - description: "delete a payment connector", - flags: [ - flag("manager-id", "the parent payment manager id", z.string().optional()), - flag("connector-id", "the payment connector id", z.string().optional()), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - if (!flags["manager-id"]) { - throw new InputValidationError("required option '--manager-id ' not specified"); - } - if (!flags["connector-id"]) { - throw new InputValidationError( - "required option '--connector-id ' not specified", - ); - } - - ctx.require(JsonRendererKey).renderJson( - await core.payment.deletePaymentConnector( - { - paymentManagerId: flags["manager-id"], - paymentConnectorId: flags["connector-id"], - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }, - coreOptsFromCtx(ctx), - ), - ); - }, - }); diff --git a/src/handlers/payment/connector/get/index.tsx b/src/handlers/payment/connector/get/index.tsx index d8cee86ab..54a548761 100644 --- a/src/handlers/payment/connector/get/index.tsx +++ b/src/handlers/payment/connector/get/index.tsx @@ -40,8 +40,7 @@ export const createGetPaymentConnectorHandler = (core: Core, io: AppIO) => response.status === "AUTHENTICATION_FAILED") ) { io.stderr.write( - `warning: the authorization URL of a ${response.status} connector cannot be renewed; ` + - "delete this connector and create it again with --quick-create.\n", + `warning: connector status is ${response.status}; its authorization URL cannot be renewed.\n`, ); } }, diff --git a/src/handlers/payment/connector/index.tsx b/src/handlers/payment/connector/index.tsx index 82a077d05..b66523054 100644 --- a/src/handlers/payment/connector/index.tsx +++ b/src/handlers/payment/connector/index.tsx @@ -2,18 +2,12 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; -import { createCreatePaymentConnectorHandler } from "./create"; -import { createDeletePaymentConnectorHandler } from "./delete"; import { createGetPaymentConnectorHandler } from "./get"; import { createListPaymentConnectorsHandler } from "./list"; -import { createUpdatePaymentConnectorHandler } from "./update"; export function createPaymentConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "manage connectors under a payment manager") .default(renderTui(core, io)) - .handler(createCreatePaymentConnectorHandler(core, io)) .handler(createGetPaymentConnectorHandler(core, io)) - .handler(createListPaymentConnectorsHandler(core)) - .handler(createUpdatePaymentConnectorHandler(core)) - .handler(createDeletePaymentConnectorHandler(core)); + .handler(createListPaymentConnectorsHandler(core)); } diff --git a/src/handlers/payment/connector/update/index.tsx b/src/handlers/payment/connector/update/index.tsx deleted file mode 100644 index de6e97b9a..000000000 --- a/src/handlers/payment/connector/update/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -import type { UpdatePaymentConnectorInput } from "../../types"; - -// No --type flag: the service rejects any change to a connector's type after -// creation. Like the manager leaf, an omitted flag leaves the field unchanged -// and there is no way to unset a description, so no --clear-* flags either. -export const createUpdatePaymentConnectorHandler = (core: Core) => - createHandler({ - name: "update", - description: "update a payment connector", - flags: [ - flag("manager-id", "the parent payment manager id", z.string().optional()), - flag("connector-id", "the payment connector id", z.string().optional()), - flag("description", "updated description", z.string().optional()), - flag( - "credential-provider", - "replacement payment credential provider name or ARN (must match the connector type)", - z.string().min(1).optional(), - ), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - if (!flags["manager-id"]) { - throw new InputValidationError("required option '--manager-id ' not specified"); - } - if (!flags["connector-id"]) { - throw new InputValidationError( - "required option '--connector-id ' not specified", - ); - } - - const input: UpdatePaymentConnectorInput = { - managerId: flags["manager-id"], - connectorId: flags["connector-id"], - ...(flags.description !== undefined ? { description: flags.description } : {}), - ...(flags["credential-provider"] - ? { credentialProvider: flags["credential-provider"] } - : {}), - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }; - - ctx - .require(JsonRendererKey) - .renderJson(await core.payment.updatePaymentConnector(input, coreOptsFromCtx(ctx))); - }, - }); diff --git a/src/handlers/payment/instrument/create/index.tsx b/src/handlers/payment/instrument/create/index.tsx deleted file mode 100644 index 1a5d6238e..000000000 --- a/src/handlers/payment/instrument/create/index.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import type { - CryptoWalletNetwork, - EmbeddedCryptoWallet, - LinkedAccount, - PaymentInstrumentType, -} from "@aws-sdk/client-bedrock-agentcore"; -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { type AppIO, SourceResolver } from "../../../../io"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx, parseJsonObjectFlag } from "../../../utils"; -import type { CreatePaymentInstrumentInput } from "../../types"; - -// Pinning these lists against the SDK types turns a new enum value into a -// compile-time reminder to widen the flag. -const INSTRUMENT_TYPES = [ - "EMBEDDED_CRYPTO_WALLET", -] as const satisfies readonly PaymentInstrumentType[]; -const DEFAULT_INSTRUMENT_TYPE: PaymentInstrumentType = "EMBEDDED_CRYPTO_WALLET"; -const NETWORKS = ["ETHEREUM", "SOLANA"] as const satisfies readonly CryptoWalletNetwork[]; - -export const createCreatePaymentInstrumentHandler = (core: Core, io: AppIO) => - createHandler({ - name: "create", - description: "create a payment instrument (an embedded crypto wallet) for a user", - flags: [ - flag("manager-id", "the payment manager ID that owns the instrument", z.string().optional()), - flag( - "user-id", - "the user the instrument belongs to (required for IAM-authenticated calls)", - z.string().optional(), - ), - flag("agent-name", "agent name recorded for observability", z.string().optional()), - flag( - "connector-id", - "the payment connector that provisions the wallet", - z.string().optional(), - ), - flag( - "type", - `instrument type (${INSTRUMENT_TYPES.join(" | ")}; default ${DEFAULT_INSTRUMENT_TYPE})`, - z.enum(INSTRUMENT_TYPES).default(DEFAULT_INSTRUMENT_TYPE), - ), - flag( - "network", - `blockchain network of the wallet (${NETWORKS.join(" | ")}; shorthand form)`, - z.enum(NETWORKS).optional(), - ), - flag( - "email", - "email address linked to the wallet (repeatable; shorthand form)", - z.array(z.string()).optional(), - ), - flag( - "phone-number", - "E.164 phone number linked to the wallet (repeatable; shorthand form)", - z.array(z.string()).optional(), - ), - flag( - "instrument-details", - "full wallet definition (JSON EmbeddedCryptoWallet; inline, file://, or - for stdin); replaces the shorthand flags", - z.string().optional(), - ), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - // Required at runtime but declared optional so that a bare invocation can - // fall through to the TUI once a screen exists. - if (!flags["manager-id"]) { - throw new InputValidationError("required option '--manager-id ' not specified"); - } - if (!flags["user-id"]) { - throw new InputValidationError("required option '--user-id ' not specified"); - } - if (!flags["connector-id"]) { - throw new InputValidationError( - "required option '--connector-id ' not specified", - ); - } - - const source = new SourceResolver({ stdin: io.stdin }); - const wallet = await resolveWallet(flags, source); - - const request: CreatePaymentInstrumentInput = { - managerId: flags["manager-id"], - userId: flags["user-id"], - paymentConnectorId: flags["connector-id"], - paymentInstrumentType: flags.type, - paymentInstrumentDetails: { embeddedCryptoWallet: wallet }, - ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }; - - ctx - .require(JsonRendererKey) - .renderJson(await core.payment.createPaymentInstrument(request, coreOptsFromCtx(ctx))); - }, - }); - -type WalletFlags = { - network?: CryptoWalletNetwork; - email?: string[]; - "phone-number"?: string[]; - "instrument-details"?: string; -}; - -// resolveWallet builds the EmbeddedCryptoWallet from whichever input form the -// caller chose. The JSON form is passed through as-is (deep validation is left to -// the service); the shorthand form covers the common email/SMS onboarding case. -async function resolveWallet( - flags: WalletFlags, - source: SourceResolver, -): Promise { - const shorthandUsed = - flags.network !== undefined || flags.email !== undefined || flags["phone-number"] !== undefined; - - if (flags["instrument-details"] !== undefined) { - if (shorthandUsed) { - throw new InputValidationError( - "--instrument-details is mutually exclusive with --network, --email, and --phone-number", - ); - } - return parseJsonObjectFlag( - "instrument-details", - await source.resolveText("instrument-details", flags["instrument-details"]), - )!; - } - - if (!flags.network) { - throw new InputValidationError("required option '--network ' not specified"); - } - const linkedAccounts: LinkedAccount[] = [ - ...(flags.email ?? []).map((emailAddress) => ({ email: { emailAddress } })), - ...(flags["phone-number"] ?? []).map((phoneNumber) => ({ sms: { phoneNumber } })), - ]; - if (linkedAccounts.length === 0) { - throw new InputValidationError( - "the shorthand form needs at least one --email or --phone-number to link to the wallet", - ); - } - return { network: flags.network, linkedAccounts }; -} diff --git a/src/handlers/payment/instrument/delete/index.tsx b/src/handlers/payment/instrument/delete/index.tsx deleted file mode 100644 index 75aceedd4..000000000 --- a/src/handlers/payment/instrument/delete/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -import type { DeletePaymentInstrumentInput } from "../../types"; - -// DeletePaymentInstrumentInput carries no agentName, so unlike the other -// instrument leaves this one offers no --agent-name. -export const createDeletePaymentInstrumentHandler = (core: Core) => - createHandler({ - name: "delete", - description: "delete a payment instrument", - flags: [ - flag("manager-id", "the payment manager ID that owns the instrument", z.string().optional()), - flag( - "user-id", - "the user the instrument belongs to (required for IAM-authenticated calls)", - z.string().optional(), - ), - flag( - "connector-id", - "the payment connector the instrument was created under", - z.string().optional(), - ), - flag("instrument-id", "the payment instrument id", z.string().optional()), - ], - handle: async (ctx, flags) => { - if (!flags["manager-id"]) { - throw new InputValidationError("required option '--manager-id ' not specified"); - } - if (!flags["user-id"]) { - throw new InputValidationError("required option '--user-id ' not specified"); - } - if (!flags["connector-id"]) { - throw new InputValidationError( - "required option '--connector-id ' not specified", - ); - } - if (!flags["instrument-id"]) { - throw new InputValidationError( - "required option '--instrument-id ' not specified", - ); - } - - const request: DeletePaymentInstrumentInput = { - managerId: flags["manager-id"], - userId: flags["user-id"], - paymentConnectorId: flags["connector-id"], - paymentInstrumentId: flags["instrument-id"], - }; - - ctx - .require(JsonRendererKey) - .renderJson(await core.payment.deletePaymentInstrument(request, coreOptsFromCtx(ctx))); - }, - }); diff --git a/src/handlers/payment/instrument/index.tsx b/src/handlers/payment/instrument/index.tsx index 152bdfcef..6b76db261 100644 --- a/src/handlers/payment/instrument/index.tsx +++ b/src/handlers/payment/instrument/index.tsx @@ -2,8 +2,6 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; -import { createCreatePaymentInstrumentHandler } from "./create"; -import { createDeletePaymentInstrumentHandler } from "./delete"; import { createGetPaymentInstrumentHandler } from "./get"; import { createListPaymentInstrumentsHandler } from "./list"; import { createGetPaymentInstrumentBalanceHandler } from "./balance"; @@ -11,9 +9,7 @@ import { createGetPaymentInstrumentBalanceHandler } from "./balance"; export function createPaymentInstrumentHandler(core: Core, io: AppIO): Router { return new Router("instrument", "manage payment instruments (embedded crypto wallets)") .default(renderTui(core, io)) - .handler(createCreatePaymentInstrumentHandler(core, io)) .handler(createGetPaymentInstrumentHandler(core)) .handler(createListPaymentInstrumentsHandler(core)) - .handler(createDeletePaymentInstrumentHandler(core)) .handler(createGetPaymentInstrumentBalanceHandler(core)); } diff --git a/src/handlers/payment/instrument/instrument.test.tsx b/src/handlers/payment/instrument/instrument.test.tsx deleted file mode 100644 index a43893417..000000000 --- a/src/handlers/payment/instrument/instrument.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, spyOn, test } from "bun:test"; -import { join } from "node:path"; -import type { - CreatePaymentInstrumentRequest, - EmbeddedCryptoWallet, -} from "@aws-sdk/client-bedrock-agentcore"; -import { CoreClient } from "../../../core"; -import { createRootHandler } from "../../index"; -import { - createSilentLogger, - fixtureFactories, - matchGolden, - parse, - TestGlobalConfigAccessor, - testIO, -} from "../../../testing"; -import instrumentCreateFixture from "../__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json"; - -const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); -const FIXTURES = join(PAYMENT_FIXTURES, "instrument"); -const REGION = "us-west-2"; -const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; -const CONNECTOR_ID = "mycdpconnectoraidandal-okve8guw4y"; -const USER_ID = "agentcore-cli-e2e"; -const EMAIL = "agentcore-cli-e2e@example.com"; -const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; -const connectorScoped = [...scoped, "--connector-id", CONNECTOR_ID]; -const shorthand = ["--network", "ETHEREUM", "--email", EMAIL]; - -function createFixtureCore(fixtures = FIXTURES): CoreClient { - return new CoreClient({ - ...fixtureFactories(PAYMENT_FIXTURES), - createDataClient: fixtureFactories(fixtures).createDataClient, - logger: createSilentLogger(), - }); -} - -async function run(args: string[], core = createFixtureCore(), io = testIO()): Promise { - const root = createRootHandler(core, { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - await root.route(["node", "agentcore", "payment", "instrument", ...args, "--region", REGION]); - return io.stdout(); -} - -async function capture(args: string[], stdin?: string): Promise { - const data = fixtureFactories(FIXTURES).createDataClient({ region: REGION }); - const send = spyOn(data, "send").mockResolvedValue( - parse(JSON.stringify(instrumentCreateFixture)), - ); - const core = new CoreClient({ - ...fixtureFactories(PAYMENT_FIXTURES), - createDataClient: () => data, - logger: createSilentLogger(), - }); - await run(["create", ...connectorScoped, ...args], core, testIO({ stdin })); - expect(send).toHaveBeenCalledTimes(1); - return send.mock.calls[0]![0].input as CreatePaymentInstrumentRequest; -} - -describe("payment instrument wallet inputs", () => { - test.each([{ flags: shorthand }, { flags: ["--phone-number", "+15555550100"] }])( - "rejects shorthand $flags alongside JSON before reading stdin", - async ({ flags }) => { - const core = createFixtureCore(); - const create = spyOn(core.payment, "createPaymentInstrument"); - const io = testIO({ stdin: "{}" }); - await expect( - run(["create", ...connectorScoped, ...flags, "--instrument-details", "-"], core, io), - ).rejects.toThrow("--instrument-details is mutually exclusive with"); - expect(create).not.toHaveBeenCalled(); - expect(io.io.stdin.readableLength).toBe(2); - }, - ); - - test("shorthand needs a network and at least one linked account", async () => { - await expect(run(["create", ...connectorScoped, "--email", EMAIL])).rejects.toThrow( - "required option '--network ' not specified", - ); - await expect(run(["create", ...connectorScoped, "--network", "ETHEREUM"])).rejects.toThrow( - "at least one --email or --phone-number", - ); - }); - - test("shorthand preserves repeated email/SMS accounts and omits unset metadata", async () => { - const request = await capture([ - "--network", - "SOLANA", - "--email", - "one@example.com", - "--email", - "two@example.com", - "--phone-number", - "+15555550100", - ]); - expect(request.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); - expect(request.paymentInstrumentDetails).toEqual({ - embeddedCryptoWallet: { - network: "SOLANA", - linkedAccounts: [ - { email: { emailAddress: "one@example.com" } }, - { email: { emailAddress: "two@example.com" } }, - { sms: { phoneNumber: "+15555550100" } }, - ], - }, - }); - expect(request).not.toHaveProperty("agentName"); - expect(request).not.toHaveProperty("clientToken"); - }); - - test.each(["inline", "stdin"])( - "passes the full wallet and optional metadata from %s", - async (source) => { - const wallet: EmbeddedCryptoWallet = { - network: "ETHEREUM", - linkedAccounts: [ - { developerJwt: { kid: "key-1", sub: "user-1" } }, - { oAuth2: { google: { sub: "google-sub", emailAddress: "g@example.com" } } }, - ], - walletAddress: "0x1234567890abcdef1234567890abcdef12345678", - redirectUrl: "https://example.test/return", - }; - const json = JSON.stringify(wallet); - const request = await capture( - [ - "--instrument-details", - source === "stdin" ? "-" : json, - "--agent-name", - "my-agent", - "--client-token", - "token-1", - ], - source === "stdin" ? json : undefined, - ); - expect(request.paymentInstrumentDetails).toEqual({ embeddedCryptoWallet: wallet }); - expect(request.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); - expect(request.agentName).toBe("my-agent"); - expect(request.clientToken).toBe("token-1"); - }, - ); -}); - -test("payment instrument lifecycle replays wallet provisioning and deletion through root/Core", async () => { - const created = await run(["create", ...connectorScoped, ...shorthand]); - matchGolden(FIXTURES, "instrument-create.golden.json", created); - const { paymentInstrument } = JSON.parse(created); - const instrumentArgs = ["--instrument-id", paymentInstrument.paymentInstrumentId]; - - const detail = await run(["get", ...scoped, ...instrumentArgs]); - matchGolden(FIXTURES, "instrument-get.golden.json", detail); - const wallet = JSON.parse(detail).paymentInstrument; - expect(wallet.paymentInstrumentId).toBe(paymentInstrument.paymentInstrumentId); - expect(wallet.paymentConnectorId).toBe(CONNECTOR_ID); - expect(wallet.status).toBe("ACTIVE"); - expect(wallet.paymentInstrumentDetails.embeddedCryptoWallet.walletAddress).toMatch( - /^0x[0-9a-fA-F]{40}$/, - ); - - const deleted = await run(["delete", ...connectorScoped, ...instrumentArgs]); - matchGolden(FIXTURES, "instrument-delete.golden.json", deleted); - expect(JSON.parse(deleted).status).toBe("DELETED"); - - // The same Get request has a separate post-delete fixture. - await expect( - run(["get", ...scoped, ...instrumentArgs], createFixtureCore(join(FIXTURES, "after-delete"))), - ).rejects.toThrow(/ResourceNotFound|not found/i); -}); diff --git a/src/handlers/payment/manager/create/index.tsx b/src/handlers/payment/manager/create/index.tsx deleted file mode 100644 index 3114cf077..000000000 --- a/src/handlers/payment/manager/create/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { AuthorizerConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { type AppIO, SourceResolver } from "../../../../io"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx, parseJsonObjectFlag, parseTags } from "../../../utils"; -import type { CreatePaymentManagerInput } from "../../types"; - -export const createCreatePaymentManagerHandler = (core: Core, io: AppIO) => - createHandler({ - name: "create", - description: "create a payment manager (auto-provisions a service role if none given)", - flags: [ - flag( - "name", - "the payment manager name (letters and digits, up to 48 characters)", - z.string().optional(), - ), - flag("description", "payment manager description", z.string().optional()), - flag( - "authorizer-type", - "how agents authenticate to the data plane: AWS_IAM (default) or CUSTOM_JWT", - z.enum(["AWS_IAM", "CUSTOM_JWT"]).default("AWS_IAM"), - ), - flag( - "authorizer-configuration", - "CUSTOM_JWT configuration (JSON AuthorizerConfiguration; inline, file://, or - for stdin)", - z.string().optional(), - ), - flag( - "role-arn", - "IAM role the Payments service assumes; a default service role is created when omitted", - z.string().min(1).optional(), - ), - flag( - "kms-key-arn", - "customer managed KMS key ARN for encrypting sensitive data at rest", - z.string().min(1).optional(), - ), - flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - // Required at runtime but declared optional so that a bare invocation can - // fall through to the TUI once a screen exists. - if (!flags.name) { - throw new InputValidationError("required option '--name ' not specified"); - } - if ( - flags["authorizer-type"] === "CUSTOM_JWT" && - flags["authorizer-configuration"] === undefined - ) { - throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); - } - if ( - flags["authorizer-type"] !== "CUSTOM_JWT" && - flags["authorizer-configuration"] !== undefined - ) { - throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); - } - - const source = new SourceResolver({ stdin: io.stdin }); - const authorizerConfiguration = parseJsonObjectFlag( - "authorizer-configuration", - await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), - ); - const tags = parseTags(flags.tags); - - const input: CreatePaymentManagerInput = { - name: flags.name, - authorizerType: flags["authorizer-type"], - ...(flags.description ? { description: flags.description } : {}), - ...(authorizerConfiguration ? { authorizerConfiguration } : {}), - ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), - ...(flags["kms-key-arn"] ? { kmsKeyArn: flags["kms-key-arn"] } : {}), - ...(tags ? { tags } : {}), - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }; - - ctx - .require(JsonRendererKey) - .renderJson(await core.payment.createPaymentManager(input, coreOptsFromCtx(ctx))); - }, - }); diff --git a/src/handlers/payment/manager/delete/index.tsx b/src/handlers/payment/manager/delete/index.tsx deleted file mode 100644 index 4f9136aec..000000000 --- a/src/handlers/payment/manager/delete/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; - -export const createDeletePaymentManagerHandler = (core: Core) => - createHandler({ - name: "delete", - description: "delete a payment manager (delete its connectors first)", - flags: [ - flag("id", "the payment manager id", z.string().optional()), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - if (!flags.id) { - throw new InputValidationError("required option '--id ' not specified"); - } - - ctx.require(JsonRendererKey).renderJson( - await core.payment.deletePaymentManager( - { - paymentManagerId: flags.id, - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }, - coreOptsFromCtx(ctx), - ), - ); - }, - }); diff --git a/src/handlers/payment/manager/index.tsx b/src/handlers/payment/manager/index.tsx index 7bd935043..1cd6b8652 100644 --- a/src/handlers/payment/manager/index.tsx +++ b/src/handlers/payment/manager/index.tsx @@ -2,18 +2,12 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; -import { createCreatePaymentManagerHandler } from "./create"; -import { createDeletePaymentManagerHandler } from "./delete"; import { createGetPaymentManagerHandler } from "./get"; import { createListPaymentManagersHandler } from "./list"; -import { createUpdatePaymentManagerHandler } from "./update"; export function createPaymentManagerHandler(core: Core, io: AppIO): Router { return new Router("manager", "manage AgentCore payment managers") .default(renderTui(core, io)) - .handler(createCreatePaymentManagerHandler(core, io)) .handler(createGetPaymentManagerHandler(core)) - .handler(createListPaymentManagersHandler(core)) - .handler(createUpdatePaymentManagerHandler(core, io)) - .handler(createDeletePaymentManagerHandler(core)); + .handler(createListPaymentManagersHandler(core)); } diff --git a/src/handlers/payment/manager/update/index.tsx b/src/handlers/payment/manager/update/index.tsx deleted file mode 100644 index d5e7dc32b..000000000 --- a/src/handlers/payment/manager/update/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { AuthorizerConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { type AppIO, SourceResolver } from "../../../../io"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx, parseJsonObjectFlag } from "../../../utils"; -import type { UpdatePaymentManagerInput } from "../../types"; - -// The payment APIs are PATCH-style with no clear wrapper: an omitted flag leaves -// the field unchanged, and there is no way to unset a description, KMS key, or -// authorizer configuration, so the CLI offers no --clear-* flags here. -export const createUpdatePaymentManagerHandler = (core: Core, io: AppIO) => - createHandler({ - name: "update", - description: "update a payment manager", - flags: [ - flag("id", "the payment manager id", z.string().optional()), - flag("description", "updated description", z.string().optional()), - flag( - "authorizer-type", - "updated data-plane authorizer: AWS_IAM or CUSTOM_JWT", - z.enum(["AWS_IAM", "CUSTOM_JWT"]).optional(), - ), - flag( - "authorizer-configuration", - "replacement CUSTOM_JWT configuration (JSON AuthorizerConfiguration; inline, file://, or - for stdin)", - z.string().optional(), - ), - flag( - "role-arn", - "updated IAM role the Payments service assumes", - z.string().min(1).optional(), - ), - flag("kms-key-arn", "updated customer managed KMS key ARN", z.string().min(1).optional()), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - if (!flags.id) { - throw new InputValidationError("required option '--id ' not specified"); - } - if ( - flags["authorizer-type"] === "AWS_IAM" && - flags["authorizer-configuration"] !== undefined - ) { - throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); - } - - const source = new SourceResolver({ stdin: io.stdin }); - const authorizerConfiguration = parseJsonObjectFlag( - "authorizer-configuration", - await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), - ); - - const input: UpdatePaymentManagerInput = { - paymentManagerId: flags.id, - ...(flags.description !== undefined ? { description: flags.description } : {}), - ...(flags["authorizer-type"] ? { authorizerType: flags["authorizer-type"] } : {}), - ...(authorizerConfiguration ? { authorizerConfiguration } : {}), - ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), - ...(flags["kms-key-arn"] ? { kmsKeyArn: flags["kms-key-arn"] } : {}), - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }; - - ctx - .require(JsonRendererKey) - .renderJson(await core.payment.updatePaymentManager(input, coreOptsFromCtx(ctx))); - }, - }); diff --git a/src/handlers/payment/payment.read.test.tsx b/src/handlers/payment/payment.read.test.tsx index 9962fe6d6..2cb8dd8d3 100644 --- a/src/handlers/payment/payment.read.test.tsx +++ b/src/handlers/payment/payment.read.test.tsx @@ -41,7 +41,7 @@ function setup(resource = "manager", overrides: Partial { +test("registers the read-only command tree without TUI or mutation leaves", () => { const payment = compile(setup().root, ValueContext.EmptyContext()).commands.find( (c) => c.name() === "payment", )!; @@ -50,10 +50,10 @@ test("registers reads and mutations as CLI-only commands", () => { payment.commands.map((resource) => [resource.name(), resource.commands.map((c) => c.name())]), ), ).toEqual({ - manager: ["create", "get", "list", "update", "delete"], - connector: ["create", "get", "list", "update", "delete"], - session: ["create", "get", "list", "delete"], - instrument: ["create", "get", "list", "delete", "balance"], + manager: ["get", "list"], + connector: ["get", "list"], + session: ["get", "list"], + instrument: ["get", "list", "balance"], }); for (const resource of payment.commands) { for (const command of resource.commands) expect(isTuiCommandSupported(command)).toBe(false); diff --git a/src/handlers/payment/payment.test.tsx b/src/handlers/payment/payment.test.tsx deleted file mode 100644 index 289eeb32a..000000000 --- a/src/handlers/payment/payment.test.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, expect, spyOn, test } from "bun:test"; -import { join } from "node:path"; -import { CoreClient } from "../../core"; -import { paymentServiceRoleName } from "../../core/paymentServiceRole"; -import { createRootHandler } from "../index"; -import { - createSilentLogger, - fixtureFactories, - isRecording, - matchGolden, - TestGlobalConfigAccessor, - testIO, - waitFor, -} from "../../testing"; - -const FIXTURES = join(import.meta.dir, "__fixtures__"); -// The recorded manager lifecycle uses us-east-1; read fixtures use us-west-2. -const REGION = "us-east-1"; -const E2E_NAME = "AgentCoreCliPaymentE2E"; - -function createFixtureCore(fixtures = FIXTURES): CoreClient { - return new CoreClient({ ...fixtureFactories(fixtures), logger: createSilentLogger() }); -} - -async function run(args: string[], core = createFixtureCore(), io = testIO()): Promise { - const root = createRootHandler(core, { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - await root.route(["node", "agentcore", "payment", "manager", ...args, "--region", REGION]); - return io.stdout(); -} - -describe("payment manager write inputs", () => { - test.each(["create", "update"] as const)( - "%s preserves explicit references and JWT configuration from stdin", - async (command) => { - const factories = fixtureFactories(FIXTURES); - const control = factories.createControlClient({ region: REGION }); - spyOn(control, "send").mockImplementation(async () => ({})); - const core = new CoreClient({ - ...factories, - createControlClient: () => control, - logger: createSilentLogger(), - }); - const call = spyOn( - core.payment, - command === "create" ? "createPaymentManager" : "updatePaymentManager", - ); - const roleArn = "arn:aws:iam::123456789012:role/PaymentRole"; - const kmsKeyArn = - "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"; - const authorizerConfiguration = { - customJWTAuthorizer: { - discoveryUrl: "https://example.test/.well-known/openid-configuration", - }, - }; - - await run( - [ - command, - ...(command === "create" - ? ["--name", "ExplicitReferences", "--authorizer-type", "CUSTOM_JWT"] - : ["--id", "manager-1", "--description", ""]), - "--role-arn", - roleArn, - "--kms-key-arn", - kmsKeyArn, - "--authorizer-configuration", - "-", - "--client-token", - "token-1", - ], - core, - testIO({ stdin: JSON.stringify(authorizerConfiguration) }), - ); - - expect(call).toHaveBeenCalledTimes(1); - expect(call).toHaveBeenCalledWith( - { - ...(command === "create" - ? { name: "ExplicitReferences", authorizerType: "CUSTOM_JWT" } - : { paymentManagerId: "manager-1", description: "" }), - roleArn, - kmsKeyArn, - authorizerConfiguration, - clientToken: "token-1", - }, - { region: REGION }, - ); - }, - ); - - test.each([ - ["create", "role-arn"], - ["create", "kms-key-arn"], - ["update", "role-arn"], - ["update", "kms-key-arn"], - ] as const)("%s rejects empty --%s before Core or stdin", async (command, flag) => { - const core = createFixtureCore(); - const call = spyOn( - core.payment, - command === "create" ? "createPaymentManager" : "updatePaymentManager", - ); - const io = testIO({ stdin: "{}" }); - - await expect( - run( - [ - command, - ...(command === "create" ? ["--name", "EmptyReference"] : ["--id", "manager-1"]), - `--${flag}`, - "", - "--authorizer-type", - "CUSTOM_JWT", - "--authorizer-configuration", - "-", - ], - core, - io, - ), - ).rejects.toThrow(`Invalid value for option '--${flag}'`); - expect(call).not.toHaveBeenCalled(); - expect(io.io.stdin.readableLength).toBe(2); - expect(io.stdout()).toBe(""); - }); - - test("enforces JWT configuration combinations for create and update", async () => { - await expect( - run(["create", "--name", "Jwt", "--authorizer-type", "CUSTOM_JWT"]), - ).rejects.toThrow("CUSTOM_JWT requires --authorizer-configuration"); - await expect( - run(["create", "--name", "Iam", "--authorizer-configuration", "{}"]), - ).rejects.toThrow("--authorizer-configuration is valid only with CUSTOM_JWT"); - await expect( - run([ - "update", - "--id", - "manager-1", - "--authorizer-type", - "AWS_IAM", - "--authorizer-configuration", - "{}", - ]), - ).rejects.toThrow("--authorizer-configuration is valid only with CUSTOM_JWT"); - }); -}); - -test("payment manager lifecycle replays default-role creation, update, and deletion through root/Core", async () => { - const created = await run([ - "create", - "--name", - E2E_NAME, - "--description", - "Created by the agentcore CLI end-to-end test", - "--tags", - "created-by=agentcore-cli-e2e", - ]); - matchGolden(FIXTURES, "manager-create.golden.json", created); - const manager = JSON.parse(created); - expect(manager.authorizerType).toBe("AWS_IAM"); - expect(manager.roleArn).toContain(paymentServiceRoleName(E2E_NAME, REGION)); - const scoped = ["--id", manager.paymentManagerId]; - await waitFor( - async () => JSON.parse(await run(["get", ...scoped])).status === "READY", - isRecording() ? 300_000 : 0, - 5_000, - ); - - const description = "Updated by the agentcore CLI end-to-end test"; - const updated = await run(["update", ...scoped, "--description", description]); - matchGolden(FIXTURES, "manager-update.golden.json", updated); - await waitFor( - async () => { - const detail = JSON.parse(await run(["get", ...scoped])); - return detail.status === "READY" && detail.description === description; - }, - isRecording() ? 300_000 : 0, - 5_000, - ); - - const deleted = await run(["delete", ...scoped]); - matchGolden(FIXTURES, "manager-delete.golden.json", deleted); - expect(JSON.parse(deleted).status).toBe("DELETING"); - - // The same Get request has a separate post-delete fixture. - await waitFor( - async () => { - try { - await run(["get", ...scoped], createFixtureCore(join(FIXTURES, "after-delete"))); - return false; - } catch (error) { - expect(error).toMatchObject({ name: "ResourceNotFoundException" }); - return true; - } - }, - isRecording() ? 300_000 : 0, - 5_000, - ); -}, 1_800_000); diff --git a/src/handlers/payment/session/create/index.tsx b/src/handlers/payment/session/create/index.tsx deleted file mode 100644 index d6eb875c0..000000000 --- a/src/handlers/payment/session/create/index.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { Currency } from "@aws-sdk/client-bedrock-agentcore"; -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -import type { CreatePaymentSessionInput } from "../../types"; - -// The service accepts only USD today. Pinning the list against the SDK type -// turns a new Currency value into a compile-time reminder to widen this. -const CURRENCIES = ["USD"] as const satisfies readonly Currency[]; -const DEFAULT_CURRENCY: Currency = "USD"; - -export const createCreatePaymentSessionHandler = (core: Core) => - createHandler({ - name: "create", - description: "create a payment session (a time-boxed payment context with a spend limit)", - flags: [ - flag("manager-id", "the payment manager ID that owns the session", z.string().optional()), - flag( - "user-id", - "the user the session is scoped to (required for IAM-authenticated calls)", - z.string().optional(), - ), - flag("agent-name", "agent name recorded for observability", z.string().optional()), - flag( - "expiry-minutes", - "how long the session stays active, in minutes (15 to 480)", - z.number().int().min(15).max(480).optional(), - ), - flag( - "max-spend", - "maximum amount the session may spend, as a decimal string (e.g. 25.00)", - z.string().optional(), - ), - flag( - "currency", - `currency of --max-spend (${CURRENCIES.join(" | ")}; default ${DEFAULT_CURRENCY})`, - z.enum(CURRENCIES).optional(), - ), - flag("client-token", "idempotency token", z.string().optional()), - ], - handle: async (ctx, flags) => { - // Required at runtime but declared optional so that a bare invocation can - // fall through to the TUI once a screen exists. - if (!flags["manager-id"]) { - throw new InputValidationError("required option '--manager-id ' not specified"); - } - if (!flags["user-id"]) { - throw new InputValidationError("required option '--user-id ' not specified"); - } - if (flags["expiry-minutes"] === undefined) { - throw new InputValidationError( - "required option '--expiry-minutes ' not specified", - ); - } - const maxSpend = flags["max-spend"]; - if (maxSpend !== undefined && maxSpend.trim() === "") { - throw new InputValidationError("--max-spend must not be empty or whitespace"); - } - if (flags.currency !== undefined && maxSpend === undefined) { - throw new InputValidationError("--currency requires --max-spend"); - } - - const request: CreatePaymentSessionInput = { - managerId: flags["manager-id"], - userId: flags["user-id"], - expiryTimeInMinutes: flags["expiry-minutes"], - ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), - ...(maxSpend !== undefined - ? { - limits: { - maxSpendAmount: { - value: maxSpend, - currency: flags.currency ?? DEFAULT_CURRENCY, - }, - }, - } - : {}), - ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), - }; - - ctx - .require(JsonRendererKey) - .renderJson(await core.payment.createPaymentSession(request, coreOptsFromCtx(ctx))); - }, - }); diff --git a/src/handlers/payment/session/delete/index.tsx b/src/handlers/payment/session/delete/index.tsx deleted file mode 100644 index 21a2eac0e..000000000 --- a/src/handlers/payment/session/delete/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import z from "zod"; -import { InputValidationError } from "../../../../errors"; -import { createHandler, flag } from "../../../../router"; -import { JsonRendererKey } from "../../../../tui"; -import type { Core } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -import type { DeletePaymentSessionInput } from "../../types"; - -// DeletePaymentSessionInput carries no agentName, so unlike the other session -// leaves this one offers no --agent-name. -export const createDeletePaymentSessionHandler = (core: Core) => - createHandler({ - name: "delete", - description: "delete a payment session", - flags: [ - flag("manager-id", "the payment manager ID that owns the session", z.string().optional()), - flag( - "user-id", - "the user the session is scoped to (required for IAM-authenticated calls)", - z.string().optional(), - ), - flag("session-id", "the payment session id", z.string().optional()), - ], - handle: async (ctx, flags) => { - if (!flags["manager-id"]) { - throw new InputValidationError("required option '--manager-id ' not specified"); - } - if (!flags["user-id"]) { - throw new InputValidationError("required option '--user-id ' not specified"); - } - if (!flags["session-id"]) { - throw new InputValidationError("required option '--session-id ' not specified"); - } - - const request: DeletePaymentSessionInput = { - managerId: flags["manager-id"], - userId: flags["user-id"], - paymentSessionId: flags["session-id"], - }; - - ctx - .require(JsonRendererKey) - .renderJson(await core.payment.deletePaymentSession(request, coreOptsFromCtx(ctx))); - }, - }); diff --git a/src/handlers/payment/session/index.tsx b/src/handlers/payment/session/index.tsx index 881f0de66..dff3e769a 100644 --- a/src/handlers/payment/session/index.tsx +++ b/src/handlers/payment/session/index.tsx @@ -2,16 +2,12 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; -import { createCreatePaymentSessionHandler } from "./create"; -import { createDeletePaymentSessionHandler } from "./delete"; import { createGetPaymentSessionHandler } from "./get"; import { createListPaymentSessionsHandler } from "./list"; export function createPaymentSessionHandler(core: Core, io: AppIO): Router { return new Router("session", "manage payment sessions (budget-limited payment contexts)") .default(renderTui(core, io)) - .handler(createCreatePaymentSessionHandler(core)) .handler(createGetPaymentSessionHandler(core)) - .handler(createListPaymentSessionsHandler(core)) - .handler(createDeletePaymentSessionHandler(core)); + .handler(createListPaymentSessionsHandler(core)); } diff --git a/src/handlers/payment/session/session.test.tsx b/src/handlers/payment/session/session.test.tsx deleted file mode 100644 index 87798aa6d..000000000 --- a/src/handlers/payment/session/session.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, spyOn, test } from "bun:test"; -import { join } from "node:path"; -import { CoreClient } from "../../../core"; -import { createRootHandler } from "../../index"; -import { - createSilentLogger, - fixtureFactories, - matchGolden, - parse, - TestGlobalConfigAccessor, - testIO, -} from "../../../testing"; -import sessionCreateFixture from "../__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json"; - -const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); -const FIXTURES = join(PAYMENT_FIXTURES, "session"); -const REGION = "us-west-2"; -const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; -const USER_ID = "agentcore-cli-e2e"; -const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; -const createArgs = ["create", ...scoped, "--expiry-minutes", "15"]; - -function createFixtureCore(fixtures = FIXTURES): CoreClient { - return new CoreClient({ - ...fixtureFactories(PAYMENT_FIXTURES), - createDataClient: fixtureFactories(fixtures).createDataClient, - logger: createSilentLogger(), - }); -} - -async function run(args: string[], core = createFixtureCore()): Promise { - const io = testIO(); - const root = createRootHandler(core, { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - await root.route(["node", "agentcore", "payment", "session", ...args, "--region", REGION]); - return io.stdout(); -} - -describe("payment session create", () => { - test.each(["14", "481", "15.5"])( - "rejects expiry outside whole minutes 15..480: %s", - async (value) => { - await expect(run(["create", ...scoped, "--expiry-minutes", value])).rejects.toThrow( - "Invalid value for option '--expiry-minutes'", - ); - }, - ); - - test.each([ - { args: ["--currency", "USD"], error: "--currency requires --max-spend" }, - { args: ["--max-spend", ""], error: "--max-spend must not be empty or whitespace" }, - { - args: ["--max-spend", " \t\n ", "--currency", "USD"], - error: "--max-spend must not be empty or whitespace", - }, - { - args: ["--max-spend", "1.00", "--currency", "EUR"], - error: "Invalid value for option '--currency'", - }, - ])("rejects invalid spend flags $args before Core", async ({ args, error }) => { - const core = createFixtureCore(); - const create = spyOn(core.payment, "createPaymentSession"); - await expect(run([...createArgs, ...args], core)).rejects.toThrow(error); - expect(create).not.toHaveBeenCalled(); - }); - - test.each([undefined, "0", "10.00"])( - "preserves spend %j without numeric coercion", - async (value) => { - const data = fixtureFactories(FIXTURES).createDataClient({ region: REGION }); - const send = spyOn(data, "send").mockResolvedValue( - parse(JSON.stringify(sessionCreateFixture)), - ); - const core = new CoreClient({ - ...fixtureFactories(PAYMENT_FIXTURES), - createDataClient: () => data, - logger: createSilentLogger(), - }); - - await run([...createArgs, ...(value === undefined ? [] : ["--max-spend", value])], core); - - expect(send).toHaveBeenCalledTimes(1); - const request = send.mock.calls[0]![0].input; - if (value === undefined) { - expect(request).not.toHaveProperty("limits"); - } else { - expect(request).toHaveProperty("limits", { - maxSpendAmount: { value, currency: "USD" }, - }); - } - }, - ); -}); - -test("payment session lifecycle replays create, read-back, and delete through root/Core", async () => { - const created = await run([...createArgs, "--max-spend", "1.00", "--currency", "USD"]); - matchGolden(FIXTURES, "session-create.golden.json", created); - const { paymentSession } = JSON.parse(created); - expect(paymentSession.expiryTimeInMinutes).toBe(15); - expect(paymentSession.limits.maxSpendAmount.currency).toBe("USD"); - expect(Number(paymentSession.limits.maxSpendAmount.value)).toBe(1); - - const sessionArgs = [...scoped, "--session-id", paymentSession.paymentSessionId]; - const detail = await run(["get", ...sessionArgs]); - matchGolden(FIXTURES, "session-get.golden.json", detail); - expect(JSON.parse(detail).paymentSession.paymentSessionId).toBe(paymentSession.paymentSessionId); - - const deleted = await run(["delete", ...sessionArgs]); - matchGolden(FIXTURES, "session-delete.golden.json", deleted); - expect(JSON.parse(deleted).status).toBe("DELETED"); - - // The same Get request has a separate post-delete fixture. - await expect( - run(["get", ...sessionArgs], createFixtureCore(join(FIXTURES, "after-delete"))), - ).rejects.toThrow(/ResourceNotFound|not found/i); -}); diff --git a/src/handlers/payment/types.tsx b/src/handlers/payment/types.tsx index 42e669d38..7456a5389 100644 --- a/src/handlers/payment/types.tsx +++ b/src/handlers/payment/types.tsx @@ -1,30 +1,10 @@ import type { - CreatePaymentConnectorResponse, - CreatePaymentManagerRequest, - CreatePaymentManagerResponse, - DeletePaymentConnectorRequest, - DeletePaymentConnectorResponse, - DeletePaymentManagerRequest, - DeletePaymentManagerResponse, GetPaymentConnectorResponse, GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, - PaymentConnectorType, - UpdatePaymentConnectorRequest, - UpdatePaymentConnectorResponse, - UpdatePaymentManagerRequest, - UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { - CreatePaymentInstrumentRequest, - CreatePaymentInstrumentResponse, - CreatePaymentSessionRequest, - CreatePaymentSessionResponse, - DeletePaymentInstrumentRequest, - DeletePaymentInstrumentResponse, - DeletePaymentSessionRequest, - DeletePaymentSessionResponse, GetPaymentInstrumentRequest, GetPaymentInstrumentResponse, GetPaymentInstrumentBalanceRequest, @@ -38,78 +18,22 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { CoreOptions } from "../../core/types"; -// CreatePaymentManagerInput is CreatePaymentManagerRequest with the service role -// made optional: when omitted, Core provisions the default service role in IAM and -// creates the manager with it. -export type CreatePaymentManagerInput = Omit & { - roleArn?: string; -}; - -export type UpdatePaymentManagerInput = UpdatePaymentManagerRequest; - -// CreatePaymentConnectorInput names a credential provider instead of carrying the -// SDK's configuration list. Core resolves a provider name to its ARN and vendor -// through identity, derives the connector type from that vendor when the caller -// omits it, and builds the single-entry union the service expects. Quick Create -// sends no credentials; the service provisions them after OAuth consent. -export type CreatePaymentConnectorInput = { - managerId: string; - name: string; - description?: string; - type?: PaymentConnectorType; - // A payment credential provider name or ARN. Required unless quickCreate is set. - credentialProvider?: string; - quickCreate?: boolean; - clientToken?: string; -}; - -// UpdatePaymentConnectorInput omits `type`: the service rejects any change to a -// connector's type after creation, so the CLI does not offer it. -export type UpdatePaymentConnectorInput = { - managerId: string; - connectorId: string; - description?: UpdatePaymentConnectorRequest["description"]; - credentialProvider?: string; - clientToken?: string; -}; - type WithPaymentManagerId = Omit & { managerId: string }; -export type CreatePaymentSessionInput = WithPaymentManagerId; export type GetPaymentSessionInput = WithPaymentManagerId; export type ListPaymentSessionsInput = WithPaymentManagerId; -export type DeletePaymentSessionInput = WithPaymentManagerId; -export type CreatePaymentInstrumentInput = WithPaymentManagerId; export type GetPaymentInstrumentInput = WithPaymentManagerId; export type GetPaymentInstrumentBalanceInput = WithPaymentManagerId; export type ListPaymentInstrumentsInput = WithPaymentManagerId; -export type DeletePaymentInstrumentInput = WithPaymentManagerId; export interface CorePaymentClient { - createPaymentManager( - input: CreatePaymentManagerInput, - options: CoreOptions, - ): Promise; getPaymentManager(id: string, options: CoreOptions): Promise; listPaymentManagers( nextToken: string | undefined, maxResults: number | undefined, options: CoreOptions, ): Promise; - updatePaymentManager( - input: UpdatePaymentManagerInput, - options: CoreOptions, - ): Promise; - deletePaymentManager( - request: DeletePaymentManagerRequest, - options: CoreOptions, - ): Promise; - - createPaymentConnector( - input: CreatePaymentConnectorInput, - options: CoreOptions, - ): Promise; getPaymentConnector( managerId: string, connectorId: string, @@ -121,20 +45,8 @@ export interface CorePaymentClient { maxResults: number | undefined, options: CoreOptions, ): Promise; - updatePaymentConnector( - input: UpdatePaymentConnectorInput, - options: CoreOptions, - ): Promise; - deletePaymentConnector( - request: DeletePaymentConnectorRequest, - options: CoreOptions, - ): Promise; // Core resolves the selected manager ID to the ARN required by the data plane. - createPaymentSession( - request: CreatePaymentSessionInput, - options: CoreOptions, - ): Promise; getPaymentSession( request: GetPaymentSessionInput, options: CoreOptions, @@ -143,15 +55,6 @@ export interface CorePaymentClient { request: ListPaymentSessionsInput, options: CoreOptions, ): Promise; - deletePaymentSession( - request: DeletePaymentSessionInput, - options: CoreOptions, - ): Promise; - - createPaymentInstrument( - request: CreatePaymentInstrumentInput, - options: CoreOptions, - ): Promise; getPaymentInstrument( request: GetPaymentInstrumentInput, options: CoreOptions, @@ -164,8 +67,4 @@ export interface CorePaymentClient { request: ListPaymentInstrumentsInput, options: CoreOptions, ): Promise; - deletePaymentInstrument( - request: DeletePaymentInstrumentInput, - options: CoreOptions, - ): Promise; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index ffb9dcb92..62909a9ab 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -33,12 +33,6 @@ import type { GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, - CreatePaymentManagerResponse, - UpdatePaymentManagerResponse, - DeletePaymentManagerResponse, - CreatePaymentConnectorResponse, - UpdatePaymentConnectorResponse, - DeletePaymentConnectorResponse, ListAgentRuntimeEndpointsResponse, ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, @@ -153,10 +147,6 @@ import type { GetPaymentSessionResponse, ListPaymentInstrumentsResponse, ListPaymentSessionsResponse, - CreatePaymentSessionResponse, - DeletePaymentSessionResponse, - CreatePaymentInstrumentResponse, - DeletePaymentInstrumentResponse, } from "@aws-sdk/client-bedrock-agentcore"; import type { CorePaymentClient } from "../handlers/payment/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; @@ -1545,36 +1535,6 @@ export class TestIdentityClient implements CoreIdentityClient { // Payment command tests use real Core clients; configure a stub explicitly if a // future screen test needs one. export class TestPaymentClient implements CorePaymentClient { - async createPaymentManager(): Promise { - throw new Error("Unexpected payment call"); - } - async updatePaymentManager(): Promise { - throw new Error("Unexpected payment call"); - } - async deletePaymentManager(): Promise { - throw new Error("Unexpected payment call"); - } - async createPaymentConnector(): Promise { - throw new Error("Unexpected payment call"); - } - async updatePaymentConnector(): Promise { - throw new Error("Unexpected payment call"); - } - async deletePaymentConnector(): Promise { - throw new Error("Unexpected payment call"); - } - async createPaymentSession(): Promise { - throw new Error("Unexpected payment call"); - } - async deletePaymentSession(): Promise { - throw new Error("Unexpected payment call"); - } - async createPaymentInstrument(): Promise { - throw new Error("Unexpected payment call"); - } - async deletePaymentInstrument(): Promise { - throw new Error("Unexpected payment call"); - } async getPaymentManager(): Promise { throw new Error("Unexpected payment call"); } From d7cd656e5788f6b7eeca61678aa696201c3be88a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 11 Sep 2026 16:49:33 +0000 Subject: [PATCH 10/10] refactor(identity): use shared payment secret flag validation --- .../identity/payment-credential-provider/flags.ts | 14 ++++++-------- .../paymentCredentialProvider.test.tsx | 10 +++++----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/handlers/identity/payment-credential-provider/flags.ts b/src/handlers/identity/payment-credential-provider/flags.ts index 79b92f117..9f6d9f589 100644 --- a/src/handlers/identity/payment-credential-provider/flags.ts +++ b/src/handlers/identity/payment-credential-provider/flags.ts @@ -8,6 +8,7 @@ import type { import { InputValidationError } from "../../../errors"; import { type AppIO, SourceResolver } from "../../../io"; import { flag } from "../../../router"; +import { assertMutuallyExclusiveFlags } from "../../utils"; import { parseSecretReference } from "../parser"; import { stripWalletAuthPrefix, @@ -258,14 +259,11 @@ export class PaymentProviderConfigurationResolver { const referenceFlagName = `${flagName}-reference` as const; const source = this.flags[flagName]; const reference = this.flags[referenceFlagName]; - if (source !== undefined && reference !== undefined) { - throw new InputValidationError( - `--${flagName} and --${referenceFlagName} are mutually exclusive`, - ); - } - if (source === undefined && reference === undefined) { - throw new InputValidationError(`either --${flagName} or --${referenceFlagName} is required`); - } + assertMutuallyExclusiveFlags( + { [flagName]: source, [referenceFlagName]: reference }, + [flagName, referenceFlagName], + { exactlyOne: true }, + ); if (source !== undefined) return { flagName, kind: "inline", source }; return { flagName, diff --git a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx index 9bd6b3f6d..1b9495691 100644 --- a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx +++ b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.test.tsx @@ -236,12 +236,12 @@ describe("payment-credential-provider flag validation", () => { [ "CoinbaseCDP without an api key secret", ["create", ...COINBASE_FLAGS], - "either --api-key-secret or --api-key-secret-reference is required", + "specify exactly one of --api-key-secret, --api-key-secret-reference", ], [ "CoinbaseCDP without a wallet secret", ["create", ...COINBASE_FLAGS, "--api-key-secret", "-"], - "either --wallet-secret or --wallet-secret-reference is required", + "specify exactly one of --wallet-secret, --wallet-secret-reference", ], [ "CoinbaseCDP with both api key secret forms", @@ -255,12 +255,12 @@ describe("payment-credential-provider flag validation", () => { "--wallet-secret-reference", SECRET_REFERENCE_JSON, ], - "--api-key-secret and --api-key-secret-reference are mutually exclusive", + "specify exactly one of --api-key-secret, --api-key-secret-reference", ], [ "StripePrivy without an app secret", ["create", ...STRIPE_FLAGS, "--authorization-private-key-reference", SECRET_REFERENCE_JSON], - "either --app-secret or --app-secret-reference is required", + "specify exactly one of --app-secret, --app-secret-reference", ], [ "StripePrivy with both authorization private key forms", @@ -274,7 +274,7 @@ describe("payment-credential-provider flag validation", () => { "--authorization-private-key-reference", SECRET_REFERENCE_JSON, ], - "--authorization-private-key and --authorization-private-key-reference are mutually exclusive", + "specify exactly one of --authorization-private-key, --authorization-private-key-reference", ], [ "inline api key secret value",