From 0d03203d50e8e777249293be1f4f1952b6ee959f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 19:49:10 +0000 Subject: [PATCH 1/6] feat(payment): add read-only core clients and contracts --- src/core/index.tsx | 3 + src/core/payment.read.test.ts | 409 +++++++++++++++++++++++++++++++++ src/core/payment.tsx | 165 +++++++++++++ src/handlers/payment/types.tsx | 70 ++++++ src/handlers/types.tsx | 2 + src/testing/TestCoreClient.tsx | 228 ++++++++++++++++++ src/testing/index.tsx | 1 + 7 files changed, 878 insertions(+) create mode 100644 src/core/payment.read.test.ts create mode 100644 src/core/payment.tsx create mode 100644 src/handlers/payment/types.tsx diff --git a/src/core/index.tsx b/src/core/index.tsx index d6c8658b0..bff250bd0 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -7,6 +7,7 @@ import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; +import { PaymentClient } from "./payment"; import { PolicyClient } from "./policy"; import { ObservabilityClient } from "./observability"; import { CloudWatchClient } from "./observability/index"; @@ -79,6 +80,7 @@ export class CoreClient implements AwsClients { readonly eval: EvalClient; readonly observability: ObservabilityClient; readonly policy: PolicyClient; + readonly payment: PaymentClient; readonly projectManager: ProjectManager; readonly bedrockAgentImporter: CoreBedrockAgentImporter; @@ -101,6 +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" })); + 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 new file mode 100644 index 000000000..b9b44be8f --- /dev/null +++ b/src/core/payment.read.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + GetPaymentConnectorCommand, + GetPaymentManagerCommand, + ListPaymentConnectorsCommand, + ListPaymentManagersCommand, + type GetPaymentConnectorResponse, + type GetPaymentManagerResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + GetPaymentInstrumentBalanceCommand, + GetPaymentInstrumentCommand, + GetPaymentSessionCommand, + ListPaymentInstrumentsCommand, + ListPaymentSessionsCommand, + type GetPaymentInstrumentBalanceResponse, + type PaymentInstrument, + type PaymentSession, +} from "@aws-sdk/client-bedrock-agentcore"; +import { ERROR_SOURCE, InputValidationError, MalformedServiceResponseError } from "../errors"; +import type { CorePaymentClient } from "../handlers/payment/types"; +import { PaymentClient } from "./payment"; +import type { AwsClients, ClientConfig, CoreOptions } from "./types"; + +const MANAGER_ID = "checkout-abc1234567"; +const MANAGER_ARN = `arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/${MANAGER_ID}`; +const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; +const options: CoreOptions = { + region: "us-east-1", + endpointUrl: "https://example.test/payments", + credentials, +}; +const config = { region: options.region, endpoint: options.endpointUrl, credentials }; +const timestamp = new Date("2026-09-01T00:00:00Z"); +const manager: GetPaymentManagerResponse = { + paymentManagerArn: MANAGER_ARN, + paymentManagerId: MANAGER_ID, + name: "Checkout", + authorizerType: "AWS_IAM", + roleArn: "arn:aws:iam::123456789012:role/Payments", + createdAt: timestamp, + lastUpdatedAt: timestamp, + status: "READY", +}; +const connector: GetPaymentConnectorResponse = { + paymentConnectorId: "connector-1", + name: "Coinbase", + type: "CoinbaseCDP", + credentialProviderConfigurations: [], + createdAt: timestamp, + lastUpdatedAt: timestamp, + status: "PENDING_AUTHENTICATION", + authorizationUrl: "https://example.test/authorize", +}; + +interface SdkCommand { + constructor: { name: string }; + input: unknown; +} +type Send = (command: SdkCommand) => Promise; + +const unexpected: Send = async (command) => { + throw new Error(`unexpected ${command.constructor.name}`); +}; + +function paymentClient(sends: { control?: Send; data?: Send } = {}) { + const controlSend = mock(sends.control ?? unexpected); + const dataSend = mock(sends.data ?? unexpected); + const control = mock( + (_config: ClientConfig) => + ({ send: controlSend }) as unknown as ReturnType, + ); + const data = mock( + (_config: ClientConfig) => ({ send: dataSend }) as unknown as ReturnType, + ); + return { client: new PaymentClient({ control, data }), control, data, controlSend, dataSend }; +} + +function serviceError(name: string, message: string): Error { + return Object.assign(new Error(message), { + name, + $metadata: { requestId: "request-1", httpStatusCode: 400 }, + }); +} + +const controlReads = [ + { + command: GetPaymentManagerCommand, + input: { paymentManagerId: MANAGER_ID }, + response: manager, + run: (client: CorePaymentClient) => client.getPaymentManager(MANAGER_ID, options), + }, + { + command: ListPaymentManagersCommand, + input: { nextToken: "page-2", maxResults: 5 }, + response: { paymentManagers: [manager], nextToken: "page-3" }, + run: (client: CorePaymentClient) => client.listPaymentManagers("page-2", 5, options), + }, + { + command: GetPaymentConnectorCommand, + input: { paymentManagerId: MANAGER_ID, paymentConnectorId: "connector-1" }, + response: connector, + run: (client: CorePaymentClient) => + client.getPaymentConnector(MANAGER_ID, "connector-1", options), + }, + { + command: ListPaymentConnectorsCommand, + input: { paymentManagerId: MANAGER_ID, nextToken: "page-2", maxResults: 5 }, + response: { paymentConnectors: [connector], nextToken: "page-3" }, + run: (client: CorePaymentClient) => + client.listPaymentConnectors(MANAGER_ID, "page-2", 5, options), + }, +]; + +describe("PaymentClient control-plane reads", () => { + test.each(controlReads)( + "$command.name preserves request, response, and configured context", + async ({ command, input, response, run }) => { + const { client, control, controlSend, data } = paymentClient({ + control: async () => response, + }); + await expect(run(client)).resolves.toBe(response); + expect(controlSend).toHaveBeenCalledTimes(1); + const sent = controlSend.mock.calls[0]![0]; + expect(sent).toBeInstanceOf(command); + expect(sent.input).toEqual(input); + expect(control).toHaveBeenCalledWith(config); + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each(controlReads)( + "$command.name preserves the original service error without retrying", + async ({ run }) => { + const error = serviceError("AccessDeniedException", "control-plane failure"); + const { client, controlSend, data } = paymentClient({ + control: async () => { + throw error; + }, + }); + await expect(run(client)).rejects.toBe(error); + expect(controlSend).toHaveBeenCalledTimes(1); + expect(data).not.toHaveBeenCalled(); + }, + ); + + test("manager and connector lists allow omitted pagination", async () => { + const { client, controlSend } = paymentClient({ control: async () => ({}) }); + await client.listPaymentManagers(undefined, undefined, { region: "us-west-2" }); + await client.listPaymentConnectors(MANAGER_ID, undefined, undefined, { region: "us-west-2" }); + expect(controlSend.mock.calls.map(([command]) => command.input)).toEqual([ + { nextToken: undefined, maxResults: undefined }, + { paymentManagerId: MANAGER_ID, nextToken: undefined, maxResults: undefined }, + ]); + }); +}); + +const scoped = { managerId: MANAGER_ID, userId: "alice", agentName: "checkout-agent" }; +const session = { ...scoped, paymentSessionId: "session-1" }; +const sessionList = { ...scoped, nextToken: "page-2", maxResults: 5 }; +const instrument = { + ...scoped, + paymentConnectorId: "connector-1", + paymentInstrumentId: "instrument-1", +}; +const instrumentList = { + ...scoped, + paymentConnectorId: "connector-1", + nextToken: "page-2", + maxResults: 2, +}; +const balance = { ...instrument, chain: "BASE_SEPOLIA" as const, token: "USDC" as const }; +const paymentSession: PaymentSession = { + paymentSessionId: "session-1", + paymentManagerArn: MANAGER_ARN, + userId: "alice", + expiryTimeInMinutes: 60, + createdAt: timestamp, + updatedAt: timestamp, +}; +const paymentInstrument: PaymentInstrument = { + paymentInstrumentId: "instrument-1", + paymentManagerArn: MANAGER_ARN, + paymentConnectorId: "connector-1", + userId: "alice", + paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET", + paymentInstrumentDetails: undefined, + status: "ACTIVE", + createdAt: timestamp, + updatedAt: timestamp, +}; +const balanceResponse: GetPaymentInstrumentBalanceResponse = { + paymentInstrumentId: "instrument-1", + tokenBalance: { + amount: "123456789012345678901234567890", + decimals: 6, + network: "ETHEREUM", + chain: "BASE_SEPOLIA", + token: "USDC", + }, +}; +const dataReads = [ + { + command: GetPaymentSessionCommand, + input: session, + response: { paymentSession }, + run: (client: CorePaymentClient, managerId = MANAGER_ID) => + client.getPaymentSession({ ...session, managerId }, options), + }, + { + command: ListPaymentSessionsCommand, + input: sessionList, + response: { paymentSessions: [paymentSession], nextToken: "page-3" }, + run: (client: CorePaymentClient, managerId = MANAGER_ID) => + client.listPaymentSessions({ ...sessionList, managerId }, options), + }, + { + command: GetPaymentInstrumentCommand, + input: instrument, + response: { paymentInstrument }, + run: (client: CorePaymentClient, managerId = MANAGER_ID) => + client.getPaymentInstrument({ ...instrument, managerId }, options), + }, + { + command: ListPaymentInstrumentsCommand, + input: instrumentList, + response: { paymentInstruments: [paymentInstrument], nextToken: "page-3" }, + run: (client: CorePaymentClient, managerId = MANAGER_ID) => + client.listPaymentInstruments({ ...instrumentList, managerId }, options), + }, + { + command: GetPaymentInstrumentBalanceCommand, + input: balance, + response: balanceResponse, + run: (client: CorePaymentClient, managerId = MANAGER_ID) => + client.getPaymentInstrumentBalance({ ...balance, managerId }, options), + }, +]; + +for (const { command, input, response, run } of dataReads) { + describe(`PaymentClient ${command.name}`, () => { + test("resolves the ID once before sending the unchanged request with the returned ARN", async () => { + const calls: string[] = []; + const { client, control, data, controlSend, dataSend } = paymentClient({ + control: async (sent) => { + calls.push(sent.constructor.name); + expect(sent).toBeInstanceOf(GetPaymentManagerCommand); + expect(sent.input).toEqual({ paymentManagerId: MANAGER_ID }); + return manager; + }, + data: async (sent) => { + calls.push(sent.constructor.name); + expect(sent).toBeInstanceOf(command); + const { managerId: _managerId, ...request } = input; + expect(sent.input).toEqual({ ...request, paymentManagerArn: MANAGER_ARN }); + expect(sent.input).not.toHaveProperty("managerId"); + return response; + }, + }); + + await expect(run(client)).resolves.toBe(response); + expect(calls).toEqual(["GetPaymentManagerCommand", command.name]); + expect(controlSend).toHaveBeenCalledTimes(1); + expect(dataSend).toHaveBeenCalledTimes(1); + expect(control).toHaveBeenCalledWith(config); + expect(data).toHaveBeenCalledWith(config); + expect(input).toHaveProperty("managerId", MANAGER_ID); + expect(input).not.toHaveProperty("paymentManagerArn"); + }); + + test("rejects an ARN used as an ID before configuring either SDK client", async () => { + const { client, control, data } = paymentClient(); + const failure = run(client, MANAGER_ARN); + await expect(failure).rejects.toBeInstanceOf(InputValidationError); + await expect(failure).rejects.toThrow(/manager ID, not an ARN/); + expect(control).not.toHaveBeenCalled(); + expect(data).not.toHaveBeenCalled(); + }); + + test("rejects CUSTOM_JWT before configuring the data client", async () => { + const { client, controlSend, data } = paymentClient({ + control: async () => ({ ...manager, authorizerType: "CUSTOM_JWT" }), + }); + const failure = run(client); + await expect(failure).rejects.toBeInstanceOf(InputValidationError); + 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(controlSend).toHaveBeenCalledTimes(1); + expect(data).not.toHaveBeenCalled(); + }); + + test.each([undefined, ""])("rejects a missing manager ARN (%s)", async (paymentManagerArn) => { + const { client, controlSend, data } = paymentClient({ + control: async () => ({ ...manager, paymentManagerArn }), + }); + const failure = run(client); + await expect(failure).rejects.toBeInstanceOf(MalformedServiceResponseError); + await expect(failure).rejects.toThrow(/returned no ARN/); + expect(controlSend).toHaveBeenCalledTimes(1); + expect(data).not.toHaveBeenCalled(); + }); + + test.each(["ResourceNotFoundException", "AccessDeniedException"])( + "preserves a lookup %s and stops before data-plane access", + async (name) => { + const error = serviceError(name, "GetPaymentManager failed"); + const { client, control, controlSend, data } = paymentClient({ + control: async () => { + throw error; + }, + }); + await expect(run(client)).rejects.toBe(error); + expect(control).toHaveBeenCalledWith(config); + expect(controlSend).toHaveBeenCalledTimes(1); + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each(["AccessDeniedException", "ValidationException", "ThrottlingException"])( + "preserves a data-plane %s without a second lookup or fallback response", + async (name) => { + const error = serviceError(name, "data-plane failure"); + const { client, controlSend, dataSend } = paymentClient({ + control: async () => manager, + data: async () => { + throw error; + }, + }); + await expect(run(client)).rejects.toBe(error); + expect(controlSend).toHaveBeenCalledTimes(1); + expect(dataSend).toHaveBeenCalledTimes(1); + }, + ); + }); +} + +describe("PaymentClient read boundaries", () => { + test("balance retains an atomic amount beyond numeric precision and its decimals", async () => { + const { client } = paymentClient({ + control: async () => manager, + data: async () => balanceResponse, + }); + const result = await client.getPaymentInstrumentBalance(balance, options); + expect(result).toBe(balanceResponse); + expect(result.tokenBalance?.amount).toBe("123456789012345678901234567890"); + expect(result.tokenBalance?.decimals).toBe(6); + }); + + test("each call resolves the manager again and uses the latest returned ARN", async () => { + const latestArn = + "arn:aws:bedrock-agentcore:eu-west-1:123456789012:payment-manager/checkout-current"; + let lookups = 0; + const { client, controlSend, data, dataSend } = paymentClient({ + control: async () => ({ + ...manager, + paymentManagerArn: ++lookups === 1 ? MANAGER_ARN : latestArn, + }), + data: async () => balanceResponse, + }); + await client.getPaymentInstrumentBalance(balance, options); + await client.getPaymentInstrumentBalance(balance, options); + expect(controlSend).toHaveBeenCalledTimes(2); + const { managerId: _managerId, ...request } = balance; + expect(dataSend.mock.calls.map(([command]) => command.input)).toEqual([ + { ...request, paymentManagerArn: MANAGER_ARN }, + { ...request, paymentManagerArn: latestArn }, + ]); + expect(data.mock.calls.map(([clientConfig]) => clientConfig)).toEqual([config, config]); + }); + + test("does not cache manager authorization across calls", async () => { + let lookups = 0; + const { client, controlSend, dataSend } = paymentClient({ + control: async () => ({ + ...manager, + authorizerType: ++lookups === 1 ? "AWS_IAM" : "CUSTOM_JWT", + }), + data: async () => balanceResponse, + }); + await client.getPaymentInstrumentBalance(balance, options); + await expect(client.getPaymentInstrumentBalance(balance, options)).rejects.toThrow( + /CUSTOM_JWT/, + ); + expect(controlSend).toHaveBeenCalledTimes(2); + expect(dataSend).toHaveBeenCalledTimes(1); + }); + + test("session and instrument lists preserve optional field omission and default client context", async () => { + const { client, control, data, dataSend } = paymentClient({ + control: async () => manager, + data: async () => ({}), + }); + await client.listPaymentSessions({ managerId: MANAGER_ID }, { region: "us-west-2" }); + await client.listPaymentInstruments({ managerId: MANAGER_ID }, { region: "us-west-2" }); + expect(dataSend.mock.calls.map(([command]) => command.input)).toEqual([ + { paymentManagerArn: MANAGER_ARN }, + { paymentManagerArn: MANAGER_ARN }, + ]); + expect(control.mock.calls.map(([clientConfig]) => clientConfig)).toEqual([ + { region: "us-west-2" }, + { region: "us-west-2" }, + ]); + expect(data.mock.calls.map(([clientConfig]) => clientConfig)).toEqual([ + { region: "us-west-2" }, + { region: "us-west-2" }, + ]); + }); +}); diff --git a/src/core/payment.tsx b/src/core/payment.tsx new file mode 100644 index 000000000..e9ac0affe --- /dev/null +++ b/src/core/payment.tsx @@ -0,0 +1,165 @@ +import { + GetPaymentConnectorCommand, + GetPaymentManagerCommand, + ListPaymentConnectorsCommand, + ListPaymentManagersCommand, + type GetPaymentConnectorResponse, + type GetPaymentManagerResponse, + type ListPaymentConnectorsResponse, + type ListPaymentManagersResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + GetPaymentInstrumentBalanceCommand, + GetPaymentInstrumentCommand, + GetPaymentSessionCommand, + ListPaymentInstrumentsCommand, + ListPaymentSessionsCommand, + type BedrockAgentCoreClient, + type GetPaymentInstrumentResponse, + type GetPaymentInstrumentBalanceResponse, + type GetPaymentSessionResponse, + type ListPaymentInstrumentsResponse, + type ListPaymentSessionsResponse, +} from "@aws-sdk/client-bedrock-agentcore"; +import { InputValidationError, MalformedServiceResponseError } from "../errors"; +import type { + CorePaymentClient, + GetPaymentSessionInput, + ListPaymentSessionsInput, + GetPaymentInstrumentInput, + GetPaymentInstrumentBalanceInput, + ListPaymentInstrumentsInput, +} from "../handlers/payment/types"; +import type { AwsClients, CoreOptions } from "./types"; +import { toClientConfig } from "./utils"; + +// 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 managers ─────────────────────────────────────────────────────── + + async getPaymentManager(id: string, options: CoreOptions): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new GetPaymentManagerCommand({ paymentManagerId: id })); + } + + async listPaymentManagers( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new ListPaymentManagersCommand({ nextToken, maxResults })); + } + + // ─── payment connectors ───────────────────────────────────────────────────── + + async getPaymentConnector( + managerId: string, + connectorId: string, + options: CoreOptions, + ): Promise { + return this.clients.control(toClientConfig(options)).send( + new GetPaymentConnectorCommand({ + paymentManagerId: managerId, + paymentConnectorId: connectorId, + }), + ); + } + + async listPaymentConnectors( + managerId: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send( + new ListPaymentConnectorsCommand({ paymentManagerId: managerId, nextToken, maxResults }), + ); + } + + // ─── payment sessions (data plane) ────────────────────────────────────────── + + async getPaymentSession( + input: GetPaymentSessionInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new GetPaymentSessionCommand({ paymentManagerArn, ...request })), + ); + } + + async listPaymentSessions( + input: ListPaymentSessionsInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new ListPaymentSessionsCommand({ paymentManagerArn, ...request })), + ); + } + + // ─── payment instruments (data plane) ─────────────────────────────────────── + + async getPaymentInstrument( + input: GetPaymentInstrumentInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new GetPaymentInstrumentCommand({ paymentManagerArn, ...request })), + ); + } + + async getPaymentInstrumentBalance( + input: GetPaymentInstrumentBalanceInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new GetPaymentInstrumentBalanceCommand({ paymentManagerArn, ...request })), + ); + } + + async listPaymentInstruments( + input: ListPaymentInstrumentsInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.sendData(managerId, options, (data, paymentManagerArn) => + data.send(new ListPaymentInstrumentsCommand({ paymentManagerArn, ...request })), + ); + } + + // ─── helpers ──────────────────────────────────────────────────────────────── + + private async sendData( + managerId: string, + options: CoreOptions, + send: (data: BedrockAgentCoreClient, paymentManagerArn: string) => Promise, + ): Promise { + if (managerId.startsWith("arn:")) { + throw new InputValidationError("use a payment manager ID, not an ARN"); + } + const manager = await this.getPaymentManager(managerId, options); + if (manager.authorizerType === "CUSTOM_JWT") { + throw new InputValidationError( + `payment manager "${managerId}" uses the CUSTOM_JWT authorizer, so its data plane accepts only bearer tokens; ` + + "this CLI does not support bearer tokens for payment commands yet. " + + "Use an AWS_IAM payment manager, or call the API directly with a JWT.", + ); + } + if (!manager.paymentManagerArn) { + throw new MalformedServiceResponseError(`payment manager "${managerId}" returned no ARN`); + } + return send(this.clients.data(toClientConfig(options)), manager.paymentManagerArn); + } +} diff --git a/src/handlers/payment/types.tsx b/src/handlers/payment/types.tsx new file mode 100644 index 000000000..7456a5389 --- /dev/null +++ b/src/handlers/payment/types.tsx @@ -0,0 +1,70 @@ +import type { + GetPaymentConnectorResponse, + GetPaymentManagerResponse, + ListPaymentConnectorsResponse, + ListPaymentManagersResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { + GetPaymentInstrumentRequest, + GetPaymentInstrumentResponse, + GetPaymentInstrumentBalanceRequest, + GetPaymentInstrumentBalanceResponse, + GetPaymentSessionRequest, + GetPaymentSessionResponse, + ListPaymentInstrumentsRequest, + ListPaymentInstrumentsResponse, + ListPaymentSessionsRequest, + ListPaymentSessionsResponse, +} from "@aws-sdk/client-bedrock-agentcore"; +import type { CoreOptions } from "../../core/types"; + +type WithPaymentManagerId = Omit & { managerId: string }; + +export type GetPaymentSessionInput = WithPaymentManagerId; +export type ListPaymentSessionsInput = WithPaymentManagerId; +export type GetPaymentInstrumentInput = WithPaymentManagerId; +export type GetPaymentInstrumentBalanceInput = + WithPaymentManagerId; +export type ListPaymentInstrumentsInput = WithPaymentManagerId; + +export interface CorePaymentClient { + getPaymentManager(id: string, options: CoreOptions): Promise; + listPaymentManagers( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise; + getPaymentConnector( + managerId: string, + connectorId: string, + options: CoreOptions, + ): Promise; + listPaymentConnectors( + managerId: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise; + + // Core resolves the selected manager ID to the ARN required by the data plane. + getPaymentSession( + request: GetPaymentSessionInput, + options: CoreOptions, + ): Promise; + listPaymentSessions( + request: ListPaymentSessionsInput, + options: CoreOptions, + ): Promise; + getPaymentInstrument( + request: GetPaymentInstrumentInput, + options: CoreOptions, + ): Promise; + getPaymentInstrumentBalance( + request: GetPaymentInstrumentBalanceInput, + options: CoreOptions, + ): Promise; + listPaymentInstruments( + request: ListPaymentInstrumentsInput, + options: CoreOptions, + ): Promise; +} diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index c41a301cb..5f92616ab 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -4,6 +4,7 @@ import type { CorePolicyClient } from "./gateway/policy/types.tsx"; import type { CoreHarnessClient } from "./harness/types.tsx"; import type { CoreIdentityClient } from "./identity/types.tsx"; import type { CoreMemoryClient } from "./memory/types.tsx"; +import type { CorePaymentClient } from "./payment/types.tsx"; import type { CoreObservabilityClient, CoreRuntimeClient } from "./runtime/types.tsx"; import type { Context } from "../router"; import type { CoreFetch } from "../core/types"; @@ -19,6 +20,7 @@ export interface Core { eval: CoreEvalClient; observability: CoreObservabilityClient; policy: CorePolicyClient; + payment: CorePaymentClient; projectManager: ProjectManager; /** Imports an alias-pinned Bedrock Agent definition into owned runtime code. */ bedrockAgentImporter: CoreBedrockAgentImporter; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index cf3bdd18e..78abdd9c2 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -28,6 +28,11 @@ import type { GetMemoryOutput, ListApiKeyCredentialProvidersResponse, ListOauth2CredentialProvidersResponse, + ListPaymentCredentialProvidersResponse, + GetPaymentConnectorResponse, + GetPaymentManagerResponse, + ListPaymentConnectorsResponse, + ListPaymentManagersResponse, ListAgentRuntimeEndpointsResponse, ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, @@ -136,6 +141,21 @@ import type { UpdateApiKeyCredentialProviderInput, UpdateOauth2CredentialProviderInput, } from "../handlers/identity/types"; +import type { + GetPaymentInstrumentResponse, + GetPaymentInstrumentBalanceResponse, + GetPaymentSessionResponse, + ListPaymentInstrumentsResponse, + ListPaymentSessionsResponse, +} from "@aws-sdk/client-bedrock-agentcore"; +import type { + CorePaymentClient, + GetPaymentSessionInput, + ListPaymentSessionsInput, + GetPaymentInstrumentInput, + GetPaymentInstrumentBalanceInput, + ListPaymentInstrumentsInput, +} from "../handlers/payment/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; import type { CloudWatchLogEvent, @@ -260,6 +280,26 @@ const DEFAULT_CREATE_PAYMENT_RESPONSE = {} as CreatePaymentCredentialProviderRes const DEFAULT_GET_PAYMENT_RESPONSE = {} as GetPaymentCredentialProviderResponse; const DEFAULT_UPDATE_PAYMENT_RESPONSE = {} as UpdatePaymentCredentialProviderResponse; const DEFAULT_DELETE_PAYMENT_RESPONSE = {} as DeletePaymentCredentialProviderResponse; +const DEFAULT_LIST_PAYMENT_PROVIDERS_RESPONSE: ListPaymentCredentialProvidersResponse = { + credentialProviders: [], +}; +const DEFAULT_GET_PAYMENT_MANAGER_RESPONSE = {} as GetPaymentManagerResponse; +const DEFAULT_LIST_PAYMENT_MANAGERS_RESPONSE: ListPaymentManagersResponse = { + paymentManagers: [], +}; +const DEFAULT_GET_PAYMENT_CONNECTOR_RESPONSE = {} as GetPaymentConnectorResponse; +const DEFAULT_LIST_PAYMENT_CONNECTORS_RESPONSE: ListPaymentConnectorsResponse = { + paymentConnectors: [], +}; +const DEFAULT_GET_PAYMENT_SESSION_RESPONSE = {} as GetPaymentSessionResponse; +const DEFAULT_LIST_PAYMENT_SESSIONS_RESPONSE: ListPaymentSessionsResponse = { + paymentSessions: [], +}; +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_GET_MEMORY_RESPONSE = {} as GetMemoryOutput; const DEFAULT_LIST_MEMORIES_RESPONSE: ListMemoriesOutput = { memories: [] }; const DEFAULT_GET_EVENT_RESPONSE: GetEventOutput = { event: undefined }; @@ -1317,6 +1357,10 @@ export class TestIdentityClient implements CoreIdentityClient { private updateOauth2Response: UpdateOauth2CredentialProviderResponse = DEFAULT_UPDATE_OAUTH2_RESPONSE; private getPaymentResponse: GetPaymentCredentialProviderResponse = DEFAULT_GET_PAYMENT_RESPONSE; + private listPaymentResponses = new Map< + string | undefined, + ListPaymentCredentialProvidersResponse + >(); private error?: Error; setGetPaymentResponse(response: GetPaymentCredentialProviderResponse): this { @@ -1324,6 +1368,14 @@ export class TestIdentityClient implements CoreIdentityClient { return this; } + setListPaymentResponse( + response: ListPaymentCredentialProvidersResponse, + forNextToken?: string, + ): this { + this.listPaymentResponses.set(forNextToken, response); + return this; + } + setGetApiKeyResponse(response: GetApiKeyCredentialProviderResponse): this { this.getApiKeyResponse = response; return this; @@ -1501,6 +1553,181 @@ export class TestIdentityClient implements CoreIdentityClient { if (this.error) throw this.error; return DEFAULT_DELETE_PAYMENT_RESPONSE; } + + async listPaymentCredentialProviders( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + this.calls.push({ + method: "listPaymentCredentialProviders", + args: [nextToken, maxResults, options], + }); + if (this.error) throw this.error; + return this.listPaymentResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_PROVIDERS_RESPONSE; + } +} + +// TestPaymentClient is the payment sub-client of TestCoreClient. Managers and +// connectors follow the harness shape (id arguments, list responses keyed by the +// requesting nextToken); the data-plane methods take SDK requests and key their +// list responses by the request's nextToken. +export class TestPaymentClient implements CorePaymentClient { + readonly calls: RecordedCall[] = []; + private getManagerResponse = DEFAULT_GET_PAYMENT_MANAGER_RESPONSE; + private listManagersResponses = new Map(); + private getConnectorResponse = DEFAULT_GET_PAYMENT_CONNECTOR_RESPONSE; + private listConnectorsResponses = new Map(); + private getSessionResponse = DEFAULT_GET_PAYMENT_SESSION_RESPONSE; + private listSessionsResponses = new Map(); + private getInstrumentResponse = DEFAULT_GET_PAYMENT_INSTRUMENT_RESPONSE; + private getInstrumentBalanceResponse = DEFAULT_GET_PAYMENT_INSTRUMENT_BALANCE_RESPONSE; + private listInstrumentsResponses = new Map(); + private error?: Error; + + setGetManagerResponse(response: GetPaymentManagerResponse): this { + this.getManagerResponse = response; + return this; + } + + setListManagersResponse(response: ListPaymentManagersResponse, forNextToken?: string): this { + this.listManagersResponses.set(forNextToken, response); + return this; + } + + setGetConnectorResponse(response: GetPaymentConnectorResponse): this { + this.getConnectorResponse = response; + return this; + } + + setListConnectorsResponse(response: ListPaymentConnectorsResponse, forNextToken?: string): this { + this.listConnectorsResponses.set(forNextToken, response); + return this; + } + + setGetSessionResponse(response: GetPaymentSessionResponse): this { + this.getSessionResponse = response; + return this; + } + + setListSessionsResponse(response: ListPaymentSessionsResponse, forNextToken?: string): this { + this.listSessionsResponses.set(forNextToken, response); + return this; + } + + setGetInstrumentResponse(response: GetPaymentInstrumentResponse): this { + this.getInstrumentResponse = response; + return this; + } + + setGetInstrumentBalanceResponse(response: GetPaymentInstrumentBalanceResponse): this { + this.getInstrumentBalanceResponse = response; + return this; + } + + setListInstrumentsResponse( + response: ListPaymentInstrumentsResponse, + forNextToken?: string, + ): this { + this.listInstrumentsResponses.set(forNextToken, response); + return this; + } + + // setError makes every subsequent call reject with `error` (undefined clears). + setError(error: Error | undefined): this { + this.error = error; + return this; + } + + async getPaymentManager(id: string, options: CoreOptions): Promise { + this.calls.push({ method: "getPaymentManager", args: [id, options] }); + if (this.error) throw this.error; + return this.getManagerResponse; + } + + async listPaymentManagers( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "listPaymentManagers", args: [nextToken, maxResults, options] }); + if (this.error) throw this.error; + return this.listManagersResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_MANAGERS_RESPONSE; + } + + async getPaymentConnector( + managerId: string, + connectorId: string, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "getPaymentConnector", args: [managerId, connectorId, options] }); + if (this.error) throw this.error; + return this.getConnectorResponse; + } + + async listPaymentConnectors( + managerId: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + this.calls.push({ + method: "listPaymentConnectors", + args: [managerId, nextToken, maxResults, options], + }); + if (this.error) throw this.error; + return this.listConnectorsResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_CONNECTORS_RESPONSE; + } + + async getPaymentSession( + request: GetPaymentSessionInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "getPaymentSession", args: [request, options] }); + if (this.error) throw this.error; + return this.getSessionResponse; + } + + async listPaymentSessions( + request: ListPaymentSessionsInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "listPaymentSessions", args: [request, options] }); + if (this.error) throw this.error; + return ( + this.listSessionsResponses.get(request.nextToken) ?? DEFAULT_LIST_PAYMENT_SESSIONS_RESPONSE + ); + } + + async getPaymentInstrument( + request: GetPaymentInstrumentInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "getPaymentInstrument", args: [request, options] }); + if (this.error) throw this.error; + return this.getInstrumentResponse; + } + + async getPaymentInstrumentBalance( + request: GetPaymentInstrumentBalanceInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "getPaymentInstrumentBalance", args: [request, options] }); + if (this.error) throw this.error; + return this.getInstrumentBalanceResponse; + } + + async listPaymentInstruments( + request: ListPaymentInstrumentsInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "listPaymentInstruments", args: [request, options] }); + if (this.error) throw this.error; + return ( + this.listInstrumentsResponses.get(request.nextToken) ?? + DEFAULT_LIST_PAYMENT_INSTRUMENTS_RESPONSE + ); + } } // TestEvalClient is the eval sub-client of TestCoreClient. @@ -2496,6 +2723,7 @@ export class TestPolicyClient implements CorePolicyClient { export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); readonly identity = new TestIdentityClient(); + readonly payment = new TestPaymentClient(); readonly memory = new TestMemoryClient(); readonly runtime = new TestRuntimeClient(); readonly gateway = new TestGatewayClient(); diff --git a/src/testing/index.tsx b/src/testing/index.tsx index d99b7bcb7..0893c4e4a 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -15,6 +15,7 @@ export { TestHarnessClient, TestIdentityClient, TestMemoryClient, + TestPaymentClient, TestRuntimeClient, TestEvalClient, type RecordedCall, From 11bb400e88e160ab2a20f58d01fd1f287d62e9bd Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 19:49:55 +0000 Subject: [PATCH 2/6] feat(payment): add manager and connector read commands --- ...aymentManagerCommand.894895e0c24c9098.json | 18 +++ ...aymentManagerCommand.c9616c307ec6a4ea.json | 17 +++ ...aymentManagerCommand.d806a2d236e5ec0c.json | 17 +++ ...ymentManagersCommand.23f97c9dcdd6350b.json | 103 ++++++++++++++++++ ...ymentConnectorCommand.9f8dfd59b8af870.json | 20 ++++ ...ntialProviderCommand.9b6249ebbbb54d1a.json | 24 ++++ ...entConnectorsCommand.c9616c307ec6a4ea.json | 16 +++ .../connector/connector-get.golden.json | 16 +++ .../connector/connector-list.golden.json | 16 +++ .../__fixtures__/manager-get.golden.json | 13 +++ .../__fixtures__/manager-list.golden.json | 75 +++++++++++++ src/handlers/payment/connector/get/index.tsx | 47 ++++++++ src/handlers/payment/connector/index.tsx | 13 +++ src/handlers/payment/connector/list/index.tsx | 33 ++++++ src/handlers/payment/manager/get/index.tsx | 22 ++++ src/handlers/payment/manager/index.tsx | 13 +++ src/handlers/payment/manager/list/index.tsx | 26 +++++ 17 files changed, 489 insertions(+) create mode 100644 src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json create mode 100644 src/handlers/payment/__fixtures__/GetPaymentManagerCommand.c9616c307ec6a4ea.json create mode 100644 src/handlers/payment/__fixtures__/GetPaymentManagerCommand.d806a2d236e5ec0c.json create mode 100644 src/handlers/payment/__fixtures__/ListPaymentManagersCommand.23f97c9dcdd6350b.json create mode 100644 src/handlers/payment/__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json create mode 100644 src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json create mode 100644 src/handlers/payment/__fixtures__/connector/ListPaymentConnectorsCommand.c9616c307ec6a4ea.json create mode 100644 src/handlers/payment/__fixtures__/connector/connector-get.golden.json create mode 100644 src/handlers/payment/__fixtures__/connector/connector-list.golden.json create mode 100644 src/handlers/payment/__fixtures__/manager-get.golden.json create mode 100644 src/handlers/payment/__fixtures__/manager-list.golden.json create mode 100644 src/handlers/payment/connector/get/index.tsx create mode 100644 src/handlers/payment/connector/index.tsx create mode 100644 src/handlers/payment/connector/list/index.tsx create mode 100644 src/handlers/payment/manager/get/index.tsx create mode 100644 src/handlers/payment/manager/index.tsx create mode 100644 src/handlers/payment/manager/list/index.tsx diff --git a/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json new file mode 100644 index 000000000..16c918232 --- /dev/null +++ b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json @@ -0,0 +1,18 @@ +{ + "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__/GetPaymentManagerCommand.c9616c307ec6a4ea.json b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.c9616c307ec6a4ea.json new file mode 100644 index 000000000..c6c5a83b1 --- /dev/null +++ b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.c9616c307ec6a4ea.json @@ -0,0 +1,17 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "MyPaymentManagerAidandal", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCore-PayBash-default-PaymentMyPaymentManagerAi-gyKcmOGL9YyA", + "createdAt": { + "$date": "2026-06-08T18:11:41.129Z" + }, + "lastUpdatedAt": { + "$date": "2026-06-08T18:11:41.129Z" + }, + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:workload-identity-directory/default/workload-identity/mypaymentmanageraidandal-gx3nxzaira" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.d806a2d236e5ec0c.json b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.d806a2d236e5ec0c.json new file mode 100644 index 000000000..ead47a54f --- /dev/null +++ b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.d806a2d236e5ec0c.json @@ -0,0 +1,17 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanager-o4ks3qfgtb", + "paymentManagerId": "mypaymentmanager-o4ks3qfgtb", + "name": "MyPaymentManager", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/paytest_PaymentRetrievalRole", + "createdAt": { + "$date": "2026-05-05T03:02:59.201Z" + }, + "lastUpdatedAt": { + "$date": "2026-05-05T03:02:59.201Z" + }, + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:workload-identity-directory/default/workload-identity/mypaymentmanager-o4ks3qfgtb" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/ListPaymentManagersCommand.23f97c9dcdd6350b.json b/src/handlers/payment/__fixtures__/ListPaymentManagersCommand.23f97c9dcdd6350b.json new file mode 100644 index 000000000..a3c9eaa3a --- /dev/null +++ b/src/handlers/payment/__fixtures__/ListPaymentManagersCommand.23f97c9dcdd6350b.json @@ -0,0 +1,103 @@ +{ + "paymentManagers": [ + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/brf6410be0-56loajl19z", + "paymentManagerId": "brf6410be0-56loajl19z", + "name": "brf6410be0", + "authorizerType": "CUSTOM_JWT", + "roleArn": "arn:aws:iam::603141041947:role/pr3rolef6410be0", + "status": "READY", + "lastUpdatedAt": { + "$date": "2026-06-02T16:59:01.552Z" + }, + "description": "r", + "createdAt": { + "$date": "2026-06-02T16:59:01.552Z" + } + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/cfn19maymgr-mncapbffse", + "paymentManagerId": "cfn19maymgr-mncapbffse", + "name": "cfn19maymgr", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/payments-cfn-verify-PaymentMgrRole-MxByV7bxfBVy", + "status": "READY", + "lastUpdatedAt": { + "$date": "2026-05-19T18:43:39.589Z" + }, + "createdAt": { + "$date": "2026-05-19T18:43:39.589Z" + } + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mgr1-mwpbvze4pu", + "paymentManagerId": "mgr1-mwpbvze4pu", + "name": "mgr1", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/Edge2_PaymentRetrievalRole", + "status": "READY", + "lastUpdatedAt": { + "$date": "2026-05-05T15:35:08.042Z" + }, + "createdAt": { + "$date": "2026-05-05T15:35:08.042Z" + } + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanager-o4ks3qfgtb", + "paymentManagerId": "mypaymentmanager-o4ks3qfgtb", + "name": "MyPaymentManager", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/paytest_PaymentRetrievalRole", + "status": "READY", + "lastUpdatedAt": { + "$date": "2026-05-05T03:02:59.201Z" + }, + "createdAt": { + "$date": "2026-05-05T03:02:59.201Z" + } + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "MyPaymentManagerAidandal", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCore-PayBash-default-PaymentMyPaymentManagerAi-gyKcmOGL9YyA", + "status": "READY", + "lastUpdatedAt": { + "$date": "2026-06-08T18:11:41.129Z" + }, + "createdAt": { + "$date": "2026-06-08T18:11:41.129Z" + } + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/paymgr-tt8ivlyv76", + "paymentManagerId": "paymgr-tt8ivlyv76", + "name": "paymgr", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCore-BugBashGW-defau-PaymentpaymgrResourceRetr-wv9Le7KK724Y", + "status": "READY", + "lastUpdatedAt": { + "$date": "2026-05-21T21:28:13.357Z" + }, + "createdAt": { + "$date": "2026-05-21T21:28:13.357Z" + } + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/pmgr-amdytlqv7l", + "paymentManagerId": "pmgr-amdytlqv7l", + "name": "pmgr", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCore-BugBashFresh-de-PaymentpmgrResourceRetrie-DJKtx7oJy58m", + "status": "READY", + "lastUpdatedAt": { + "$date": "2026-05-22T02:27:29.922Z" + }, + "createdAt": { + "$date": "2026-05-22T02:27:29.922Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json b/src/handlers/payment/__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json new file mode 100644 index 000000000..6db03fc63 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json @@ -0,0 +1,20 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "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" + }, + "lastUpdatedAt": { + "$date": "2026-09-08T20:14:32.139Z" + }, + "status": "READY", + "description": "Updated by the agentcore CLI end-to-end test" +} \ 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 new file mode 100644 index 000000000..55e0e51e0 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json @@ -0,0 +1,24 @@ +{ + "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/ListPaymentConnectorsCommand.c9616c307ec6a4ea.json b/src/handlers/payment/__fixtures__/connector/ListPaymentConnectorsCommand.c9616c307ec6a4ea.json new file mode 100644 index 000000000..f6666ada4 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/ListPaymentConnectorsCommand.c9616c307ec6a4ea.json @@ -0,0 +1,16 @@ +{ + "paymentConnectors": [ + { + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "status": "READY" + }, + { + "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", + "name": "MyCdpConnectorAidandal", + "type": "CoinbaseCDP", + "status": "READY" + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-get.golden.json b/src/handlers/payment/__fixtures__/connector/connector-get.golden.json new file mode 100644 index 000000000..bc91b1af6 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-get.golden.json @@ -0,0 +1,16 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "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", + "lastUpdatedAt": "2026-09-08T20:14:32.139Z", + "status": "READY", + "description": "Updated by the agentcore CLI end-to-end test" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-list.golden.json b/src/handlers/payment/__fixtures__/connector/connector-list.golden.json new file mode 100644 index 000000000..f6666ada4 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-list.golden.json @@ -0,0 +1,16 @@ +{ + "paymentConnectors": [ + { + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "status": "READY" + }, + { + "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", + "name": "MyCdpConnectorAidandal", + "type": "CoinbaseCDP", + "status": "READY" + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-get.golden.json b/src/handlers/payment/__fixtures__/manager-get.golden.json new file mode 100644 index 000000000..3821d1548 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-get.golden.json @@ -0,0 +1,13 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanager-o4ks3qfgtb", + "paymentManagerId": "mypaymentmanager-o4ks3qfgtb", + "name": "MyPaymentManager", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/paytest_PaymentRetrievalRole", + "createdAt": "2026-05-05T03:02:59.201Z", + "lastUpdatedAt": "2026-05-05T03:02:59.201Z", + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:workload-identity-directory/default/workload-identity/mypaymentmanager-o4ks3qfgtb" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-list.golden.json b/src/handlers/payment/__fixtures__/manager-list.golden.json new file mode 100644 index 000000000..5a0f00835 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-list.golden.json @@ -0,0 +1,75 @@ +{ + "paymentManagers": [ + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/brf6410be0-56loajl19z", + "paymentManagerId": "brf6410be0-56loajl19z", + "name": "brf6410be0", + "authorizerType": "CUSTOM_JWT", + "roleArn": "arn:aws:iam::603141041947:role/pr3rolef6410be0", + "status": "READY", + "lastUpdatedAt": "2026-06-02T16:59:01.552Z", + "description": "r", + "createdAt": "2026-06-02T16:59:01.552Z" + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/cfn19maymgr-mncapbffse", + "paymentManagerId": "cfn19maymgr-mncapbffse", + "name": "cfn19maymgr", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/payments-cfn-verify-PaymentMgrRole-MxByV7bxfBVy", + "status": "READY", + "lastUpdatedAt": "2026-05-19T18:43:39.589Z", + "createdAt": "2026-05-19T18:43:39.589Z" + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mgr1-mwpbvze4pu", + "paymentManagerId": "mgr1-mwpbvze4pu", + "name": "mgr1", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/Edge2_PaymentRetrievalRole", + "status": "READY", + "lastUpdatedAt": "2026-05-05T15:35:08.042Z", + "createdAt": "2026-05-05T15:35:08.042Z" + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanager-o4ks3qfgtb", + "paymentManagerId": "mypaymentmanager-o4ks3qfgtb", + "name": "MyPaymentManager", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/paytest_PaymentRetrievalRole", + "status": "READY", + "lastUpdatedAt": "2026-05-05T03:02:59.201Z", + "createdAt": "2026-05-05T03:02:59.201Z" + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "MyPaymentManagerAidandal", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCore-PayBash-default-PaymentMyPaymentManagerAi-gyKcmOGL9YyA", + "status": "READY", + "lastUpdatedAt": "2026-06-08T18:11:41.129Z", + "createdAt": "2026-06-08T18:11:41.129Z" + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/paymgr-tt8ivlyv76", + "paymentManagerId": "paymgr-tt8ivlyv76", + "name": "paymgr", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCore-BugBashGW-defau-PaymentpaymgrResourceRetr-wv9Le7KK724Y", + "status": "READY", + "lastUpdatedAt": "2026-05-21T21:28:13.357Z", + "createdAt": "2026-05-21T21:28:13.357Z" + }, + { + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/pmgr-amdytlqv7l", + "paymentManagerId": "pmgr-amdytlqv7l", + "name": "pmgr", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCore-BugBashFresh-de-PaymentpmgrResourceRetrie-DJKtx7oJy58m", + "status": "READY", + "lastUpdatedAt": "2026-05-22T02:27:29.922Z", + "createdAt": "2026-05-22T02:27:29.922Z" + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/connector/get/index.tsx b/src/handlers/payment/connector/get/index.tsx new file mode 100644 index 000000000..54a548761 --- /dev/null +++ b/src/handlers/payment/connector/get/index.tsx @@ -0,0 +1,47 @@ +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"; + +export const createGetPaymentConnectorHandler = (core: Core, io: AppIO) => + createHandler({ + name: "get", + description: "get a payment connector by id", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("connector-id", "the payment connector id", 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 response = await core.payment.getPaymentConnector( + flags["manager-id"], + flags["connector-id"], + coreOptsFromCtx(ctx), + ); + ctx.require(JsonRendererKey).renderJson(response); + + // A lapsed or failed OAuth consent is terminal for a Quick Create + // connector: the service issues no second authorization URL. + if ( + !ctx.require(JsonKey) && + (response.status === "AUTHENTICATION_EXPIRED" || + response.status === "AUTHENTICATION_FAILED") + ) { + io.stderr.write( + `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 new file mode 100644 index 000000000..b66523054 --- /dev/null +++ b/src/handlers/payment/connector/index.tsx @@ -0,0 +1,13 @@ +import type { AppIO } from "../../../io"; +import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { Core } from "../../types"; +import { createGetPaymentConnectorHandler } from "./get"; +import { createListPaymentConnectorsHandler } from "./list"; + +export function createPaymentConnectorHandler(core: Core, io: AppIO): Router { + return new Router("connector", "manage connectors under a payment manager") + .default(renderTui(core, io)) + .handler(createGetPaymentConnectorHandler(core, io)) + .handler(createListPaymentConnectorsHandler(core)); +} diff --git a/src/handlers/payment/connector/list/index.tsx b/src/handlers/payment/connector/list/index.tsx new file mode 100644 index 000000000..f0b1b721b --- /dev/null +++ b/src/handlers/payment/connector/list/index.tsx @@ -0,0 +1,33 @@ +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 createListPaymentConnectorsHandler = (core: Core) => + createHandler({ + name: "list", + description: "list the connectors of a payment manager (server-side paginated)", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + flag("max-results", "maximum number of items to return", z.number().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.payment.listPaymentConnectors( + flags["manager-id"], + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/payment/manager/get/index.tsx b/src/handlers/payment/manager/get/index.tsx new file mode 100644 index 000000000..0b45637e6 --- /dev/null +++ b/src/handlers/payment/manager/get/index.tsx @@ -0,0 +1,22 @@ +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 createGetPaymentManagerHandler = (core: Core) => + createHandler({ + name: "get", + description: "get a payment manager by id", + flags: [flag("id", "the payment manager id", 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.getPaymentManager(flags.id, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/manager/index.tsx b/src/handlers/payment/manager/index.tsx new file mode 100644 index 000000000..1cd6b8652 --- /dev/null +++ b/src/handlers/payment/manager/index.tsx @@ -0,0 +1,13 @@ +import type { AppIO } from "../../../io"; +import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { Core } from "../../types"; +import { createGetPaymentManagerHandler } from "./get"; +import { createListPaymentManagersHandler } from "./list"; + +export function createPaymentManagerHandler(core: Core, io: AppIO): Router { + return new Router("manager", "manage AgentCore payment managers") + .default(renderTui(core, io)) + .handler(createGetPaymentManagerHandler(core)) + .handler(createListPaymentManagersHandler(core)); +} diff --git a/src/handlers/payment/manager/list/index.tsx b/src/handlers/payment/manager/list/index.tsx new file mode 100644 index 000000000..36cd0a56f --- /dev/null +++ b/src/handlers/payment/manager/list/index.tsx @@ -0,0 +1,26 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createListPaymentManagersHandler = (core: Core) => + createHandler({ + name: "list", + description: "list payment managers (server-side paginated)", + flags: [ + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + flag("max-results", "maximum number of items to return", z.number().optional()), + ], + handle: async (ctx, flags) => { + ctx + .require(JsonRendererKey) + .renderJson( + await core.payment.listPaymentManagers( + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); From 44a1af1f916e55d1c68ddadce397f196081b228d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 19:50:41 +0000 Subject: [PATCH 3/6] feat(payment): add session and instrument inspection commands --- ...entInstrumentCommand.1f6c9a8ae4328e61.json | 23 ++ ...ntInstrumentsCommand.df0e58fb9a8e3483.json | 18 + .../instrument/instrument-get.golden.json | 19 + .../instrument/instrument-list.golden.json | 14 + ...aymentSessionCommand.86f3e58b4b886322.json | 29 ++ ...ymentSessionsCommand.a9c4632c14ae0699.json | 16 + .../session/session-get.golden.json | 23 ++ .../session/session-list.golden.json | 12 + .../instrument/balance/balance.test.tsx | 366 ++++++++++++++++++ .../payment/instrument/balance/index.tsx | 74 ++++ src/handlers/payment/instrument/get/index.tsx | 53 +++ src/handlers/payment/instrument/index.tsx | 15 + .../payment/instrument/list/index.tsx | 51 +++ src/handlers/payment/session/get/index.tsx | 45 +++ src/handlers/payment/session/index.tsx | 13 + src/handlers/payment/session/list/index.tsx | 44 +++ 16 files changed, 815 insertions(+) create mode 100644 src/handlers/payment/__fixtures__/instrument/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json create mode 100644 src/handlers/payment/__fixtures__/instrument/ListPaymentInstrumentsCommand.df0e58fb9a8e3483.json create mode 100644 src/handlers/payment/__fixtures__/instrument/instrument-get.golden.json create mode 100644 src/handlers/payment/__fixtures__/instrument/instrument-list.golden.json create mode 100644 src/handlers/payment/__fixtures__/session/GetPaymentSessionCommand.86f3e58b4b886322.json create mode 100644 src/handlers/payment/__fixtures__/session/ListPaymentSessionsCommand.a9c4632c14ae0699.json create mode 100644 src/handlers/payment/__fixtures__/session/session-get.golden.json create mode 100644 src/handlers/payment/__fixtures__/session/session-list.golden.json create mode 100644 src/handlers/payment/instrument/balance/balance.test.tsx create mode 100644 src/handlers/payment/instrument/balance/index.tsx create mode 100644 src/handlers/payment/instrument/get/index.tsx create mode 100644 src/handlers/payment/instrument/index.tsx create mode 100644 src/handlers/payment/instrument/list/index.tsx create mode 100644 src/handlers/payment/session/get/index.tsx create mode 100644 src/handlers/payment/session/index.tsx create mode 100644 src/handlers/payment/session/list/index.tsx diff --git a/src/handlers/payment/__fixtures__/instrument/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json b/src/handlers/payment/__fixtures__/instrument/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json new file mode 100644 index 000000000..045ee3a27 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.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/ListPaymentInstrumentsCommand.df0e58fb9a8e3483.json b/src/handlers/payment/__fixtures__/instrument/ListPaymentInstrumentsCommand.df0e58fb9a8e3483.json new file mode 100644 index 000000000..d7db0fd2a --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/ListPaymentInstrumentsCommand.df0e58fb9a8e3483.json @@ -0,0 +1,18 @@ +{ + "paymentInstruments": [ + { + "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", + "status": "ACTIVE", + "createdAt": { + "$date": "2026-09-08T20:24:40.849Z" + }, + "updatedAt": { + "$date": "2026-09-08T20:24:41.673Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/instrument-get.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-get.golden.json new file mode 100644 index 000000000..468d5d808 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/instrument-get.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-list.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-list.golden.json new file mode 100644 index 000000000..52811605b --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/instrument-list.golden.json @@ -0,0 +1,14 @@ +{ + "paymentInstruments": [ + { + "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", + "status": "ACTIVE", + "createdAt": "2026-09-08T20:24:40.849Z", + "updatedAt": "2026-09-08T20:24:41.673Z" + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/GetPaymentSessionCommand.86f3e58b4b886322.json b/src/handlers/payment/__fixtures__/session/GetPaymentSessionCommand.86f3e58b4b886322.json new file mode 100644 index 000000000..9bc3a7437 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/GetPaymentSessionCommand.86f3e58b4b886322.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", + "currency": "USD" + } + }, + "availableLimits": { + "availableSpendAmount": { + "value": "1", + "currency": "USD" + }, + "updatedAt": { + "$date": "2026-09-08T20:20:28.697Z" + } + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/ListPaymentSessionsCommand.a9c4632c14ae0699.json b/src/handlers/payment/__fixtures__/session/ListPaymentSessionsCommand.a9c4632c14ae0699.json new file mode 100644 index 000000000..d73d725bc --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/ListPaymentSessionsCommand.a9c4632c14ae0699.json @@ -0,0 +1,16 @@ +{ + "paymentSessions": [ + { + "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" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-get.golden.json b/src/handlers/payment/__fixtures__/session/session-get.golden.json new file mode 100644 index 000000000..cd7ec06a6 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/session-get.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", + "currency": "USD" + } + }, + "availableLimits": { + "availableSpendAmount": { + "value": "1", + "currency": "USD" + }, + "updatedAt": "2026-09-08T20:20:28.697Z" + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-list.golden.json b/src/handlers/payment/__fixtures__/session/session-list.golden.json new file mode 100644 index 000000000..8c6e3c6ca --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/session-list.golden.json @@ -0,0 +1,12 @@ +{ + "paymentSessions": [ + { + "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" + } + ] +} \ No newline at end of file diff --git a/src/handlers/payment/instrument/balance/balance.test.tsx b/src/handlers/payment/instrument/balance/balance.test.tsx new file mode 100644 index 000000000..e2a7c80d9 --- /dev/null +++ b/src/handlers/payment/instrument/balance/balance.test.tsx @@ -0,0 +1,366 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + GetPaymentInstrumentBalanceCommand, + GetPaymentInstrumentCommand, + ResourceNotFoundException, + ValidationException, + type BedrockAgentCoreClient, + type GetPaymentInstrumentBalanceResponse, + type GetPaymentInstrumentResponse, +} from "@aws-sdk/client-bedrock-agentcore"; +import { + GetPaymentManagerCommand, + type BedrockAgentCoreControlClient, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { CoreClient, type ClientConfig } from "../../../../core"; +import { createRootHandler } from "../../../index"; +import { + createSilentLogger, + fixtureFactories, + TestGlobalConfigAccessor, + testIO, +} from "../../../../testing"; + +const FIXTURES = join(import.meta.dir, "..", "..", "__fixtures__", "instrument"); +const REGION = "us-west-2"; +const MANAGER_ID = "balance-manager"; +const MANAGER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/balance-manager"; +const USER_ID = "balance-user"; +const CONNECTOR_ID = "balance-connector"; +const INSTRUMENT_ID = "balance-instrument"; + +// Synthetic SDK response, not evidence of a live funded-wallet query. +const BALANCE_RESPONSE = { + paymentInstrumentId: INSTRUMENT_ID, + tokenBalance: { + amount: "9007199254740993123456789", + decimals: 6, + token: "USDC", + network: "ETHEREUM", + chain: "BASE", + }, +} satisfies GetPaymentInstrumentBalanceResponse; + +const requiredFlags = [ + ["--manager-id", MANAGER_ID], + ["--user-id", USER_ID], + ["--connector-id", CONNECTOR_ID], + ["--instrument-id", INSTRUMENT_ID], + ["--chain", "BASE"], +] as const; + +function createCommandTest({ + response = BALANCE_RESPONSE, + error, +}: { + response?: GetPaymentInstrumentBalanceResponse | GetPaymentInstrumentResponse; + error?: Error; +} = {}) { + const sent: { input: unknown }[] = []; + const configs: ClientConfig[] = []; + const lookups: GetPaymentManagerCommand[] = []; + const controlConfigs: ClientConfig[] = []; + const { createIamClient, createLogsClient } = fixtureFactories(FIXTURES); + const core = new CoreClient({ + createControlClient: (config) => { + controlConfigs.push(config); + return { + send: async (command: GetPaymentManagerCommand) => { + expect(command).toBeInstanceOf(GetPaymentManagerCommand); + lookups.push(command); + return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; + }, + } as unknown as BedrockAgentCoreControlClient; + }, + createDataClient: (config) => { + configs.push(config); + return { + send: async (command: { input: unknown }) => { + sent.push(command); + if (error) throw error; + return response; + }, + } as unknown as BedrockAgentCoreClient; + }, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + return { + io, + sent, + configs, + lookups, + controlConfigs, + run: (args: string[], region = REGION) => + root.route([ + "node", + "agentcore", + "payment", + "instrument", + ...args, + "--region", + region, + "--json", + ]), + }; +} + +describe("payment instrument balance", () => { + test("sends the balance command with exact scope and a default USDC token", async () => { + const { run, io, sent, lookups } = createCommandTest(); + await run(["balance", ...requiredFlags.flat()]); + + expect(lookups).toHaveLength(1); + expect(lookups[0]?.input).toStrictEqual({ paymentManagerId: MANAGER_ID }); + expect(sent).toHaveLength(1); + expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); + expect(sent[0]?.input).toStrictEqual({ + paymentManagerArn: MANAGER_ARN, + userId: USER_ID, + paymentConnectorId: CONNECTOR_ID, + paymentInstrumentId: INSTRUMENT_ID, + chain: "BASE", + token: "USDC", + }); + expect(JSON.parse(io.stdout())).toEqual(BALANCE_RESPONSE); + }); + + test("forwards an explicit token and optional agent name", async () => { + const { run, sent } = createCommandTest(); + await run([ + "balance", + ...requiredFlags.flat(), + "--token", + "USDC", + "--agent-name", + "balance-agent", + ]); + + expect(sent).toHaveLength(1); + expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); + expect(sent[0]?.input).toStrictEqual({ + paymentManagerArn: MANAGER_ARN, + userId: USER_ID, + paymentConnectorId: CONNECTOR_ID, + paymentInstrumentId: INSTRUMENT_ID, + chain: "BASE", + token: "USDC", + agentName: "balance-agent", + }); + }); + + test.each(["BASE", "BASE_SEPOLIA", "ETHEREUM", "SOLANA", "SOLANA_DEVNET"])( + "forwards the %s chain unchanged", + async (chain) => { + const { run, sent } = createCommandTest(); + await run([ + "balance", + ...requiredFlags.filter(([name]) => name !== "--chain").flat(), + "--chain", + chain, + ]); + + expect(sent).toHaveLength(1); + expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); + expect(sent[0]?.input).toStrictEqual({ + paymentManagerArn: MANAGER_ARN, + userId: USER_ID, + paymentConnectorId: CONNECTOR_ID, + paymentInstrumentId: INSTRUMENT_ID, + chain, + token: "USDC", + }); + }, + ); + + test("preserves atomic amounts beyond MAX_SAFE_INTEGER and token decimals in JSON", async () => { + const { run, io } = createCommandTest(); + await run(["balance", ...requiredFlags.flat()]); + + expect(JSON.parse(io.stdout())).toEqual({ + paymentInstrumentId: INSTRUMENT_ID, + tokenBalance: { + amount: "9007199254740993123456789", + decimals: 6, + token: "USDC", + network: "ETHEREUM", + chain: "BASE", + }, + }); + }); + + test("preserves a successful zero balance as the raw string", async () => { + const response: GetPaymentInstrumentBalanceResponse = { + ...BALANCE_RESPONSE, + tokenBalance: { ...BALANCE_RESPONSE.tokenBalance, amount: "0" }, + }; + const { run, io } = createCommandTest({ response }); + await run(["balance", ...requiredFlags.flat()]); + + expect(JSON.parse(io.stdout())).toEqual(response); + expect(JSON.parse(io.stdout()).tokenBalance.amount).toBe("0"); + }); + + test("forwards context region and endpoint to both SDK factories without deriving the ARN region", async () => { + const { run, configs, controlConfigs, sent } = createCommandTest(); + await run( + ["balance", ...requiredFlags.flat(), "--endpoint-url", "https://payments.example.test"], + "eu-west-1", + ); + + expect(configs).toEqual([{ region: "eu-west-1", endpoint: "https://payments.example.test" }]); + expect(controlConfigs).toEqual([ + { region: "eu-west-1", endpoint: "https://payments.example.test" }, + ]); + expect(sent).toHaveLength(1); + expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); + }); +}); + +describe("payment instrument balance validation", () => { + test("rejects the removed --manager-arn flag, including alongside --manager-id", async () => { + const { run, io, sent, lookups } = createCommandTest(); + for (const idArgs of [[], requiredFlags.flat()]) { + await expect(run(["balance", ...idArgs, "--manager-arn", MANAGER_ARN])).rejects.toThrow( + /unknown option '--manager-arn'/, + ); + } + expect(sent).toEqual([]); + expect(lookups).toEqual([]); + expect(io.stdout()).toBe(""); + }); + + test.each(["manager-id", "user-id", "connector-id", "instrument-id", "chain"])( + "requires --%s before calling the SDK", + async (name) => { + const { run, io, sent, configs, lookups } = createCommandTest(); + await expect( + run(["balance", ...requiredFlags.filter(([flag]) => flag !== `--${name}`).flat()]), + ).rejects.toThrow(`required option '--${name} <${name}>' not specified`); + + expect(sent).toEqual([]); + expect(configs).toEqual([]); + expect(lookups).toEqual([]); + expect(io.stdout()).toBe(""); + }, + ); + + test.each(["manager-id", "user-id", "connector-id", "instrument-id"])( + "rejects an explicitly empty --%s before calling the SDK", + async (name) => { + const { run, io, sent, configs, lookups } = createCommandTest(); + await expect( + run([ + "balance", + ...requiredFlags.map(([flag, value]) => [flag, flag === `--${name}` ? "" : value]).flat(), + ]), + ).rejects.toThrow(`required option '--${name} <${name}>' not specified`); + + expect(sent).toEqual([]); + expect(configs).toEqual([]); + expect(lookups).toEqual([]); + expect(io.stdout()).toBe(""); + }, + ); + + test.each(["BITCOIN", "base", "1", ""])("rejects invalid chain %j", async (chain) => { + const { run, io, sent, configs } = createCommandTest(); + await expect( + run([ + "balance", + ...requiredFlags.filter(([name]) => name !== "--chain").flat(), + "--chain", + chain, + ]), + ).rejects.toThrow(/Invalid value for option '--chain'/); + + expect(sent).toEqual([]); + expect(configs).toEqual([]); + expect(io.stdout()).toBe(""); + }); + + test.each(["ETH", "usdc", ""])("rejects invalid token %j", async (token) => { + const { run, io, sent, configs } = createCommandTest(); + await expect(run(["balance", ...requiredFlags.flat(), "--token", token])).rejects.toThrow( + /Invalid value for option '--token'/, + ); + + expect(sent).toEqual([]); + expect(configs).toEqual([]); + expect(io.stdout()).toBe(""); + }); + + test.each([ + new ValidationException({ + message: "No USDC balance is available for this instrument on BASE", + reason: "FieldValidationFailed", + $metadata: {}, + }), + new ResourceNotFoundException({ + message: "Payment instrument not found", + $metadata: {}, + }), + ])("surfaces $name without rendering a zero balance", async (error) => { + const { run, io, sent } = createCommandTest({ error }); + await expect(run(["balance", ...requiredFlags.flat()])).rejects.toBe(error); + + expect(sent).toHaveLength(1); + expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); + expect(io.stdout()).toBe(""); + }); +}); + +describe("payment instrument get remains separate from balance", () => { + test("gets metadata without requiring a chain or querying balance", async () => { + const response: GetPaymentInstrumentResponse = { + paymentInstrument: { + paymentInstrumentId: INSTRUMENT_ID, + paymentManagerArn: MANAGER_ARN, + paymentConnectorId: CONNECTOR_ID, + userId: USER_ID, + paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET", + paymentInstrumentDetails: { + embeddedCryptoWallet: { + network: "ETHEREUM", + linkedAccounts: [{ email: { emailAddress: "balance@example.test" } }], + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + }, + }, + status: "ACTIVE", + createdAt: new Date("2026-09-09T00:00:00Z"), + updatedAt: new Date("2026-09-09T00:00:00Z"), + }, + }; + const { run, io, sent } = createCommandTest({ response }); + await run([ + "get", + "--manager-id", + MANAGER_ID, + "--user-id", + USER_ID, + "--instrument-id", + INSTRUMENT_ID, + ]); + + expect(sent).toHaveLength(1); + expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentCommand); + expect(sent[0]?.input).toStrictEqual({ + paymentManagerArn: MANAGER_ARN, + userId: USER_ID, + paymentInstrumentId: INSTRUMENT_ID, + }); + const output = JSON.parse(io.stdout()); + expect(output.paymentInstrument.paymentInstrumentId).toBe(INSTRUMENT_ID); + expect(output.paymentInstrument.status).toBe("ACTIVE"); + expect(output).not.toHaveProperty("tokenBalance"); + }); +}); diff --git a/src/handlers/payment/instrument/balance/index.tsx b/src/handlers/payment/instrument/balance/index.tsx new file mode 100644 index 000000000..4e51991f5 --- /dev/null +++ b/src/handlers/payment/instrument/balance/index.tsx @@ -0,0 +1,74 @@ +import { BlockchainChainId, InstrumentBalanceToken } 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 { GetPaymentInstrumentBalanceInput } from "../../types"; + +export const createGetPaymentInstrumentBalanceHandler = (core: Core) => + createHandler({ + name: "balance", + description: "get a payment instrument's token balance on a specific chain", + 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 the instrument was created under", + z.string().optional(), + ), + flag("instrument-id", "the payment instrument id", z.string().optional()), + flag( + "chain", + `the blockchain chain to query (${Object.values(BlockchainChainId).join(" | ")})`, + z.enum(BlockchainChainId).optional(), + ), + flag( + "token", + `the token to query (${Object.values(InstrumentBalanceToken).join(" | ")}; default USDC)`, + z.enum(InstrumentBalanceToken).default(InstrumentBalanceToken.USDC), + ), + ], + 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", + ); + } + if (!flags["chain"]) { + throw new InputValidationError("required option '--chain ' not specified"); + } + + const request: GetPaymentInstrumentBalanceInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentConnectorId: flags["connector-id"], + paymentInstrumentId: flags["instrument-id"], + chain: flags["chain"], + token: flags["token"], + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.getPaymentInstrumentBalance(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/instrument/get/index.tsx b/src/handlers/payment/instrument/get/index.tsx new file mode 100644 index 000000000..ab5ee779a --- /dev/null +++ b/src/handlers/payment/instrument/get/index.tsx @@ -0,0 +1,53 @@ +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 { GetPaymentInstrumentInput } from "../../types"; + +export const createGetPaymentInstrumentHandler = (core: Core) => + createHandler({ + name: "get", + description: "get a payment instrument by id", + 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", + "restrict the lookup to instruments under this payment connector", + 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["instrument-id"]) { + throw new InputValidationError( + "required option '--instrument-id ' not specified", + ); + } + + const request: GetPaymentInstrumentInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentInstrumentId: flags["instrument-id"], + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + ...(flags["connector-id"] ? { paymentConnectorId: flags["connector-id"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.getPaymentInstrument(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/instrument/index.tsx b/src/handlers/payment/instrument/index.tsx new file mode 100644 index 000000000..6b76db261 --- /dev/null +++ b/src/handlers/payment/instrument/index.tsx @@ -0,0 +1,15 @@ +import type { AppIO } from "../../../io"; +import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { Core } from "../../types"; +import { createGetPaymentInstrumentHandler } from "./get"; +import { createListPaymentInstrumentsHandler } from "./list"; +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(createGetPaymentInstrumentHandler(core)) + .handler(createListPaymentInstrumentsHandler(core)) + .handler(createGetPaymentInstrumentBalanceHandler(core)); +} diff --git a/src/handlers/payment/instrument/list/index.tsx b/src/handlers/payment/instrument/list/index.tsx new file mode 100644 index 000000000..4bf99ef49 --- /dev/null +++ b/src/handlers/payment/instrument/list/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 { ListPaymentInstrumentsInput } from "../../types"; + +export const createListPaymentInstrumentsHandler = (core: Core) => + createHandler({ + name: "list", + description: + "list a user's payment instruments under a payment manager (server-side paginated)", + flags: [ + flag("manager-id", "the payment manager ID that owns the instruments", z.string().optional()), + flag( + "user-id", + "the user whose instruments to list (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("agent-name", "agent name recorded for observability", z.string().optional()), + flag( + "connector-id", + "only list instruments under this payment connector", + z.string().optional(), + ), + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + flag("max-results", "maximum number of items to return", z.number().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"); + } + + const request: ListPaymentInstrumentsInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + ...(flags["connector-id"] ? { paymentConnectorId: flags["connector-id"] } : {}), + ...(flags["next-token"] ? { nextToken: flags["next-token"] } : {}), + ...(flags["max-results"] !== undefined ? { maxResults: flags["max-results"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.listPaymentInstruments(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/session/get/index.tsx b/src/handlers/payment/session/get/index.tsx new file mode 100644 index 000000000..5596d1540 --- /dev/null +++ b/src/handlers/payment/session/get/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 { GetPaymentSessionInput } from "../../types"; + +export const createGetPaymentSessionHandler = (core: Core) => + createHandler({ + name: "get", + description: "get a payment session by id", + 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("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: GetPaymentSessionInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentSessionId: flags["session-id"], + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.getPaymentSession(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/session/index.tsx b/src/handlers/payment/session/index.tsx new file mode 100644 index 000000000..dff3e769a --- /dev/null +++ b/src/handlers/payment/session/index.tsx @@ -0,0 +1,13 @@ +import type { AppIO } from "../../../io"; +import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { Core } from "../../types"; +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(createGetPaymentSessionHandler(core)) + .handler(createListPaymentSessionsHandler(core)); +} diff --git a/src/handlers/payment/session/list/index.tsx b/src/handlers/payment/session/list/index.tsx new file mode 100644 index 000000000..442288563 --- /dev/null +++ b/src/handlers/payment/session/list/index.tsx @@ -0,0 +1,44 @@ +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 { ListPaymentSessionsInput } from "../../types"; + +export const createListPaymentSessionsHandler = (core: Core) => + createHandler({ + name: "list", + description: "list a user's payment sessions under a payment manager (server-side paginated)", + flags: [ + flag("manager-id", "the payment manager ID that owns the sessions", z.string().optional()), + flag( + "user-id", + "the user whose sessions to list (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("agent-name", "agent name recorded for observability", z.string().optional()), + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + flag("max-results", "maximum number of items to return", z.number().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"); + } + + const request: ListPaymentSessionsInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + ...(flags["next-token"] ? { nextToken: flags["next-token"] } : {}), + ...(flags["max-results"] !== undefined ? { maxResults: flags["max-results"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.listPaymentSessions(request, coreOptsFromCtx(ctx))); + }, + }); From f62b696fda683ec786d9168d3ed4b95493101f83 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 19:51:30 +0000 Subject: [PATCH 4/6] feat(identity): expose payment credential provider reads --- src/core/identity.tsx | 12 ++ src/handlers/identity/identity.test.tsx | 1 + src/handlers/identity/index.tsx | 4 +- ...entialProviderCommand.9ebdd66cb38819e.json | 24 +++ ...ntialProviderCommand.f7b7a089d09d9e1d.json | 6 + ...tialProvidersCommand.23f97c9dcdd6350b.json | 114 ++++++++++++++ ...tialProvidersCommand.52636e8890926ea8.json | 16 ++ ...tialProvidersCommand.7d2e22c637f6b633.json | 16 ++ ...tialProvidersCommand.8362171cbd70b263.json | 92 ++++++++++++ .../__fixtures__/get.golden.json | 20 +++ .../__fixtures__/list-page-1.golden.json | 12 ++ .../__fixtures__/list-page-2.golden.json | 12 ++ .../__fixtures__/list.golden.json | 74 +++++++++ .../payment-credential-provider/get/index.tsx | 24 +++ .../payment-credential-provider/index.tsx | 14 ++ .../list/index.tsx | 26 ++++ .../paymentCredentialProvider.read.test.tsx | 142 ++++++++++++++++++ src/handlers/identity/types.tsx | 11 +- 18 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.9ebdd66cb38819e.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.f7b7a089d09d9e1d.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.23f97c9dcdd6350b.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.52636e8890926ea8.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.7d2e22c637f6b633.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/get.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/list-page-1.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/list-page-2.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/list.golden.json create mode 100644 src/handlers/identity/payment-credential-provider/get/index.tsx create mode 100644 src/handlers/identity/payment-credential-provider/index.tsx create mode 100644 src/handlers/identity/payment-credential-provider/list/index.tsx create mode 100644 src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx diff --git a/src/core/identity.tsx b/src/core/identity.tsx index 9d8070451..4ca0e69b5 100644 --- a/src/core/identity.tsx +++ b/src/core/identity.tsx @@ -10,6 +10,7 @@ import { GetPaymentCredentialProviderCommand, ListApiKeyCredentialProvidersCommand, ListOauth2CredentialProvidersCommand, + ListPaymentCredentialProvidersCommand, UpdateApiKeyCredentialProviderCommand, UpdateOauth2CredentialProviderCommand, UpdatePaymentCredentialProviderCommand, @@ -26,6 +27,7 @@ import { type CreatePaymentCredentialProviderResponse, type DeletePaymentCredentialProviderResponse, type GetPaymentCredentialProviderResponse, + type ListPaymentCredentialProvidersResponse, type UpdatePaymentCredentialProviderResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { @@ -155,6 +157,16 @@ export class IdentityClient implements CoreIdentityClient { .send(new GetPaymentCredentialProviderCommand({ name })); } + async listPaymentCredentialProviders( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new ListPaymentCredentialProvidersCommand({ nextToken, maxResults })); + } + async updatePaymentCredentialProvider( input: UpdatePaymentCredentialProviderInput, options: CoreOptions, diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx index 8be21378b..2dc12c871 100644 --- a/src/handlers/identity/identity.test.tsx +++ b/src/handlers/identity/identity.test.tsx @@ -56,6 +56,7 @@ describe("identity command hierarchy", () => { expect(identity?.children().map((child) => child.name())).toEqual([ "api-key-credential-provider", "oauth2-credential-provider", + "payment-credential-provider", ]); expect( identity diff --git a/src/handlers/identity/index.tsx b/src/handlers/identity/index.tsx index 64011df40..68d4dcd41 100644 --- a/src/handlers/identity/index.tsx +++ b/src/handlers/identity/index.tsx @@ -5,11 +5,13 @@ import type { AppIO } from "../../io"; import type { Core } from "../types"; import { createApiKeyCredentialProviderHandler } from "./api-key-credential-provider"; import { createOauth2CredentialProviderHandler } from "./oauth2-credential-provider"; +import { createPaymentCredentialProviderHandler } from "./payment-credential-provider"; export function createIdentityHandler(core: Core, io: AppIO): Router { return new Router("identity", "manage AgentCore Identity resources") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) .handler(createApiKeyCredentialProviderHandler(core, io)) - .handler(createOauth2CredentialProviderHandler(core, io)); + .handler(createOauth2CredentialProviderHandler(core, io)) + .handler(createPaymentCredentialProviderHandler(core, io)); } diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.9ebdd66cb38819e.json b/src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.9ebdd66cb38819e.json new file mode 100644 index 000000000..f636ebf3a --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.9ebdd66cb38819e.json @@ -0,0 +1,24 @@ +{ + "name": "agentcore-cli-payment-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "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" + } + }, + "createdTime": { + "$date": "2026-09-08T20:16:25.544Z" + }, + "lastUpdatedTime": { + "$date": "2026-09-08T20:16:25.544Z" + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.f7b7a089d09d9e1d.json b/src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.f7b7a089d09d9e1d.json new file mode 100644 index 000000000..34b0fd4c5 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/GetPaymentCredentialProviderCommand.f7b7a089d09d9e1d.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "PaymentCredentialProvider not found" + } +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.23f97c9dcdd6350b.json b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.23f97c9dcdd6350b.json new file mode 100644 index 000000000..93aa78839 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.23f97c9dcdd6350b.json @@ -0,0 +1,114 @@ +{ + "credentialProviders": [ + { + "name": "DeployTest-CdpConn-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/DeployTest-CdpConn-cdp", + "createdTime": { + "$date": "2026-05-14T15:48:17.857Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-20T19:09:36.566Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-05T03:02:59.583Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-21T19:22:56.308Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-06-08T18:10:19.508Z" + }, + "lastUpdatedTime": { + "$date": "2026-06-08T18:10:19.508Z" + } + }, + { + "name": "agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "createdTime": { + "$date": "2026-09-08T20:16:25.544Z" + }, + "lastUpdatedTime": { + "$date": "2026-09-08T20:16:25.544Z" + } + }, + { + "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", + "createdTime": { + "$date": "2026-09-08T20:16:26.070Z" + }, + "lastUpdatedTime": { + "$date": "2026-09-08T20:16:26.070Z" + } + }, + { + "name": "cdp-creds", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/cdp-creds", + "createdTime": { + "$date": "2026-05-12T21:18:46.009Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-21T21:19:41.791Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-22T01:59:54.168Z" + }, + "lastUpdatedTime": { + "$date": "2026-05-22T02:42:24.884Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.52636e8890926ea8.json b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.52636e8890926ea8.json new file mode 100644 index 000000000..754a56eac --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.52636e8890926ea8.json @@ -0,0 +1,16 @@ +{ + "credentialProviders": [ + { + "name": "InvokeMgr-InvokeCdp-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/InvokeMgr-InvokeCdp-cdp", + "createdTime": { + "$date": "2026-05-20T19:09:36.566Z" + }, + "lastUpdatedTime": { + "$date": "2026-05-20T19:09:36.566Z" + } + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjAzMTQxMDQxOTQ3I1BBWU1FTlQiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJTSyI6eyJ2YWx1ZVN0ciI6Ikludm9rZU1nci1JbnZva2VDZHAtY2RwIiwiYXR0cmlidXRlVHlwZSI6IlMifSwiUEsiOnsidmFsdWVTdHIiOiJQQVlNRU5UX0NSRURfUFJPVklERVIjNjAzMTQxMDQxOTQ3I2RlZmF1bHQjSW52b2tlTWdyLUludm9rZUNkcC1jZHAiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9fQ==" +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.7d2e22c637f6b633.json b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.7d2e22c637f6b633.json new file mode 100644 index 000000000..b26d58bc0 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.7d2e22c637f6b633.json @@ -0,0 +1,16 @@ +{ + "credentialProviders": [ + { + "name": "DeployTest-CdpConn-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/DeployTest-CdpConn-cdp", + "createdTime": { + "$date": "2026-05-14T15:48:17.857Z" + }, + "lastUpdatedTime": { + "$date": "2026-05-14T15:48:17.857Z" + } + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjAzMTQxMDQxOTQ3I1BBWU1FTlQiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJTSyI6eyJ2YWx1ZVN0ciI6IkRlcGxveVRlc3QtQ2RwQ29ubi1jZHAiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJQSyI6eyJ2YWx1ZVN0ciI6IlBBWU1FTlRfQ1JFRF9QUk9WSURFUiM2MDMxNDEwNDE5NDcjZGVmYXVsdCNEZXBsb3lUZXN0LUNkcENvbm4tY2RwIiwiYXR0cmlidXRlVHlwZSI6IlMifX0=" +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json new file mode 100644 index 000000000..edf0f9a2d --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json @@ -0,0 +1,92 @@ +{ + "credentialProviders": [ + { + "name": "DeployTest-CdpConn-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/DeployTest-CdpConn-cdp", + "createdTime": { + "$date": "2026-05-14T15:48:17.857Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-20T19:09:36.566Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-05T03:02:59.583Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-21T19:22:56.308Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-06-08T18:10:19.508Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-12T21:18:46.009Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-21T21:19:41.791Z" + }, + "lastUpdatedTime": { + "$date": "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": { + "$date": "2026-05-22T01:59:54.168Z" + }, + "lastUpdatedTime": { + "$date": "2026-05-22T02:42:24.884Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/get.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/get.golden.json new file mode 100644 index 000000000..db9929018 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/get.golden.json @@ -0,0 +1,20 @@ +{ + "name": "agentcore-cli-payment-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "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" + } + }, + "createdTime": "2026-09-08T20:16:25.544Z", + "lastUpdatedTime": "2026-09-08T20:16:25.544Z" +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/list-page-1.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/list-page-1.golden.json new file mode 100644 index 000000000..e9db4018d --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/list-page-1.golden.json @@ -0,0 +1,12 @@ +{ + "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" + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjAzMTQxMDQxOTQ3I1BBWU1FTlQiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJTSyI6eyJ2YWx1ZVN0ciI6IkRlcGxveVRlc3QtQ2RwQ29ubi1jZHAiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJQSyI6eyJ2YWx1ZVN0ciI6IlBBWU1FTlRfQ1JFRF9QUk9WSURFUiM2MDMxNDEwNDE5NDcjZGVmYXVsdCNEZXBsb3lUZXN0LUNkcENvbm4tY2RwIiwiYXR0cmlidXRlVHlwZSI6IlMifX0=" +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/list-page-2.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/list-page-2.golden.json new file mode 100644 index 000000000..06cf4238d --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/list-page-2.golden.json @@ -0,0 +1,12 @@ +{ + "credentialProviders": [ + { + "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" + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjAzMTQxMDQxOTQ3I1BBWU1FTlQiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJTSyI6eyJ2YWx1ZVN0ciI6Ikludm9rZU1nci1JbnZva2VDZHAtY2RwIiwiYXR0cmlidXRlVHlwZSI6IlMifSwiUEsiOnsidmFsdWVTdHIiOiJQQVlNRU5UX0NSRURfUFJPVklERVIjNjAzMTQxMDQxOTQ3I2RlZmF1bHQjSW52b2tlTWdyLUludm9rZUNkcC1jZHAiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9fQ==" +} \ No newline at end of file diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/list.golden.json b/src/handlers/identity/payment-credential-provider/__fixtures__/list.golden.json new file mode 100644 index 000000000..86a578c46 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/__fixtures__/list.golden.json @@ -0,0 +1,74 @@ +{ + "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": "agentcore-cli-payment-fixture", + "credentialProviderVendor": "CoinbaseCDP", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/agentcore-cli-payment-fixture", + "createdTime": "2026-09-08T20:16:25.544Z", + "lastUpdatedTime": "2026-09-08T20:16:25.544Z" + }, + { + "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", + "createdTime": "2026-09-08T20:16:26.070Z", + "lastUpdatedTime": "2026-09-08T20:16:26.070Z" + }, + { + "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/get/index.tsx b/src/handlers/identity/payment-credential-provider/get/index.tsx new file mode 100644 index 000000000..f09709329 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/get/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 createGetPaymentCredentialProviderHandler = (core: Core) => + createHandler({ + name: "get", + description: "get 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.getPaymentCredentialProvider(flags.name, coreOptsFromCtx(ctx)), + ); + }, + }); diff --git a/src/handlers/identity/payment-credential-provider/index.tsx b/src/handlers/identity/payment-credential-provider/index.tsx new file mode 100644 index 000000000..ed8323500 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/index.tsx @@ -0,0 +1,14 @@ +import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; +import { createGetPaymentCredentialProviderHandler } from "./get"; +import { createListPaymentCredentialProvidersHandler } from "./list"; + +export function createPaymentCredentialProviderHandler(core: Core, io: AppIO): Router { + return new Router("payment-credential-provider", "manage payment credential providers") + .default(renderTui(core, io)) + .supportedTuiCommands() + .handler(createGetPaymentCredentialProviderHandler(core)) + .handler(createListPaymentCredentialProvidersHandler(core)); +} diff --git a/src/handlers/identity/payment-credential-provider/list/index.tsx b/src/handlers/identity/payment-credential-provider/list/index.tsx new file mode 100644 index 000000000..65bc22918 --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/list/index.tsx @@ -0,0 +1,26 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createListPaymentCredentialProvidersHandler = (core: Core) => + createHandler({ + name: "list", + description: "list payment credential providers", + flags: [ + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + flag("max-results", "maximum number of items to return", z.number().optional()), + ], + handle: async (ctx, flags) => { + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.listPaymentCredentialProviders( + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx new file mode 100644 index 000000000..1ae97cb9b --- /dev/null +++ b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../../core"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + 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"]; +const FIXTURE_PROVIDER_NAME = "agentcore-cli-payment-fixture"; +const MISSING_PROVIDER_NAME = "agentcore-cli-payment-fixture-2"; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...BASE, ...args, "--region", REGION]); + return io.stdout(); +} + +describe("payment-credential-provider read-only command hierarchy", () => { + test("registers get and list only, with no create, update, or delete commands", () => { + const root = createRootHandler(createFixtureCore(), { + 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(["get", "list"]); + }); + + test("prints command help with --json", async () => { + const stdout = await run(["--json"]); + + expect(stdout).toContain("Usage: agentcore identity payment-credential-provider"); + expect(stdout).toContain("Commands:"); + expect(stdout).toContain("get"); + expect(stdout).toContain("list"); + }); +}); + +describe("payment-credential-provider get", () => { + test("prints a recorded provider as JSON", async () => { + const stdout = await run(["get", "--name", FIXTURE_PROVIDER_NAME]); + + matchGolden(FIXTURES, "get.golden.json", stdout); + expect(JSON.parse(stdout)).toMatchObject({ + name: FIXTURE_PROVIDER_NAME, + credentialProviderVendor: "CoinbaseCDP", + }); + }); + + test.each([ + ["omitted", ["get"]], + ["omitted with --json", ["get", "--json"]], + ["empty", ["get", "--name", ""]], + ] as const)("requires a nonempty --name when %s", async (_label, args) => { + await expect(run([...args])).rejects.toThrow("required option '--name ' not specified"); + }); + + test("preserves the recorded service error name and message", async () => { + await expect(run(["get", "--name", MISSING_PROVIDER_NAME])).rejects.toMatchObject({ + name: "ResourceNotFoundException", + message: "PaymentCredentialProvider not found", + }); + }); +}); + +describe("payment-credential-provider list", () => { + test.each([ + ["without flags", ["list"]], + ["with --json", ["list", "--json"]], + ] as const)("prints recorded providers %s", async (_label, args) => { + const stdout = await run([...args]); + + matchGolden(FIXTURES, "list.golden.json", stdout); + expect(JSON.parse(stdout).credentialProviders).toContainEqual( + expect.objectContaining({ name: FIXTURE_PROVIDER_NAME }), + ); + }); + + test("forwards --max-results and --next-token and preserves pagination tokens", async () => { + const firstPage = await run(["list", "--max-results", "1"]); + matchGolden(FIXTURES, "list-page-1.golden.json", firstPage); + const first = JSON.parse(firstPage); + expect(first.credentialProviders).toHaveLength(1); + expect(first.credentialProviders[0].name).toBe("DeployTest-CdpConn-cdp"); + expect(first.nextToken).toBeString(); + expect(first.nextToken.length).toBeGreaterThan(0); + + const secondPage = await run(["list", "--max-results", "1", "--next-token", first.nextToken]); + matchGolden(FIXTURES, "list-page-2.golden.json", secondPage); + const second = JSON.parse(secondPage); + expect(second.credentialProviders).toHaveLength(1); + expect(second.credentialProviders[0].name).toBe("InvokeMgr-InvokeCdp-cdp"); + expect(second.nextToken).toBeString(); + expect(second.nextToken).not.toBe(first.nextToken); + }); +}); + +describe("payment-credential-provider read flag validation", () => { + test.each([ + ["get --name without a value", ["get", "--name"], /--name/], + ["get with an unknown flag", ["get", "--id", FIXTURE_PROVIDER_NAME], /unknown option '--id'/], + ["list --max-results without a value", ["list", "--max-results"], /--max-results/], + ["list --max-results with a non-number", ["list", "--max-results", "abc"], /--max-results/], + ["list --next-token without a value", ["list", "--next-token"], /--next-token/], + [ + "list with an unknown flag", + ["list", "--name", FIXTURE_PROVIDER_NAME], + /unknown option '--name'/, + ], + ] as const)("rejects %s", async (_label, args, message) => { + await expect(run([...args])).rejects.toThrow(message); + }); +}); diff --git a/src/handlers/identity/types.tsx b/src/handlers/identity/types.tsx index 575ed40a3..6f8363f90 100644 --- a/src/handlers/identity/types.tsx +++ b/src/handlers/identity/types.tsx @@ -17,6 +17,7 @@ import type { CreatePaymentCredentialProviderResponse, DeletePaymentCredentialProviderResponse, GetPaymentCredentialProviderResponse, + ListPaymentCredentialProvidersResponse, UpdatePaymentCredentialProviderRequest, UpdatePaymentCredentialProviderResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; @@ -75,8 +76,9 @@ export interface CoreIdentityClient { ): Promise; // 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 are - // provisioned by `project deploy` rather than an `agentcore identity` subcommand. + // 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. createPaymentCredentialProvider( input: CreatePaymentCredentialProviderInput, options: CoreOptions, @@ -85,6 +87,11 @@ export interface CoreIdentityClient { name: string, options: CoreOptions, ): Promise; + listPaymentCredentialProviders( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise; updatePaymentCredentialProvider( input: UpdatePaymentCredentialProviderInput, options: CoreOptions, From ec00cf82c9141835fed3e33d3e05bc2acee099fe Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 19:52:27 +0000 Subject: [PATCH 5/6] docs(payment): wire and document read-only command surface --- README.md | 72 ++- src/components/CliOnlyScreen.test.tsx | 2 +- src/handlers/index.tsx | 2 + src/handlers/payment/index.tsx | 24 + src/handlers/payment/payment.read.test.tsx | 555 +++++++++++++++++++++ src/handlers/root.test.tsx | 1 + 6 files changed, 649 insertions(+), 7 deletions(-) create mode 100644 src/handlers/payment/index.tsx create mode 100644 src/handlers/payment/payment.read.test.tsx diff --git a/README.md b/README.md index 79822cc32..2a810396c 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,15 @@ agentcore # interactive TUI │ │ ├── list # list API key credential providers │ │ ├── update # update an API key credential provider │ │ └── delete # delete an API key credential provider -│ └── oauth2-credential-provider -│ ├── create # create an OAuth2 credential provider -│ ├── get # get an OAuth2 credential provider -│ ├── list # list OAuth2 credential providers -│ ├── update # update an OAuth2 credential provider -│ └── delete # delete an OAuth2 credential provider +│ ├── oauth2-credential-provider +│ │ ├── create # create an OAuth2 credential provider +│ │ ├── get # get an OAuth2 credential provider +│ │ ├── list # list OAuth2 credential providers +│ │ ├── update # update an OAuth2 credential provider +│ │ └── delete # delete an OAuth2 credential provider +│ └── payment-credential-provider +│ ├── get # get a payment credential provider +│ └── list # list payment credential providers ├── runtime # inspect deployed AgentCore Runtimes │ ├── get # fetch a Runtime by id │ ├── list # list Runtimes (server-side paginated) @@ -107,6 +110,20 @@ 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) +│ ├── manager +│ │ ├── get # get a payment manager by id +│ │ └── list # list payment managers (server-side paginated) +│ ├── connector # connectors under a payment manager +│ │ ├── get # get a connector (shows the Quick Create authorization URL while pending) +│ │ └── list # list a manager's connectors +│ ├── session # budget-limited payment contexts (data plane) +│ │ ├── get +│ │ └── list +│ └── instrument # embedded crypto wallets (data plane) +│ ├── get +│ ├── list +│ └── balance # read token balance on an explicit chain (default token: USDC) ├── eval # evaluate and optimize AgentCore agents │ └── evaluator # manage AgentCore evaluators │ ├── llm-as-a-judge # LLM-as-a-Judge evaluators @@ -186,6 +203,49 @@ 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 + +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. + +```bash +# 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 + +# Inspect provider metadata stored in AgentCore Identity. +agentcore identity payment-credential-provider list --json +agentcore identity payment-credential-provider get --name + +# 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 + +# Check funding on one chain. USDC is the default token. +agentcore payment instrument balance --manager-id --user-id alice \ + --connector-id --instrument-id --chain BASE_SEPOLIA +``` + +`instrument get` returns instrument metadata without querying balances. `balance` +requires an explicit chain and accepts `--token USDC`; wallet network families +such as ETHEREUM do not identify whether to query mainnet or a testnet. The JSON +response retains the raw atomic amount string and decimals. A service error is +reported as an error, never converted to a zero balance. + +Data-plane commands work against managers that use the `AWS_IAM` authorizer. +The CLI resolves `--manager-id` through `GetPaymentManager` in the configured +region, then supplies the returned ARN to the data-plane API. Callers need +`bedrock-agentcore:GetPaymentManager` as well as the relevant data-plane action. +Region resolution follows the other imperative commands: `--region`, environment +variables, the active AWS profile, then the CLI default. +A `CUSTOM_JWT` manager accepts only bearer tokens on its data plane, which +these commands do not send yet; the CLI reports that limitation before calling +the data plane. + ### Examples ```bash diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index b6b7c91eb..08cd511cb 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -37,7 +37,7 @@ describe("menus list command-line-only subcommands below a divider", () => { await waitForText(r.lastFrame, "command line only"); expect(menuEntries(r.lastFrame()!)).toEqual({ screens: ["project", "harness", "identity", "runtime", "memory", "gateway", "eval"], - cliOnly: ["feedback", "config", "update"], + cliOnly: ["payment", "feedback", "config", "update"], }); r.unmount(); }); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index d67f11900..f8f60bd8e 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -5,6 +5,7 @@ import { createGatewayHandler } from "./gateway/index.tsx"; import { createHarnessHandler } from "./harness/index.tsx"; import { createIdentityHandler } from "./identity/index.tsx"; import { createMemoryHandler } from "./memory/index.tsx"; +import { createPaymentHandler } from "./payment/index.tsx"; import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; @@ -72,6 +73,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createRuntimeHandler(core, io)); root.handler(createMemoryHandler(core, io)); root.handler(createGatewayHandler(core, io)); + root.handler(createPaymentHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createFeedbackHandler(core, io)); root.handler(createConfigHandler()); diff --git a/src/handlers/payment/index.tsx b/src/handlers/payment/index.tsx new file mode 100644 index 000000000..c2ebb5f37 --- /dev/null +++ b/src/handlers/payment/index.tsx @@ -0,0 +1,24 @@ +import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; +import { Router } from "../../router"; +import { renderTui } from "../../tui"; +import type { AppIO } from "../../io"; +import type { Core } from "../types"; +import { createPaymentConnectorHandler } from "./connector"; +import { createPaymentInstrumentHandler } from "./instrument"; +import { createPaymentManagerHandler } from "./manager"; +import { createPaymentSessionHandler } from "./session"; + +export function createPaymentHandler(core: Core, io: AppIO): Router { + return ( + new Router("payment", "manage AgentCore Payments") + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) + // No payment screens ship yet: every leaf runs from the command line and the + // menus list them as such. Widen this as screens land. + .supportedTuiCommands() + .handler(createPaymentManagerHandler(core, io)) + .handler(createPaymentConnectorHandler(core, io)) + .handler(createPaymentSessionHandler(core, io)) + .handler(createPaymentInstrumentHandler(core, io)) + ); +} diff --git a/src/handlers/payment/payment.read.test.tsx b/src/handlers/payment/payment.read.test.tsx new file mode 100644 index 000000000..8ef8211b2 --- /dev/null +++ b/src/handlers/payment/payment.read.test.tsx @@ -0,0 +1,555 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import { + GetPaymentInstrumentCommand, + GetPaymentSessionCommand, + ListPaymentInstrumentsCommand, + ListPaymentSessionsCommand, + type BedrockAgentCoreClient, +} from "@aws-sdk/client-bedrock-agentcore"; +import { + AccessDeniedException, + GetPaymentConnectorCommand, + GetPaymentManagerCommand, + ListPaymentConnectorsCommand, + ListPaymentManagersCommand, + ResourceNotFoundException, + type BedrockAgentCoreControlClient, + type GetPaymentConnectorResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { CoreClient, type ClientConfig } from "../../core"; +import { compile, isTuiCommandSupported, ValueContext } from "../../router"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + parse, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; +import connectorFixture from "./__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json"; + +const FIXTURES = join(import.meta.dir, "__fixtures__"); +const REGION = "us-west-2"; +const EXISTING_MANAGER_ID = "mypaymentmanager-o4ks3qfgtb"; +const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; +const MANAGER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira"; +const CONNECTOR_ID = "agentcorecliconnectore2e-6rodjuiuig"; +const INSTRUMENT_CONNECTOR_ID = "mycdpconnectoraidandal-okve8guw4y"; +const SESSION_ID = "payment-session-nq812U4e1BJIfw1"; +const INSTRUMENT_ID = "payment-instrument-CG2Tl7U1HnCGfHW"; +const USER_ID = "agentcore-cli-e2e"; +const scoped = [ + ["--manager-id", MANAGER_ID], + ["--user-id", USER_ID], +]; + +type Resource = "manager" | "connector" | "session" | "instrument"; +type Send = (command: { input: unknown }) => Promise; + +function createCommandTest({ + resource = "manager", + controlSend, + dataSend, +}: { + resource?: Resource; + controlSend?: Send; + dataSend?: Send; +} = {}) { + const factories = fixtureFactories( + resource === "connector" ? join(FIXTURES, "connector") : FIXTURES, + ); + const dataFactories = fixtureFactories(join(FIXTURES, resource)); + const createControlClient = mock((config: ClientConfig) => + controlSend + ? ({ send: controlSend } as unknown as BedrockAgentCoreControlClient) + : factories.createControlClient(config), + ); + const createDataClient = mock((config: ClientConfig) => + dataSend + ? ({ send: dataSend } as unknown as BedrockAgentCoreClient) + : dataFactories.createDataClient(config), + ); + const unexpectedClient = () => { + throw new Error("payment reads must not use other SDK clients"); + }; + const core = new CoreClient({ + createControlClient, + createDataClient, + createIamClient: unexpectedClient, + createLogsClient: unexpectedClient, + createCloudFormationClient: unexpectedClient, + logger: createSilentLogger(), + }); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + return { + core, + root, + io, + createControlClient, + createDataClient, + run: (args: string[], region = REGION) => + root.route(["node", "agentcore", "payment", ...args, "--region", region]), + }; +} + +const reads = [ + { + resource: "manager", + verb: "get", + requiredFlags: [["--id", EXISTING_MANAGER_ID]], + expected: { paymentManagerId: EXISTING_MANAGER_ID, status: "READY" }, + }, + { + resource: "manager", + verb: "list", + requiredFlags: [], + expected: { + paymentManagers: expect.arrayContaining([ + expect.objectContaining({ paymentManagerId: EXISTING_MANAGER_ID }), + ]), + }, + }, + { + resource: "connector", + verb: "get", + requiredFlags: [ + ["--manager-id", MANAGER_ID], + ["--connector-id", CONNECTOR_ID], + ], + expected: { paymentConnectorId: CONNECTOR_ID, status: "READY" }, + }, + { + resource: "connector", + verb: "list", + requiredFlags: [["--manager-id", MANAGER_ID]], + expected: { + paymentConnectors: expect.arrayContaining([ + expect.objectContaining({ paymentConnectorId: CONNECTOR_ID }), + ]), + }, + }, + { + resource: "session", + verb: "get", + requiredFlags: [...scoped, ["--session-id", SESSION_ID]], + expected: { paymentSession: { paymentSessionId: SESSION_ID, userId: USER_ID } }, + }, + { + resource: "session", + verb: "list", + requiredFlags: scoped, + expected: { + paymentSessions: expect.arrayContaining([ + expect.objectContaining({ paymentSessionId: SESSION_ID }), + ]), + }, + }, + { + resource: "instrument", + verb: "get", + requiredFlags: [...scoped, ["--instrument-id", INSTRUMENT_ID]], + expected: { + paymentInstrument: { paymentInstrumentId: INSTRUMENT_ID, status: "ACTIVE" }, + }, + }, + { + resource: "instrument", + verb: "list", + requiredFlags: scoped, + expected: { + paymentInstruments: expect.arrayContaining([ + expect.objectContaining({ paymentInstrumentId: INSTRUMENT_ID }), + ]), + }, + }, +] satisfies { + resource: Resource; + verb: string; + requiredFlags: string[][]; + expected: object; +}[]; + +describe("payment read-only command tree", () => { + test("exposes exactly nine CLI-only leaves and no write commands", () => { + const { root } = createCommandTest(); + const payment = compile(root, ValueContext.EmptyContext()).commands.find( + (command) => command.name() === "payment", + ); + expect(payment).toBeDefined(); + expect(isTuiCommandSupported(payment!)).toBe(false); + expect( + Object.fromEntries( + payment!.commands.map((resource) => [ + resource.name(), + resource.commands.map((command) => command.name()), + ]), + ), + ).toEqual({ + manager: ["get", "list"], + connector: ["get", "list"], + session: ["get", "list"], + instrument: ["get", "list", "balance"], + }); + const leaves = payment!.commands.flatMap((resource) => resource.commands); + expect(leaves).toHaveLength(9); + for (const command of [...payment!.commands, ...leaves]) { + expect(isTuiCommandSupported(command)).toBe(false); + expect(command.options.map((option) => option.long)).not.toContain("--wait"); + expect(command.options.map((option) => option.long)).not.toContain("--browser"); + } + }); +}); + +for (const { resource, verb, requiredFlags, expected } of reads) { + describe(`payment ${resource} ${verb} read`, () => { + test("replays an independent Get/List fixture through the real root and Core", async () => { + const { run, io } = createCommandTest({ resource }); + const filter = + resource === "instrument" && verb === "list" + ? ["--connector-id", INSTRUMENT_CONNECTOR_ID] + : []; + await run([resource, verb, ...requiredFlags.flat(), ...filter, "--json"]); + + matchGolden( + resource === "manager" ? FIXTURES : join(FIXTURES, resource), + `${resource}-${verb}.golden.json`, + io.stdout(), + ); + expect(JSON.parse(io.stdout())).toMatchObject(expected); + expect(io.stderr()).toBe(""); + }); + + for (const [flagName] of requiredFlags) { + test.each(["omitted", "empty"])( + `rejects %s ${flagName} before constructing SDK clients`, + async (mode) => { + const { run, io, createControlClient, createDataClient } = createCommandTest({ + resource, + }); + const flags = requiredFlags.flatMap(([name, value]) => + name !== flagName ? [name!, value!] : mode === "empty" ? [name!, ""] : [], + ); + await expect(run([resource, verb, ...flags])).rejects.toThrow( + `required option '${flagName} <${flagName!.slice(2)}>' not specified`, + ); + expect(createControlClient).not.toHaveBeenCalled(); + expect(createDataClient).not.toHaveBeenCalled(); + expect(io.stdout()).toBe(""); + expect(io.stderr()).toBe(""); + }, + ); + } + + test.each([ + new ResourceNotFoundException({ message: "Payment resource not found", $metadata: {} }), + new AccessDeniedException({ message: "Access to payment resource denied", $metadata: {} }), + ])("propagates $name unchanged without rendering output", async (error) => { + const send = mock(async () => { + throw error; + }); + const isData = resource === "session" || resource === "instrument"; + const { run, io } = createCommandTest({ + resource, + ...(isData ? { dataSend: send } : { controlSend: send }), + }); + await expect(run([resource, verb, ...requiredFlags.flat(), "--json"])).rejects.toBe(error); + expect(send).toHaveBeenCalledTimes(1); + expect(io.stdout()).toBe(""); + expect(io.stderr()).toBe(""); + }); + }); +} + +const lists = [ + { + resource: "manager", + flags: [], + command: ListPaymentManagersCommand, + request: {}, + field: "paymentManagers", + item: { paymentManagerId: EXISTING_MANAGER_ID }, + }, + { + resource: "connector", + flags: ["--manager-id", MANAGER_ID], + command: ListPaymentConnectorsCommand, + request: { paymentManagerId: MANAGER_ID }, + field: "paymentConnectors", + item: { paymentConnectorId: CONNECTOR_ID }, + }, + { + resource: "session", + flags: scoped.flat(), + command: ListPaymentSessionsCommand, + request: { paymentManagerArn: MANAGER_ARN, userId: USER_ID }, + field: "paymentSessions", + item: { paymentSessionId: SESSION_ID }, + }, + { + resource: "instrument", + flags: [...scoped.flat(), "--connector-id", INSTRUMENT_CONNECTOR_ID], + command: ListPaymentInstrumentsCommand, + request: { + paymentManagerArn: MANAGER_ARN, + userId: USER_ID, + paymentConnectorId: INSTRUMENT_CONNECTOR_ID, + }, + field: "paymentInstruments", + item: { paymentInstrumentId: INSTRUMENT_ID }, + }, +] satisfies { + resource: Resource; + flags: string[]; + command: unknown; + request: object; + field: string; + item: object; +}[]; + +describe("payment read pagination", () => { + test.each(lists)( + "$resource list preserves page size, opaque tokens, and an empty last page", + async ({ resource, flags, command: Command, request, field, item }) => { + const nextToken = "page+2/=opaque"; + const send = mock(async (command: { input: unknown }) => { + expect(command).toBeInstanceOf(Command); + return (command.input as { nextToken?: string }).nextToken + ? { [field]: [] } + : { [field]: [item], nextToken }; + }); + const isData = resource === "session" || resource === "instrument"; + const options = { resource, ...(isData ? { dataSend: send } : { controlSend: send }) }; + const first = createCommandTest(options); + await first.run([resource, "list", ...flags, "--max-results", "1", "--json"]); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]![0].input).toEqual({ + ...request, + maxResults: 1, + ...(isData ? {} : { nextToken: undefined }), + }); + const page = JSON.parse(first.io.stdout()); + expect(page).toEqual({ [field]: [item], nextToken }); + + const second = createCommandTest(options); + await second.run([ + resource, + "list", + ...flags, + "--max-results", + "1", + "--next-token", + page.nextToken, + "--json", + ]); + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[1]![0].input).toEqual({ ...request, maxResults: 1, nextToken }); + expect(JSON.parse(second.io.stdout())).toEqual({ [field]: [] }); + }, + ); + + test.each(lists)( + "$resource list rejects a non-numeric page size before constructing SDK clients", + async ({ resource, flags }) => { + const { run, createControlClient, createDataClient } = createCommandTest({ resource }); + await expect( + run([resource, "list", ...flags, "--max-results", "not-a-number"]), + ).rejects.toThrow("Invalid value for option '--max-results'"); + expect(createControlClient).not.toHaveBeenCalled(); + expect(createDataClient).not.toHaveBeenCalled(); + }, + ); +}); + +const dataReads = [ + { + resource: "session", + verb: "get", + method: "getPaymentSession", + command: GetPaymentSessionCommand, + flags: ["--session-id", SESSION_ID], + request: { paymentSessionId: SESSION_ID }, + }, + { + resource: "session", + verb: "list", + method: "listPaymentSessions", + command: ListPaymentSessionsCommand, + flags: [], + request: {}, + }, + { + resource: "instrument", + verb: "get", + method: "getPaymentInstrument", + command: GetPaymentInstrumentCommand, + flags: ["--instrument-id", INSTRUMENT_ID, "--connector-id", INSTRUMENT_CONNECTOR_ID], + request: { paymentInstrumentId: INSTRUMENT_ID, paymentConnectorId: INSTRUMENT_CONNECTOR_ID }, + }, + { + resource: "instrument", + verb: "list", + method: "listPaymentInstruments", + command: ListPaymentInstrumentsCommand, + flags: ["--connector-id", INSTRUMENT_CONNECTOR_ID], + request: { paymentConnectorId: INSTRUMENT_CONNECTOR_ID }, + }, +] as const; + +for (const { resource, verb, method, command: Command, flags, request } of dataReads) { + describe(`payment ${resource} ${verb} scope`, () => { + test("passes managerId to Core and uses the configured region for both SDK clients", async () => { + const controlSend = mock(async (command: { input: unknown }) => { + expect(command).toBeInstanceOf(GetPaymentManagerCommand); + expect(command.input).toEqual({ paymentManagerId: MANAGER_ID }); + return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; + }); + const dataSend = mock(async (command: { input: unknown }) => { + expect(command).toBeInstanceOf(Command); + expect(command.input).toEqual({ + ...request, + paymentManagerArn: MANAGER_ARN, + userId: USER_ID, + agentName: "read-agent", + }); + return {}; + }); + const { core, run, createControlClient, createDataClient } = createCommandTest({ + resource, + controlSend, + dataSend, + }); + const call = spyOn(core.payment, method); + try { + await run( + [ + resource, + verb, + ...scoped.flat(), + ...flags, + "--agent-name", + "read-agent", + "--endpoint-url", + "https://payments.example.test", + ], + "eu-west-1", + ); + expect(call).toHaveBeenCalledWith( + { ...request, managerId: MANAGER_ID, userId: USER_ID, agentName: "read-agent" }, + expect.objectContaining({ + region: "eu-west-1", + endpointUrl: "https://payments.example.test", + }), + ); + } finally { + call.mockRestore(); + } + expect(controlSend).toHaveBeenCalledTimes(1); + expect(dataSend).toHaveBeenCalledTimes(1); + for (const factory of [createControlClient, createDataClient]) { + expect(factory).toHaveBeenCalledTimes(1); + expect(factory).toHaveBeenCalledWith({ + region: "eu-west-1", + endpoint: "https://payments.example.test", + }); + } + }); + + test("rejects ARN selectors and the removed --manager-arn flag before SDK calls", async () => { + const { run, createControlClient, createDataClient } = createCommandTest({ resource }); + await expect( + run([resource, verb, "--manager-id", MANAGER_ARN, "--user-id", USER_ID, ...flags]), + ).rejects.toThrow("use a payment manager ID, not an ARN"); + for (const idArgs of [[], scoped.flat()]) { + await expect( + run([resource, verb, ...idArgs, ...flags, "--manager-arn", MANAGER_ARN]), + ).rejects.toThrow("unknown option '--manager-arn'"); + } + expect(createControlClient).not.toHaveBeenCalled(); + expect(createDataClient).not.toHaveBeenCalled(); + }); + + test("preserves a parent lookup error without calling the data plane", async () => { + const error = new ResourceNotFoundException({ + message: "Payment manager not found", + $metadata: {}, + }); + const controlSend = mock(async () => { + throw error; + }); + const { run, io, createDataClient } = createCommandTest({ resource, controlSend }); + await expect(run([resource, verb, ...scoped.flat(), ...flags])).rejects.toBe(error); + expect(controlSend).toHaveBeenCalledTimes(1); + expect(createDataClient).not.toHaveBeenCalled(); + expect(io.stdout()).toBe(""); + expect(io.stderr()).toBe(""); + }); + + test.each([ + { + manager: { paymentManagerArn: MANAGER_ARN, authorizerType: "CUSTOM_JWT" }, + message: "uses the CUSTOM_JWT authorizer", + }, + { manager: { authorizerType: "AWS_IAM" }, message: "returned no ARN" }, + ])("rejects an unusable manager: $message", async ({ manager, message }) => { + const { run, io, createDataClient } = createCommandTest({ + resource, + controlSend: async () => manager, + }); + await expect(run([resource, verb, ...scoped.flat(), ...flags])).rejects.toThrow(message); + expect(createDataClient).not.toHaveBeenCalled(); + expect(io.stdout()).toBe(""); + }); + }); +} + +describe("payment connector read-only hints", () => { + test.each([ + "AUTHENTICATION_EXPIRED", + "AUTHENTICATION_FAILED", + "PENDING_AUTHENTICATION", + "READY", + ] as const)("preserves %s without suggesting absent write commands", async (status) => { + for (const jsonFlags of [[], ["--json"]]) { + const response = { + ...parse(JSON.stringify(connectorFixture)), + status, + }; + const send = mock(async (command: { input: unknown }) => { + expect(command).toBeInstanceOf(GetPaymentConnectorCommand); + expect(command.input).toEqual({ + paymentManagerId: MANAGER_ID, + paymentConnectorId: CONNECTOR_ID, + }); + return response; + }); + const { run, io } = createCommandTest({ resource: "connector", controlSend: send }); + await run([ + "connector", + "get", + "--manager-id", + MANAGER_ID, + "--connector-id", + CONNECTOR_ID, + ...jsonFlags, + ]); + expect(JSON.parse(io.stdout())).toEqual(JSON.parse(JSON.stringify(response))); + expect(send).toHaveBeenCalledTimes(1); + const terminal = status === "AUTHENTICATION_EXPIRED" || status === "AUTHENTICATION_FAILED"; + 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); + } else { + expect(io.stderr()).toBe(""); + } + } + }); +}); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index d4246de59..2a128f467 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -17,6 +17,7 @@ describe("createRootHandler", () => { "runtime", "memory", "gateway", + "payment", "eval", "feedback", "config", From 975d8192405fb0c1f51dec4df4917345b93c4c76 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 10 Sep 2026 21:12:56 +0000 Subject: [PATCH 6/6] test(payment): consolidate read command coverage --- src/core/payment.read.test.ts | 472 +++---------- ...tialProvidersCommand.8362171cbd70b263.json | 92 --- .../paymentCredentialProvider.read.test.tsx | 17 - ...aymentManagerCommand.894895e0c24c9098.json | 18 - ...ntialProviderCommand.9b6249ebbbb54d1a.json | 24 - .../instrument/balance/balance.test.tsx | 399 +++-------- src/handlers/payment/payment.read.test.tsx | 662 ++++-------------- src/testing/TestCoreClient.tsx | 210 +----- src/testing/index.tsx | 1 - 9 files changed, 340 insertions(+), 1555 deletions(-) delete mode 100644 src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json delete mode 100644 src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json delete mode 100644 src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json diff --git a/src/core/payment.read.test.ts b/src/core/payment.read.test.ts index b9b44be8f..66ad240fa 100644 --- a/src/core/payment.read.test.ts +++ b/src/core/payment.read.test.ts @@ -1,71 +1,25 @@ -import { describe, expect, mock, test } from "bun:test"; -import { - GetPaymentConnectorCommand, - GetPaymentManagerCommand, - ListPaymentConnectorsCommand, - ListPaymentManagersCommand, - type GetPaymentConnectorResponse, - type GetPaymentManagerResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { - GetPaymentInstrumentBalanceCommand, - GetPaymentInstrumentCommand, - GetPaymentSessionCommand, - ListPaymentInstrumentsCommand, - ListPaymentSessionsCommand, - type GetPaymentInstrumentBalanceResponse, - type PaymentInstrument, - type PaymentSession, -} from "@aws-sdk/client-bedrock-agentcore"; -import { ERROR_SOURCE, InputValidationError, MalformedServiceResponseError } from "../errors"; -import type { CorePaymentClient } from "../handlers/payment/types"; +import { expect, mock, test } from "bun:test"; +import { GetPaymentManagerCommand } from "@aws-sdk/client-bedrock-agentcore-control"; +import { ListPaymentSessionsCommand } from "@aws-sdk/client-bedrock-agentcore"; import { PaymentClient } from "./payment"; -import type { AwsClients, ClientConfig, CoreOptions } from "./types"; +import type { AwsClients, ClientConfig } from "./types"; const MANAGER_ID = "checkout-abc1234567"; const MANAGER_ARN = `arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/${MANAGER_ID}`; -const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; -const options: CoreOptions = { +const request = { managerId: MANAGER_ID, userId: "alice" }; +const options = { region: "us-east-1", - endpointUrl: "https://example.test/payments", - credentials, -}; -const config = { region: options.region, endpoint: options.endpointUrl, credentials }; -const timestamp = new Date("2026-09-01T00:00:00Z"); -const manager: GetPaymentManagerResponse = { - paymentManagerArn: MANAGER_ARN, - paymentManagerId: MANAGER_ID, - name: "Checkout", - authorizerType: "AWS_IAM", - roleArn: "arn:aws:iam::123456789012:role/Payments", - createdAt: timestamp, - lastUpdatedAt: timestamp, - status: "READY", -}; -const connector: GetPaymentConnectorResponse = { - paymentConnectorId: "connector-1", - name: "Coinbase", - type: "CoinbaseCDP", - credentialProviderConfigurations: [], - createdAt: timestamp, - lastUpdatedAt: timestamp, - status: "PENDING_AUTHENTICATION", - authorizationUrl: "https://example.test/authorize", -}; - -interface SdkCommand { - constructor: { name: string }; - input: unknown; -} -type Send = (command: SdkCommand) => Promise; - -const unexpected: Send = async (command) => { - throw new Error(`unexpected ${command.constructor.name}`); + endpointUrl: "https://payments.example.test", + credentials: { accessKeyId: "test-key", secretAccessKey: "test-secret" }, }; -function paymentClient(sends: { control?: Send; data?: Send } = {}) { - const controlSend = mock(sends.control ?? unexpected); - const dataSend = mock(sends.data ?? unexpected); +function setup() { + const controlSend = mock(async (_command: unknown) => ({ + paymentManagerArn: MANAGER_ARN, + authorizerType: "AWS_IAM", + })); + const response = { paymentSessions: [] }; + const dataSend = mock(async (_command: unknown) => response); const control = mock( (_config: ClientConfig) => ({ send: controlSend }) as unknown as ReturnType, @@ -73,337 +27,85 @@ 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 }; + return { + client: new PaymentClient({ control, data }), + control, + data, + controlSend, + dataSend, + response, + }; } -function serviceError(name: string, message: string): Error { - return Object.assign(new Error(message), { - name, - $metadata: { requestId: "request-1", httpStatusCode: 400 }, +test("resolves the manager in the configured region and forwards the returned ARN and context", async () => { + const { client, control, data, controlSend, dataSend, response } = setup(); + const input = { ...request, agentName: "agent", nextToken: "page+2/=", maxResults: 5 }; + expect(await client.listPaymentSessions(input, options)).toBe(response); + expect(controlSend.mock.calls[0]?.[0]).toBeInstanceOf(GetPaymentManagerCommand); + expect(controlSend.mock.calls[0]?.[0]).toMatchObject({ input: { paymentManagerId: MANAGER_ID } }); + expect(dataSend.mock.calls[0]?.[0]).toBeInstanceOf(ListPaymentSessionsCommand); + expect((dataSend.mock.calls[0]![0] as ListPaymentSessionsCommand).input).toEqual({ + paymentManagerArn: MANAGER_ARN, + userId: "alice", + agentName: "agent", + nextToken: "page+2/=", + maxResults: 5, }); -} - -const controlReads = [ - { - command: GetPaymentManagerCommand, - input: { paymentManagerId: MANAGER_ID }, - response: manager, - run: (client: CorePaymentClient) => client.getPaymentManager(MANAGER_ID, options), - }, - { - command: ListPaymentManagersCommand, - input: { nextToken: "page-2", maxResults: 5 }, - response: { paymentManagers: [manager], nextToken: "page-3" }, - run: (client: CorePaymentClient) => client.listPaymentManagers("page-2", 5, options), - }, - { - command: GetPaymentConnectorCommand, - input: { paymentManagerId: MANAGER_ID, paymentConnectorId: "connector-1" }, - response: connector, - run: (client: CorePaymentClient) => - client.getPaymentConnector(MANAGER_ID, "connector-1", options), - }, - { - command: ListPaymentConnectorsCommand, - input: { paymentManagerId: MANAGER_ID, nextToken: "page-2", maxResults: 5 }, - response: { paymentConnectors: [connector], nextToken: "page-3" }, - run: (client: CorePaymentClient) => - client.listPaymentConnectors(MANAGER_ID, "page-2", 5, options), - }, -]; - -describe("PaymentClient control-plane reads", () => { - test.each(controlReads)( - "$command.name preserves request, response, and configured context", - async ({ command, input, response, run }) => { - const { client, control, controlSend, data } = paymentClient({ - control: async () => response, - }); - await expect(run(client)).resolves.toBe(response); - expect(controlSend).toHaveBeenCalledTimes(1); - const sent = controlSend.mock.calls[0]![0]; - expect(sent).toBeInstanceOf(command); - expect(sent.input).toEqual(input); - expect(control).toHaveBeenCalledWith(config); - expect(data).not.toHaveBeenCalled(); - }, - ); + const config = { + region: options.region, + endpoint: options.endpointUrl, + credentials: options.credentials, + }; + expect(control).toHaveBeenCalledWith(config); + expect(data).toHaveBeenCalledWith(config); + + controlSend.mockResolvedValueOnce({ + paymentManagerArn: `${MANAGER_ARN}-new`, + authorizerType: "AWS_IAM", + }); + await client.listPaymentSessions(request, options); + expect(controlSend).toHaveBeenCalledTimes(2); + expect(dataSend.mock.calls[1]?.[0]).toMatchObject({ + input: { paymentManagerArn: `${MANAGER_ARN}-new`, userId: "alice" }, + }); + expect(input).toEqual({ ...request, agentName: "agent", nextToken: "page+2/=", maxResults: 5 }); +}); - test.each(controlReads)( - "$command.name preserves the original service error without retrying", - async ({ run }) => { - const error = serviceError("AccessDeniedException", "control-plane failure"); - const { client, controlSend, data } = paymentClient({ - control: async () => { - throw error; - }, - }); - await expect(run(client)).rejects.toBe(error); - expect(controlSend).toHaveBeenCalledTimes(1); - expect(data).not.toHaveBeenCalled(); - }, +test("rejects an ARN selector before contacting AWS", async () => { + const { client, control, data } = setup(); + await expect(client.listPaymentSessions({ managerId: MANAGER_ARN }, options)).rejects.toThrow( + "use a payment manager ID, not an ARN", ); - - test("manager and connector lists allow omitted pagination", async () => { - const { client, controlSend } = paymentClient({ control: async () => ({}) }); - await client.listPaymentManagers(undefined, undefined, { region: "us-west-2" }); - await client.listPaymentConnectors(MANAGER_ID, undefined, undefined, { region: "us-west-2" }); - expect(controlSend.mock.calls.map(([command]) => command.input)).toEqual([ - { nextToken: undefined, maxResults: undefined }, - { paymentManagerId: MANAGER_ID, nextToken: undefined, maxResults: undefined }, - ]); - }); + expect(control).not.toHaveBeenCalled(); + expect(data).not.toHaveBeenCalled(); }); -const scoped = { managerId: MANAGER_ID, userId: "alice", agentName: "checkout-agent" }; -const session = { ...scoped, paymentSessionId: "session-1" }; -const sessionList = { ...scoped, nextToken: "page-2", maxResults: 5 }; -const instrument = { - ...scoped, - paymentConnectorId: "connector-1", - paymentInstrumentId: "instrument-1", -}; -const instrumentList = { - ...scoped, - paymentConnectorId: "connector-1", - nextToken: "page-2", - maxResults: 2, -}; -const balance = { ...instrument, chain: "BASE_SEPOLIA" as const, token: "USDC" as const }; -const paymentSession: PaymentSession = { - paymentSessionId: "session-1", - paymentManagerArn: MANAGER_ARN, - userId: "alice", - expiryTimeInMinutes: 60, - createdAt: timestamp, - updatedAt: timestamp, -}; -const paymentInstrument: PaymentInstrument = { - paymentInstrumentId: "instrument-1", - paymentManagerArn: MANAGER_ARN, - paymentConnectorId: "connector-1", - userId: "alice", - paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET", - paymentInstrumentDetails: undefined, - status: "ACTIVE", - createdAt: timestamp, - updatedAt: timestamp, -}; -const balanceResponse: GetPaymentInstrumentBalanceResponse = { - paymentInstrumentId: "instrument-1", - tokenBalance: { - amount: "123456789012345678901234567890", - decimals: 6, - network: "ETHEREUM", - chain: "BASE_SEPOLIA", - token: "USDC", +test.each([ + [{ paymentManagerArn: MANAGER_ARN, authorizerType: "CUSTOM_JWT" }, "CUSTOM_JWT"], + [{ paymentManagerArn: "", authorizerType: "AWS_IAM" }, "returned no ARN"], +] as const)( + "rejects an unusable manager before data-plane access: %j", + async (manager, message) => { + const { client, controlSend, data } = setup(); + controlSend.mockResolvedValueOnce(manager); + await expect(client.listPaymentSessions(request, options)).rejects.toThrow(message); + expect(data).not.toHaveBeenCalled(); }, -}; -const dataReads = [ - { - command: GetPaymentSessionCommand, - input: session, - response: { paymentSession }, - run: (client: CorePaymentClient, managerId = MANAGER_ID) => - client.getPaymentSession({ ...session, managerId }, options), - }, - { - command: ListPaymentSessionsCommand, - input: sessionList, - response: { paymentSessions: [paymentSession], nextToken: "page-3" }, - run: (client: CorePaymentClient, managerId = MANAGER_ID) => - client.listPaymentSessions({ ...sessionList, managerId }, options), - }, - { - command: GetPaymentInstrumentCommand, - input: instrument, - response: { paymentInstrument }, - run: (client: CorePaymentClient, managerId = MANAGER_ID) => - client.getPaymentInstrument({ ...instrument, managerId }, options), - }, - { - command: ListPaymentInstrumentsCommand, - input: instrumentList, - response: { paymentInstruments: [paymentInstrument], nextToken: "page-3" }, - run: (client: CorePaymentClient, managerId = MANAGER_ID) => - client.listPaymentInstruments({ ...instrumentList, managerId }, options), - }, - { - command: GetPaymentInstrumentBalanceCommand, - input: balance, - response: balanceResponse, - run: (client: CorePaymentClient, managerId = MANAGER_ID) => - client.getPaymentInstrumentBalance({ ...balance, managerId }, options), - }, -]; - -for (const { command, input, response, run } of dataReads) { - describe(`PaymentClient ${command.name}`, () => { - test("resolves the ID once before sending the unchanged request with the returned ARN", async () => { - const calls: string[] = []; - const { client, control, data, controlSend, dataSend } = paymentClient({ - control: async (sent) => { - calls.push(sent.constructor.name); - expect(sent).toBeInstanceOf(GetPaymentManagerCommand); - expect(sent.input).toEqual({ paymentManagerId: MANAGER_ID }); - return manager; - }, - data: async (sent) => { - calls.push(sent.constructor.name); - expect(sent).toBeInstanceOf(command); - const { managerId: _managerId, ...request } = input; - expect(sent.input).toEqual({ ...request, paymentManagerArn: MANAGER_ARN }); - expect(sent.input).not.toHaveProperty("managerId"); - return response; - }, - }); - - await expect(run(client)).resolves.toBe(response); - expect(calls).toEqual(["GetPaymentManagerCommand", command.name]); - expect(controlSend).toHaveBeenCalledTimes(1); - expect(dataSend).toHaveBeenCalledTimes(1); - expect(control).toHaveBeenCalledWith(config); - expect(data).toHaveBeenCalledWith(config); - expect(input).toHaveProperty("managerId", MANAGER_ID); - expect(input).not.toHaveProperty("paymentManagerArn"); - }); - - test("rejects an ARN used as an ID before configuring either SDK client", async () => { - const { client, control, data } = paymentClient(); - const failure = run(client, MANAGER_ARN); - await expect(failure).rejects.toBeInstanceOf(InputValidationError); - await expect(failure).rejects.toThrow(/manager ID, not an ARN/); - expect(control).not.toHaveBeenCalled(); - expect(data).not.toHaveBeenCalled(); - }); - - test("rejects CUSTOM_JWT before configuring the data client", async () => { - const { client, controlSend, data } = paymentClient({ - control: async () => ({ ...manager, authorizerType: "CUSTOM_JWT" }), - }); - const failure = run(client); - await expect(failure).rejects.toBeInstanceOf(InputValidationError); - 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(controlSend).toHaveBeenCalledTimes(1); - expect(data).not.toHaveBeenCalled(); - }); - - test.each([undefined, ""])("rejects a missing manager ARN (%s)", async (paymentManagerArn) => { - const { client, controlSend, data } = paymentClient({ - control: async () => ({ ...manager, paymentManagerArn }), - }); - const failure = run(client); - await expect(failure).rejects.toBeInstanceOf(MalformedServiceResponseError); - await expect(failure).rejects.toThrow(/returned no ARN/); - expect(controlSend).toHaveBeenCalledTimes(1); - expect(data).not.toHaveBeenCalled(); - }); - - test.each(["ResourceNotFoundException", "AccessDeniedException"])( - "preserves a lookup %s and stops before data-plane access", - async (name) => { - const error = serviceError(name, "GetPaymentManager failed"); - const { client, control, controlSend, data } = paymentClient({ - control: async () => { - throw error; - }, - }); - await expect(run(client)).rejects.toBe(error); - expect(control).toHaveBeenCalledWith(config); - expect(controlSend).toHaveBeenCalledTimes(1); - expect(data).not.toHaveBeenCalled(); - }, - ); - - test.each(["AccessDeniedException", "ValidationException", "ThrottlingException"])( - "preserves a data-plane %s without a second lookup or fallback response", - async (name) => { - const error = serviceError(name, "data-plane failure"); - const { client, controlSend, dataSend } = paymentClient({ - control: async () => manager, - data: async () => { - throw error; - }, - }); - await expect(run(client)).rejects.toBe(error); - expect(controlSend).toHaveBeenCalledTimes(1); - expect(dataSend).toHaveBeenCalledTimes(1); - }, - ); - }); -} - -describe("PaymentClient read boundaries", () => { - test("balance retains an atomic amount beyond numeric precision and its decimals", async () => { - const { client } = paymentClient({ - control: async () => manager, - data: async () => balanceResponse, - }); - const result = await client.getPaymentInstrumentBalance(balance, options); - expect(result).toBe(balanceResponse); - expect(result.tokenBalance?.amount).toBe("123456789012345678901234567890"); - expect(result.tokenBalance?.decimals).toBe(6); - }); - - test("each call resolves the manager again and uses the latest returned ARN", async () => { - const latestArn = - "arn:aws:bedrock-agentcore:eu-west-1:123456789012:payment-manager/checkout-current"; - let lookups = 0; - const { client, controlSend, data, dataSend } = paymentClient({ - control: async () => ({ - ...manager, - paymentManagerArn: ++lookups === 1 ? MANAGER_ARN : latestArn, - }), - data: async () => balanceResponse, - }); - await client.getPaymentInstrumentBalance(balance, options); - await client.getPaymentInstrumentBalance(balance, options); - expect(controlSend).toHaveBeenCalledTimes(2); - const { managerId: _managerId, ...request } = balance; - expect(dataSend.mock.calls.map(([command]) => command.input)).toEqual([ - { ...request, paymentManagerArn: MANAGER_ARN }, - { ...request, paymentManagerArn: latestArn }, - ]); - expect(data.mock.calls.map(([clientConfig]) => clientConfig)).toEqual([config, config]); - }); - - test("does not cache manager authorization across calls", async () => { - let lookups = 0; - const { client, controlSend, dataSend } = paymentClient({ - control: async () => ({ - ...manager, - authorizerType: ++lookups === 1 ? "AWS_IAM" : "CUSTOM_JWT", - }), - data: async () => balanceResponse, - }); - await client.getPaymentInstrumentBalance(balance, options); - await expect(client.getPaymentInstrumentBalance(balance, options)).rejects.toThrow( - /CUSTOM_JWT/, - ); - expect(controlSend).toHaveBeenCalledTimes(2); - expect(dataSend).toHaveBeenCalledTimes(1); - }); +); + +test("preserves a lookup failure without contacting the data plane", async () => { + const { client, controlSend, data } = setup(); + const error = new Error("Payment manager not found"); + controlSend.mockRejectedValueOnce(error); + await expect(client.listPaymentSessions(request, options)).rejects.toBe(error); + expect(data).not.toHaveBeenCalled(); +}); - test("session and instrument lists preserve optional field omission and default client context", async () => { - const { client, control, data, dataSend } = paymentClient({ - control: async () => manager, - data: async () => ({}), - }); - await client.listPaymentSessions({ managerId: MANAGER_ID }, { region: "us-west-2" }); - await client.listPaymentInstruments({ managerId: MANAGER_ID }, { region: "us-west-2" }); - expect(dataSend.mock.calls.map(([command]) => command.input)).toEqual([ - { paymentManagerArn: MANAGER_ARN }, - { paymentManagerArn: MANAGER_ARN }, - ]); - expect(control.mock.calls.map(([clientConfig]) => clientConfig)).toEqual([ - { region: "us-west-2" }, - { region: "us-west-2" }, - ]); - expect(data.mock.calls.map(([clientConfig]) => clientConfig)).toEqual([ - { region: "us-west-2" }, - { region: "us-west-2" }, - ]); - }); +test("preserves a data-plane failure without retrying the lookup", async () => { + const { client, controlSend, dataSend } = setup(); + const error = new Error("Access denied"); + dataSend.mockRejectedValueOnce(error); + await expect(client.listPaymentSessions(request, options)).rejects.toBe(error); + expect(controlSend).toHaveBeenCalledTimes(1); + expect(dataSend).toHaveBeenCalledTimes(1); }); diff --git a/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json b/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json deleted file mode 100644 index edf0f9a2d..000000000 --- a/src/handlers/identity/payment-credential-provider/__fixtures__/ListPaymentCredentialProvidersCommand.8362171cbd70b263.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "credentialProviders": [ - { - "name": "DeployTest-CdpConn-cdp", - "credentialProviderVendor": "CoinbaseCDP", - "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/DeployTest-CdpConn-cdp", - "createdTime": { - "$date": "2026-05-14T15:48:17.857Z" - }, - "lastUpdatedTime": { - "$date": "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": { - "$date": "2026-05-20T19:09:36.566Z" - }, - "lastUpdatedTime": { - "$date": "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": { - "$date": "2026-05-05T03:02:59.583Z" - }, - "lastUpdatedTime": { - "$date": "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": { - "$date": "2026-05-21T19:22:56.308Z" - }, - "lastUpdatedTime": { - "$date": "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": { - "$date": "2026-06-08T18:10:19.508Z" - }, - "lastUpdatedTime": { - "$date": "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": { - "$date": "2026-05-12T21:18:46.009Z" - }, - "lastUpdatedTime": { - "$date": "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": { - "$date": "2026-05-21T21:19:41.791Z" - }, - "lastUpdatedTime": { - "$date": "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": { - "$date": "2026-05-22T01:59:54.168Z" - }, - "lastUpdatedTime": { - "$date": "2026-05-22T02:42:24.884Z" - } - } - ] -} \ No newline at end of file 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..65b7003dc 100644 --- a/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx +++ b/src/handlers/identity/payment-credential-provider/paymentCredentialProvider.read.test.tsx @@ -123,20 +123,3 @@ describe("payment-credential-provider list", () => { expect(second.nextToken).not.toBe(first.nextToken); }); }); - -describe("payment-credential-provider read flag validation", () => { - test.each([ - ["get --name without a value", ["get", "--name"], /--name/], - ["get with an unknown flag", ["get", "--id", FIXTURE_PROVIDER_NAME], /unknown option '--id'/], - ["list --max-results without a value", ["list", "--max-results"], /--max-results/], - ["list --max-results with a non-number", ["list", "--max-results", "abc"], /--max-results/], - ["list --next-token without a value", ["list", "--next-token"], /--next-token/], - [ - "list with an unknown flag", - ["list", "--name", FIXTURE_PROVIDER_NAME], - /unknown option '--name'/, - ], - ] as const)("rejects %s", async (_label, args, message) => { - await expect(run([...args])).rejects.toThrow(message); - }); -}); 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__/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/instrument/balance/balance.test.tsx b/src/handlers/payment/instrument/balance/balance.test.tsx index e2a7c80d9..6935c88d4 100644 --- a/src/handlers/payment/instrument/balance/balance.test.tsx +++ b/src/handlers/payment/instrument/balance/balance.test.tsx @@ -1,91 +1,41 @@ -import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; +import { expect, mock, test } from "bun:test"; import { GetPaymentInstrumentBalanceCommand, GetPaymentInstrumentCommand, - ResourceNotFoundException, - ValidationException, type BedrockAgentCoreClient, - type GetPaymentInstrumentBalanceResponse, - type GetPaymentInstrumentResponse, } from "@aws-sdk/client-bedrock-agentcore"; -import { - GetPaymentManagerCommand, - type BedrockAgentCoreControlClient, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { CoreClient, type ClientConfig } from "../../../../core"; +import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; +import { CoreClient } from "../../../../core"; import { createRootHandler } from "../../../index"; -import { - createSilentLogger, - fixtureFactories, - TestGlobalConfigAccessor, - testIO, -} from "../../../../testing"; - -const FIXTURES = join(import.meta.dir, "..", "..", "__fixtures__", "instrument"); -const REGION = "us-west-2"; -const MANAGER_ID = "balance-manager"; -const MANAGER_ARN = - "arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/balance-manager"; -const USER_ID = "balance-user"; -const CONNECTOR_ID = "balance-connector"; -const INSTRUMENT_ID = "balance-instrument"; +import { createSilentLogger, TestGlobalConfigAccessor, testIO } from "../../../../testing"; -// Synthetic SDK response, not evidence of a live funded-wallet query. -const BALANCE_RESPONSE = { - paymentInstrumentId: INSTRUMENT_ID, +const ARN = "arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/manager"; +const scope = ["--manager-id", "manager", "--user-id", "user", "--instrument-id", "instrument"]; +// Synthetic response: fixture replay does not require a funded wallet. +const balance = { + paymentInstrumentId: "instrument", tokenBalance: { amount: "9007199254740993123456789", decimals: 6, token: "USDC", network: "ETHEREUM", - chain: "BASE", + chain: "BASE_SEPOLIA", }, -} satisfies GetPaymentInstrumentBalanceResponse; +}; -const requiredFlags = [ - ["--manager-id", MANAGER_ID], - ["--user-id", USER_ID], - ["--connector-id", CONNECTOR_ID], - ["--instrument-id", INSTRUMENT_ID], - ["--chain", "BASE"], -] as const; - -function createCommandTest({ - response = BALANCE_RESPONSE, - error, -}: { - response?: GetPaymentInstrumentBalanceResponse | GetPaymentInstrumentResponse; - error?: Error; -} = {}) { - const sent: { input: unknown }[] = []; - const configs: ClientConfig[] = []; - const lookups: GetPaymentManagerCommand[] = []; - const controlConfigs: ClientConfig[] = []; - const { createIamClient, createLogsClient } = fixtureFactories(FIXTURES); +function setup(response: object = balance) { + const send = mock(async (_command: unknown) => response); + const unexpectedClient = () => { + throw new Error("Unexpected AWS client"); + }; const core = new CoreClient({ - createControlClient: (config) => { - controlConfigs.push(config); - return { - send: async (command: GetPaymentManagerCommand) => { - expect(command).toBeInstanceOf(GetPaymentManagerCommand); - lookups.push(command); - return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; - }, - } as unknown as BedrockAgentCoreControlClient; - }, - createDataClient: (config) => { - configs.push(config); - return { - send: async (command: { input: unknown }) => { - sent.push(command); - if (error) throw error; - return response; - }, - } as unknown as BedrockAgentCoreClient; - }, - createIamClient, - createLogsClient, + createControlClient: () => + ({ + send: async () => ({ paymentManagerArn: ARN, authorizerType: "AWS_IAM" }), + }) as unknown as BedrockAgentCoreControlClient, + createDataClient: () => ({ send }) as unknown as BedrockAgentCoreClient, + createIamClient: unexpectedClient, + createLogsClient: unexpectedClient, logger: createSilentLogger(), }); const io = testIO(); @@ -95,12 +45,9 @@ function createCommandTest({ globalConfigAccessor: new TestGlobalConfigAccessor(), }); return { + send, io, - sent, - configs, - lookups, - controlConfigs, - run: (args: string[], region = REGION) => + run: (args: string[]) => root.route([ "node", "agentcore", @@ -108,259 +55,69 @@ function createCommandTest({ "instrument", ...args, "--region", - region, + "us-west-2", "--json", ]), }; } -describe("payment instrument balance", () => { - test("sends the balance command with exact scope and a default USDC token", async () => { - const { run, io, sent, lookups } = createCommandTest(); - await run(["balance", ...requiredFlags.flat()]); - - expect(lookups).toHaveLength(1); - expect(lookups[0]?.input).toStrictEqual({ paymentManagerId: MANAGER_ID }); - expect(sent).toHaveLength(1); - expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); - expect(sent[0]?.input).toStrictEqual({ - paymentManagerArn: MANAGER_ARN, - userId: USER_ID, - paymentConnectorId: CONNECTOR_ID, - paymentInstrumentId: INSTRUMENT_ID, - chain: "BASE", - token: "USDC", - }); - expect(JSON.parse(io.stdout())).toEqual(BALANCE_RESPONSE); - }); - - test("forwards an explicit token and optional agent name", async () => { - const { run, sent } = createCommandTest(); - await run([ - "balance", - ...requiredFlags.flat(), - "--token", - "USDC", - "--agent-name", - "balance-agent", - ]); - - expect(sent).toHaveLength(1); - expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); - expect(sent[0]?.input).toStrictEqual({ - paymentManagerArn: MANAGER_ARN, - userId: USER_ID, - paymentConnectorId: CONNECTOR_ID, - paymentInstrumentId: INSTRUMENT_ID, - chain: "BASE", - token: "USDC", - agentName: "balance-agent", - }); - }); - - test.each(["BASE", "BASE_SEPOLIA", "ETHEREUM", "SOLANA", "SOLANA_DEVNET"])( - "forwards the %s chain unchanged", - async (chain) => { - const { run, sent } = createCommandTest(); - await run([ - "balance", - ...requiredFlags.filter(([name]) => name !== "--chain").flat(), - "--chain", - chain, - ]); - - expect(sent).toHaveLength(1); - expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); - expect(sent[0]?.input).toStrictEqual({ - paymentManagerArn: MANAGER_ARN, - userId: USER_ID, - paymentConnectorId: CONNECTOR_ID, - paymentInstrumentId: INSTRUMENT_ID, - chain, - token: "USDC", - }); - }, - ); - - test("preserves atomic amounts beyond MAX_SAFE_INTEGER and token decimals in JSON", async () => { - const { run, io } = createCommandTest(); - await run(["balance", ...requiredFlags.flat()]); - - expect(JSON.parse(io.stdout())).toEqual({ - paymentInstrumentId: INSTRUMENT_ID, - tokenBalance: { - amount: "9007199254740993123456789", - decimals: 6, - token: "USDC", - network: "ETHEREUM", - chain: "BASE", - }, - }); - }); - - test("preserves a successful zero balance as the raw string", async () => { - const response: GetPaymentInstrumentBalanceResponse = { - ...BALANCE_RESPONSE, - tokenBalance: { ...BALANCE_RESPONSE.tokenBalance, amount: "0" }, - }; - const { run, io } = createCommandTest({ response }); - await run(["balance", ...requiredFlags.flat()]); - - expect(JSON.parse(io.stdout())).toEqual(response); - expect(JSON.parse(io.stdout()).tokenBalance.amount).toBe("0"); - }); - - test("forwards context region and endpoint to both SDK factories without deriving the ARN region", async () => { - const { run, configs, controlConfigs, sent } = createCommandTest(); - await run( - ["balance", ...requiredFlags.flat(), "--endpoint-url", "https://payments.example.test"], - "eu-west-1", - ); - - expect(configs).toEqual([{ region: "eu-west-1", endpoint: "https://payments.example.test" }]); - expect(controlConfigs).toEqual([ - { region: "eu-west-1", endpoint: "https://payments.example.test" }, - ]); - expect(sent).toHaveLength(1); - expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); +test("balance sends the selected chain and complete scope, defaults USDC, and preserves atomic precision", async () => { + const { run, send, io } = setup(); + await run([ + "balance", + ...scope, + "--connector-id", + "connector", + "--chain", + "BASE_SEPOLIA", + "--agent-name", + "agent", + ]); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); + expect((send.mock.calls[0]![0] as GetPaymentInstrumentBalanceCommand).input).toEqual({ + paymentManagerArn: ARN, + userId: "user", + paymentInstrumentId: "instrument", + paymentConnectorId: "connector", + chain: "BASE_SEPOLIA", + token: "USDC", + agentName: "agent", }); + expect(JSON.parse(io.stdout())).toEqual(balance); }); -describe("payment instrument balance validation", () => { - test("rejects the removed --manager-arn flag, including alongside --manager-id", async () => { - const { run, io, sent, lookups } = createCommandTest(); - for (const idArgs of [[], requiredFlags.flat()]) { - await expect(run(["balance", ...idArgs, "--manager-arn", MANAGER_ARN])).rejects.toThrow( - /unknown option '--manager-arn'/, - ); - } - expect(sent).toEqual([]); - expect(lookups).toEqual([]); - expect(io.stdout()).toBe(""); - }); - - test.each(["manager-id", "user-id", "connector-id", "instrument-id", "chain"])( - "requires --%s before calling the SDK", - async (name) => { - const { run, io, sent, configs, lookups } = createCommandTest(); - await expect( - run(["balance", ...requiredFlags.filter(([flag]) => flag !== `--${name}`).flat()]), - ).rejects.toThrow(`required option '--${name} <${name}>' not specified`); - - expect(sent).toEqual([]); - expect(configs).toEqual([]); - expect(lookups).toEqual([]); - expect(io.stdout()).toBe(""); - }, - ); - - test.each(["manager-id", "user-id", "connector-id", "instrument-id"])( - "rejects an explicitly empty --%s before calling the SDK", - async (name) => { - const { run, io, sent, configs, lookups } = createCommandTest(); - await expect( - run([ - "balance", - ...requiredFlags.map(([flag, value]) => [flag, flag === `--${name}` ? "" : value]).flat(), - ]), - ).rejects.toThrow(`required option '--${name} <${name}>' not specified`); +test("preserves a successful zero balance but never turns an error into zero", async () => { + const zero = { ...balance, tokenBalance: { ...balance.tokenBalance, amount: "0" } }; + const success = setup(zero); + const args = ["balance", ...scope, "--connector-id", "connector", "--chain", "BASE"]; + await success.run(args); + expect(JSON.parse(success.io.stdout())).toEqual(zero); + + const failure = setup(); + const error = new Error("No USDC balance is available"); + failure.send.mockRejectedValueOnce(error); + await expect(failure.run(args)).rejects.toBe(error); + expect(failure.io.stdout()).toBe(""); +}); - expect(sent).toEqual([]); - expect(configs).toEqual([]); - expect(lookups).toEqual([]); - expect(io.stdout()).toBe(""); - }, +test.each([ + [[], "--chain"], + [["--chain", "base"], "Invalid value for option '--chain'"], + [["--chain", "BASE", "--token", "ETH"], "Invalid value for option '--token'"], +] as const)("requires an explicit supported chain and token: %j", async (flags, message) => { + const { run, send } = setup(); + await expect(run(["balance", ...scope, "--connector-id", "connector", ...flags])).rejects.toThrow( + message, ); - - test.each(["BITCOIN", "base", "1", ""])("rejects invalid chain %j", async (chain) => { - const { run, io, sent, configs } = createCommandTest(); - await expect( - run([ - "balance", - ...requiredFlags.filter(([name]) => name !== "--chain").flat(), - "--chain", - chain, - ]), - ).rejects.toThrow(/Invalid value for option '--chain'/); - - expect(sent).toEqual([]); - expect(configs).toEqual([]); - expect(io.stdout()).toBe(""); - }); - - test.each(["ETH", "usdc", ""])("rejects invalid token %j", async (token) => { - const { run, io, sent, configs } = createCommandTest(); - await expect(run(["balance", ...requiredFlags.flat(), "--token", token])).rejects.toThrow( - /Invalid value for option '--token'/, - ); - - expect(sent).toEqual([]); - expect(configs).toEqual([]); - expect(io.stdout()).toBe(""); - }); - - test.each([ - new ValidationException({ - message: "No USDC balance is available for this instrument on BASE", - reason: "FieldValidationFailed", - $metadata: {}, - }), - new ResourceNotFoundException({ - message: "Payment instrument not found", - $metadata: {}, - }), - ])("surfaces $name without rendering a zero balance", async (error) => { - const { run, io, sent } = createCommandTest({ error }); - await expect(run(["balance", ...requiredFlags.flat()])).rejects.toBe(error); - - expect(sent).toHaveLength(1); - expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentBalanceCommand); - expect(io.stdout()).toBe(""); - }); + expect(send).not.toHaveBeenCalled(); }); -describe("payment instrument get remains separate from balance", () => { - test("gets metadata without requiring a chain or querying balance", async () => { - const response: GetPaymentInstrumentResponse = { - paymentInstrument: { - paymentInstrumentId: INSTRUMENT_ID, - paymentManagerArn: MANAGER_ARN, - paymentConnectorId: CONNECTOR_ID, - userId: USER_ID, - paymentInstrumentType: "EMBEDDED_CRYPTO_WALLET", - paymentInstrumentDetails: { - embeddedCryptoWallet: { - network: "ETHEREUM", - linkedAccounts: [{ email: { emailAddress: "balance@example.test" } }], - walletAddress: "0x1234567890abcdef1234567890abcdef12345678", - }, - }, - status: "ACTIVE", - createdAt: new Date("2026-09-09T00:00:00Z"), - updatedAt: new Date("2026-09-09T00:00:00Z"), - }, - }; - const { run, io, sent } = createCommandTest({ response }); - await run([ - "get", - "--manager-id", - MANAGER_ID, - "--user-id", - USER_ID, - "--instrument-id", - INSTRUMENT_ID, - ]); - - expect(sent).toHaveLength(1); - expect(sent[0]).toBeInstanceOf(GetPaymentInstrumentCommand); - expect(sent[0]?.input).toStrictEqual({ - paymentManagerArn: MANAGER_ARN, - userId: USER_ID, - paymentInstrumentId: INSTRUMENT_ID, - }); - const output = JSON.parse(io.stdout()); - expect(output.paymentInstrument.paymentInstrumentId).toBe(INSTRUMENT_ID); - expect(output.paymentInstrument.status).toBe("ACTIVE"); - expect(output).not.toHaveProperty("tokenBalance"); - }); +test("instrument get remains independent of balance and requires no chain", async () => { + const response = { paymentInstrument: { paymentInstrumentId: "instrument", status: "ACTIVE" } }; + const { run, send, io } = setup(response); + await run(["get", ...scope]); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]).toBeInstanceOf(GetPaymentInstrumentCommand); + expect(JSON.parse(io.stdout())).toEqual(response); }); diff --git a/src/handlers/payment/payment.read.test.tsx b/src/handlers/payment/payment.read.test.tsx index 8ef8211b2..2cb8dd8d3 100644 --- a/src/handlers/payment/payment.read.test.tsx +++ b/src/handlers/payment/payment.read.test.tsx @@ -1,86 +1,29 @@ -import { describe, expect, mock, spyOn, test } from "bun:test"; +import { expect, mock, spyOn, test } from "bun:test"; import { join } from "node:path"; -import { - GetPaymentInstrumentCommand, - GetPaymentSessionCommand, - ListPaymentInstrumentsCommand, - ListPaymentSessionsCommand, - type BedrockAgentCoreClient, -} from "@aws-sdk/client-bedrock-agentcore"; -import { - AccessDeniedException, - GetPaymentConnectorCommand, - GetPaymentManagerCommand, - ListPaymentConnectorsCommand, - ListPaymentManagersCommand, - ResourceNotFoundException, - type BedrockAgentCoreControlClient, - type GetPaymentConnectorResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { CoreClient, type ClientConfig } from "../../core"; +import { CoreClient } from "../../core"; import { compile, isTuiCommandSupported, ValueContext } from "../../router"; import { createSilentLogger, fixtureFactories, matchGolden, - parse, TestGlobalConfigAccessor, testIO, } from "../../testing"; import { createRootHandler } from "../index"; -import connectorFixture from "./__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json"; const FIXTURES = join(import.meta.dir, "__fixtures__"); -const REGION = "us-west-2"; -const EXISTING_MANAGER_ID = "mypaymentmanager-o4ks3qfgtb"; const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; -const MANAGER_ARN = - "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira"; const CONNECTOR_ID = "agentcorecliconnectore2e-6rodjuiuig"; const INSTRUMENT_CONNECTOR_ID = "mycdpconnectoraidandal-okve8guw4y"; const SESSION_ID = "payment-session-nq812U4e1BJIfw1"; const INSTRUMENT_ID = "payment-instrument-CG2Tl7U1HnCGfHW"; -const USER_ID = "agentcore-cli-e2e"; -const scoped = [ - ["--manager-id", MANAGER_ID], - ["--user-id", USER_ID], -]; +const scope = ["--manager-id", MANAGER_ID, "--user-id", "agentcore-cli-e2e"]; -type Resource = "manager" | "connector" | "session" | "instrument"; -type Send = (command: { input: unknown }) => Promise; - -function createCommandTest({ - resource = "manager", - controlSend, - dataSend, -}: { - resource?: Resource; - controlSend?: Send; - dataSend?: Send; -} = {}) { - const factories = fixtureFactories( - resource === "connector" ? join(FIXTURES, "connector") : FIXTURES, - ); - const dataFactories = fixtureFactories(join(FIXTURES, resource)); - const createControlClient = mock((config: ClientConfig) => - controlSend - ? ({ send: controlSend } as unknown as BedrockAgentCoreControlClient) - : factories.createControlClient(config), - ); - const createDataClient = mock((config: ClientConfig) => - dataSend - ? ({ send: dataSend } as unknown as BedrockAgentCoreClient) - : dataFactories.createDataClient(config), - ); - const unexpectedClient = () => { - throw new Error("payment reads must not use other SDK clients"); - }; +function setup(resource = "manager", overrides: Partial> = {}) { const core = new CoreClient({ - createControlClient, - createDataClient, - createIamClient: unexpectedClient, - createLogsClient: unexpectedClient, - createCloudFormationClient: unexpectedClient, + ...fixtureFactories(resource === "connector" ? join(FIXTURES, resource) : FIXTURES), + createDataClient: fixtureFactories(join(FIXTURES, resource)).createDataClient, + ...overrides, logger: createSilentLogger(), }); const io = testIO(); @@ -91,465 +34,166 @@ function createCommandTest({ }); return { core, - root, io, - createControlClient, - createDataClient, - run: (args: string[], region = REGION) => - root.route(["node", "agentcore", "payment", ...args, "--region", region]), + root, + run: (args: string[]) => + root.route(["node", "agentcore", "payment", ...args, "--region", "us-west-2"]), }; } -const reads = [ - { - resource: "manager", - verb: "get", - requiredFlags: [["--id", EXISTING_MANAGER_ID]], - expected: { paymentManagerId: EXISTING_MANAGER_ID, status: "READY" }, - }, - { - resource: "manager", - verb: "list", - requiredFlags: [], - expected: { - paymentManagers: expect.arrayContaining([ - expect.objectContaining({ paymentManagerId: EXISTING_MANAGER_ID }), - ]), - }, - }, - { - resource: "connector", - verb: "get", - requiredFlags: [ - ["--manager-id", MANAGER_ID], - ["--connector-id", CONNECTOR_ID], - ], - expected: { paymentConnectorId: CONNECTOR_ID, status: "READY" }, - }, - { - resource: "connector", - verb: "list", - requiredFlags: [["--manager-id", MANAGER_ID]], - expected: { - paymentConnectors: expect.arrayContaining([ - expect.objectContaining({ paymentConnectorId: CONNECTOR_ID }), - ]), - }, - }, - { - resource: "session", - verb: "get", - requiredFlags: [...scoped, ["--session-id", SESSION_ID]], - expected: { paymentSession: { paymentSessionId: SESSION_ID, userId: USER_ID } }, - }, - { - resource: "session", - verb: "list", - requiredFlags: scoped, - expected: { - paymentSessions: expect.arrayContaining([ - expect.objectContaining({ paymentSessionId: SESSION_ID }), - ]), - }, - }, - { - resource: "instrument", - verb: "get", - requiredFlags: [...scoped, ["--instrument-id", INSTRUMENT_ID]], - expected: { - paymentInstrument: { paymentInstrumentId: INSTRUMENT_ID, status: "ACTIVE" }, - }, - }, - { - resource: "instrument", - verb: "list", - requiredFlags: scoped, - expected: { - paymentInstruments: expect.arrayContaining([ - expect.objectContaining({ paymentInstrumentId: INSTRUMENT_ID }), - ]), - }, - }, -] satisfies { - resource: Resource; - verb: string; - requiredFlags: string[][]; - expected: object; -}[]; - -describe("payment read-only command tree", () => { - test("exposes exactly nine CLI-only leaves and no write commands", () => { - const { root } = createCommandTest(); - const payment = compile(root, ValueContext.EmptyContext()).commands.find( - (command) => command.name() === "payment", - ); - expect(payment).toBeDefined(); - expect(isTuiCommandSupported(payment!)).toBe(false); - expect( - Object.fromEntries( - payment!.commands.map((resource) => [ - resource.name(), - resource.commands.map((command) => command.name()), - ]), - ), - ).toEqual({ - manager: ["get", "list"], - connector: ["get", "list"], - session: ["get", "list"], - instrument: ["get", "list", "balance"], - }); - const leaves = payment!.commands.flatMap((resource) => resource.commands); - expect(leaves).toHaveLength(9); - for (const command of [...payment!.commands, ...leaves]) { - expect(isTuiCommandSupported(command)).toBe(false); - expect(command.options.map((option) => option.long)).not.toContain("--wait"); - expect(command.options.map((option) => option.long)).not.toContain("--browser"); - } +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", + )!; + expect( + Object.fromEntries( + payment.commands.map((resource) => [resource.name(), resource.commands.map((c) => c.name())]), + ), + ).toEqual({ + 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); + } }); -for (const { resource, verb, requiredFlags, expected } of reads) { - describe(`payment ${resource} ${verb} read`, () => { - test("replays an independent Get/List fixture through the real root and Core", async () => { - const { run, io } = createCommandTest({ resource }); - const filter = - resource === "instrument" && verb === "list" - ? ["--connector-id", INSTRUMENT_CONNECTOR_ID] - : []; - await run([resource, verb, ...requiredFlags.flat(), ...filter, "--json"]); - - matchGolden( - resource === "manager" ? FIXTURES : join(FIXTURES, resource), - `${resource}-${verb}.golden.json`, - io.stdout(), - ); - expect(JSON.parse(io.stdout())).toMatchObject(expected); - expect(io.stderr()).toBe(""); - }); - - for (const [flagName] of requiredFlags) { - test.each(["omitted", "empty"])( - `rejects %s ${flagName} before constructing SDK clients`, - async (mode) => { - const { run, io, createControlClient, createDataClient } = createCommandTest({ - resource, - }); - const flags = requiredFlags.flatMap(([name, value]) => - name !== flagName ? [name!, value!] : mode === "empty" ? [name!, ""] : [], - ); - await expect(run([resource, verb, ...flags])).rejects.toThrow( - `required option '${flagName} <${flagName!.slice(2)}>' not specified`, - ); - expect(createControlClient).not.toHaveBeenCalled(); - expect(createDataClient).not.toHaveBeenCalled(); - expect(io.stdout()).toBe(""); - expect(io.stderr()).toBe(""); - }, - ); - } - - test.each([ - new ResourceNotFoundException({ message: "Payment resource not found", $metadata: {} }), - new AccessDeniedException({ message: "Access to payment resource denied", $metadata: {} }), - ])("propagates $name unchanged without rendering output", async (error) => { - const send = mock(async () => { - throw error; - }); - const isData = resource === "session" || resource === "instrument"; - const { run, io } = createCommandTest({ - resource, - ...(isData ? { dataSend: send } : { controlSend: send }), - }); - await expect(run([resource, verb, ...requiredFlags.flat(), "--json"])).rejects.toBe(error); - expect(send).toHaveBeenCalledTimes(1); - expect(io.stdout()).toBe(""); - expect(io.stderr()).toBe(""); - }); - }); -} - -const lists = [ - { - resource: "manager", - flags: [], - command: ListPaymentManagersCommand, - request: {}, - field: "paymentManagers", - item: { paymentManagerId: EXISTING_MANAGER_ID }, - }, - { - resource: "connector", - flags: ["--manager-id", MANAGER_ID], - command: ListPaymentConnectorsCommand, - request: { paymentManagerId: MANAGER_ID }, - field: "paymentConnectors", - item: { paymentConnectorId: CONNECTOR_ID }, - }, - { - resource: "session", - flags: scoped.flat(), - command: ListPaymentSessionsCommand, - request: { paymentManagerArn: MANAGER_ARN, userId: USER_ID }, - field: "paymentSessions", - item: { paymentSessionId: SESSION_ID }, - }, - { - resource: "instrument", - flags: [...scoped.flat(), "--connector-id", INSTRUMENT_CONNECTOR_ID], - command: ListPaymentInstrumentsCommand, - request: { - paymentManagerArn: MANAGER_ARN, - userId: USER_ID, - paymentConnectorId: INSTRUMENT_CONNECTOR_ID, - }, - field: "paymentInstruments", - item: { paymentInstrumentId: INSTRUMENT_ID }, - }, -] satisfies { - resource: Resource; - flags: string[]; - command: unknown; - request: object; - field: string; - item: object; -}[]; - -describe("payment read pagination", () => { - test.each(lists)( - "$resource list preserves page size, opaque tokens, and an empty last page", - async ({ resource, flags, command: Command, request, field, item }) => { - const nextToken = "page+2/=opaque"; - const send = mock(async (command: { input: unknown }) => { - expect(command).toBeInstanceOf(Command); - return (command.input as { nextToken?: string }).nextToken - ? { [field]: [] } - : { [field]: [item], nextToken }; - }); - const isData = resource === "session" || resource === "instrument"; - const options = { resource, ...(isData ? { dataSend: send } : { controlSend: send }) }; - const first = createCommandTest(options); - await first.run([resource, "list", ...flags, "--max-results", "1", "--json"]); - expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]![0].input).toEqual({ - ...request, - maxResults: 1, - ...(isData ? {} : { nextToken: undefined }), - }); - const page = JSON.parse(first.io.stdout()); - expect(page).toEqual({ [field]: [item], nextToken }); - - const second = createCommandTest(options); - await second.run([ - resource, - "list", - ...flags, - "--max-results", - "1", - "--next-token", - page.nextToken, - "--json", - ]); - expect(send).toHaveBeenCalledTimes(2); - expect(send.mock.calls[1]![0].input).toEqual({ ...request, maxResults: 1, nextToken }); - expect(JSON.parse(second.io.stdout())).toEqual({ [field]: [] }); - }, - ); - - test.each(lists)( - "$resource list rejects a non-numeric page size before constructing SDK clients", - async ({ resource, flags }) => { - const { run, createControlClient, createDataClient } = createCommandTest({ resource }); - await expect( - run([resource, "list", ...flags, "--max-results", "not-a-number"]), - ).rejects.toThrow("Invalid value for option '--max-results'"); - expect(createControlClient).not.toHaveBeenCalled(); - expect(createDataClient).not.toHaveBeenCalled(); - }, +test.each([ + ["manager", "get", ["--id", "mypaymentmanager-o4ks3qfgtb"]], + ["manager", "list", []], + ["connector", "get", ["--manager-id", MANAGER_ID, "--connector-id", CONNECTOR_ID]], + ["connector", "list", ["--manager-id", MANAGER_ID]], + ["session", "get", [...scope, "--session-id", SESSION_ID]], + ["session", "list", scope], + ["instrument", "get", [...scope, "--instrument-id", INSTRUMENT_ID]], + ["instrument", "list", [...scope, "--connector-id", INSTRUMENT_CONNECTOR_ID]], +] as const)("%s %s renders the recorded response", async (resource, verb, flags) => { + const { run, io } = setup(resource); + await run([resource, verb, ...flags]); + matchGolden( + resource === "manager" ? FIXTURES : join(FIXTURES, resource), + `${resource}-${verb}.golden.json`, + io.stdout(), ); }); -const dataReads = [ - { - resource: "session", - verb: "get", - method: "getPaymentSession", - command: GetPaymentSessionCommand, - flags: ["--session-id", SESSION_ID], - request: { paymentSessionId: SESSION_ID }, - }, - { - resource: "session", - verb: "list", - method: "listPaymentSessions", - command: ListPaymentSessionsCommand, - flags: [], - request: {}, - }, - { - resource: "instrument", - verb: "get", - method: "getPaymentInstrument", - command: GetPaymentInstrumentCommand, - flags: ["--instrument-id", INSTRUMENT_ID, "--connector-id", INSTRUMENT_CONNECTOR_ID], - request: { paymentInstrumentId: INSTRUMENT_ID, paymentConnectorId: INSTRUMENT_CONNECTOR_ID }, - }, - { - resource: "instrument", - verb: "list", - method: "listPaymentInstruments", - command: ListPaymentInstrumentsCommand, - flags: ["--connector-id", INSTRUMENT_CONNECTOR_ID], - request: { paymentConnectorId: INSTRUMENT_CONNECTOR_ID }, - }, -] as const; - -for (const { resource, verb, method, command: Command, flags, request } of dataReads) { - describe(`payment ${resource} ${verb} scope`, () => { - test("passes managerId to Core and uses the configured region for both SDK clients", async () => { - const controlSend = mock(async (command: { input: unknown }) => { - expect(command).toBeInstanceOf(GetPaymentManagerCommand); - expect(command.input).toEqual({ paymentManagerId: MANAGER_ID }); - return { paymentManagerArn: MANAGER_ARN, authorizerType: "AWS_IAM" }; - }); - const dataSend = mock(async (command: { input: unknown }) => { - expect(command).toBeInstanceOf(Command); - expect(command.input).toEqual({ - ...request, - paymentManagerArn: MANAGER_ARN, - userId: USER_ID, - agentName: "read-agent", - }); - return {}; - }); - const { core, run, createControlClient, createDataClient } = createCommandTest({ - resource, - controlSend, - dataSend, - }); - const call = spyOn(core.payment, method); - try { - await run( - [ - resource, - verb, - ...scoped.flat(), - ...flags, - "--agent-name", - "read-agent", - "--endpoint-url", - "https://payments.example.test", - ], - "eu-west-1", - ); - expect(call).toHaveBeenCalledWith( - { ...request, managerId: MANAGER_ID, userId: USER_ID, agentName: "read-agent" }, - expect.objectContaining({ - region: "eu-west-1", - endpointUrl: "https://payments.example.test", - }), - ); - } finally { - call.mockRestore(); - } - expect(controlSend).toHaveBeenCalledTimes(1); - expect(dataSend).toHaveBeenCalledTimes(1); - for (const factory of [createControlClient, createDataClient]) { - expect(factory).toHaveBeenCalledTimes(1); - expect(factory).toHaveBeenCalledWith({ - region: "eu-west-1", - endpoint: "https://payments.example.test", - }); - } +test.each([ + ["manager", [], {}], + ["connector", ["--manager-id", MANAGER_ID], { paymentManagerId: MANAGER_ID }], + ["session", scope, { userId: "agentcore-cli-e2e" }], + [ + "instrument", + [...scope, "--connector-id", INSTRUMENT_CONNECTOR_ID], + { userId: "agentcore-cli-e2e", paymentConnectorId: INSTRUMENT_CONNECTOR_ID }, + ], +] as const)( + "%s list forwards pagination without consuming further pages", + async (resource, flags, expected) => { + const response = { nextToken: "page-3" }; + const send = mock(async (_command: { input: unknown }) => response); + const isData = resource === "session" || resource === "instrument"; + const { run, io } = setup(resource, { + [isData ? "createDataClient" : "createControlClient"]: () => ({ send }) as never, }); - - test("rejects ARN selectors and the removed --manager-arn flag before SDK calls", async () => { - const { run, createControlClient, createDataClient } = createCommandTest({ resource }); - await expect( - run([resource, verb, "--manager-id", MANAGER_ARN, "--user-id", USER_ID, ...flags]), - ).rejects.toThrow("use a payment manager ID, not an ARN"); - for (const idArgs of [[], scoped.flat()]) { - await expect( - run([resource, verb, ...idArgs, ...flags, "--manager-arn", MANAGER_ARN]), - ).rejects.toThrow("unknown option '--manager-arn'"); - } - expect(createControlClient).not.toHaveBeenCalled(); - expect(createDataClient).not.toHaveBeenCalled(); - }); - - test("preserves a parent lookup error without calling the data plane", async () => { - const error = new ResourceNotFoundException({ - message: "Payment manager not found", - $metadata: {}, - }); - const controlSend = mock(async () => { - throw error; - }); - const { run, io, createDataClient } = createCommandTest({ resource, controlSend }); - await expect(run([resource, verb, ...scoped.flat(), ...flags])).rejects.toBe(error); - expect(controlSend).toHaveBeenCalledTimes(1); - expect(createDataClient).not.toHaveBeenCalled(); - expect(io.stdout()).toBe(""); - expect(io.stderr()).toBe(""); + await run([ + resource, + "list", + ...flags, + "--next-token", + "page+2/=", + "--max-results", + "1", + "--json", + ]); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]![0].input).toEqual({ + ...expected, + ...(isData + ? { + paymentManagerArn: `arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/${MANAGER_ID}`, + } + : {}), + nextToken: "page+2/=", + maxResults: 1, }); + expect(JSON.parse(io.stdout())).toEqual(response); + }, +); - test.each([ +test("session and instrument reads forward user, agent, and optional connector scope", async () => { + const { core, run } = setup(); + const session = spyOn(core.payment, "getPaymentSession").mockResolvedValue({ + paymentSession: undefined, + }); + const instrument = spyOn(core.payment, "getPaymentInstrument").mockResolvedValue({ + paymentInstrument: undefined, + }); + try { + await run(["session", "get", ...scope, "--session-id", SESSION_ID, "--agent-name", "agent"]); + expect(session).toHaveBeenCalledWith( { - manager: { paymentManagerArn: MANAGER_ARN, authorizerType: "CUSTOM_JWT" }, - message: "uses the CUSTOM_JWT authorizer", + managerId: MANAGER_ID, + userId: "agentcore-cli-e2e", + paymentSessionId: SESSION_ID, + agentName: "agent", }, - { manager: { authorizerType: "AWS_IAM" }, message: "returned no ARN" }, - ])("rejects an unusable manager: $message", async ({ manager, message }) => { - const { run, io, createDataClient } = createCommandTest({ - resource, - controlSend: async () => manager, - }); - await expect(run([resource, verb, ...scoped.flat(), ...flags])).rejects.toThrow(message); - expect(createDataClient).not.toHaveBeenCalled(); - expect(io.stdout()).toBe(""); - }); - }); -} + expect.objectContaining({ region: "us-west-2" }), + ); + await run([ + "instrument", + "get", + ...scope, + "--instrument-id", + INSTRUMENT_ID, + "--connector-id", + INSTRUMENT_CONNECTOR_ID, + ]); + expect(instrument).toHaveBeenCalledWith( + { + managerId: MANAGER_ID, + userId: "agentcore-cli-e2e", + paymentInstrumentId: INSTRUMENT_ID, + paymentConnectorId: INSTRUMENT_CONNECTOR_ID, + }, + expect.objectContaining({ region: "us-west-2" }), + ); + } finally { + session.mockRestore(); + instrument.mockRestore(); + } +}); -describe("payment connector read-only hints", () => { - test.each([ - "AUTHENTICATION_EXPIRED", - "AUTHENTICATION_FAILED", - "PENDING_AUTHENTICATION", - "READY", - ] as const)("preserves %s without suggesting absent write commands", async (status) => { - for (const jsonFlags of [[], ["--json"]]) { - const response = { - ...parse(JSON.stringify(connectorFixture)), - status, - }; - const send = mock(async (command: { input: unknown }) => { - expect(command).toBeInstanceOf(GetPaymentConnectorCommand); - expect(command.input).toEqual({ - paymentManagerId: MANAGER_ID, - paymentConnectorId: CONNECTOR_ID, - }); - return response; - }); - const { run, io } = createCommandTest({ resource: "connector", controlSend: send }); - await run([ - "connector", - "get", - "--manager-id", - MANAGER_ID, - "--connector-id", - CONNECTOR_ID, - ...jsonFlags, - ]); - expect(JSON.parse(io.stdout())).toEqual(JSON.parse(JSON.stringify(response))); - expect(send).toHaveBeenCalledTimes(1); - const terminal = status === "AUTHENTICATION_EXPIRED" || status === "AUTHENTICATION_FAILED"; - 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); - } else { - expect(io.stderr()).toBe(""); - } - } - }); +test.each([ + [["manager", "get"], "--id"], + [["connector", "get", "--manager-id", MANAGER_ID], "--connector-id"], + [["session", "get", ...scope], "--session-id"], + [["instrument", "get", ...scope], "--instrument-id"], + [["session", "list", "--manager-id", MANAGER_ID], "--user-id"], + [ + ["instrument", "get", ...scope, "--instrument-id", INSTRUMENT_ID, "--manager-arn", "arn:old"], + "unknown option", + ], +] as const)("rejects incomplete or obsolete selectors: %j", async (args, message) => { + await expect(setup().run([...args])).rejects.toThrow(message); }); + +test.each(["AUTHENTICATION_EXPIRED", "AUTHENTICATION_FAILED"] as const)( + "connector get reports %s on stderr, except with --json", + async (status) => { + const { core, run, io } = setup("connector"); + const call = spyOn(core.payment, "getPaymentConnector").mockResolvedValue({ status } as never); + try { + const args = ["connector", "get", "--manager-id", MANAGER_ID, "--connector-id", CONNECTOR_ID]; + await run(args); + expect(io.stderr()).toContain("cannot be renewed"); + expect(JSON.parse(io.stdout())).toEqual({ status }); + const stderr = io.stderr(); + await run([...args, "--json"]); + expect(io.stderr()).toBe(stderr); + } finally { + call.mockRestore(); + } + }, +); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 78abdd9c2..62909a9ab 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -148,14 +148,7 @@ import type { ListPaymentInstrumentsResponse, ListPaymentSessionsResponse, } from "@aws-sdk/client-bedrock-agentcore"; -import type { - CorePaymentClient, - GetPaymentSessionInput, - ListPaymentSessionsInput, - GetPaymentInstrumentInput, - GetPaymentInstrumentBalanceInput, - ListPaymentInstrumentsInput, -} from "../handlers/payment/types"; +import type { CorePaymentClient } from "../handlers/payment/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; import type { CloudWatchLogEvent, @@ -283,23 +276,6 @@ const DEFAULT_DELETE_PAYMENT_RESPONSE = {} as DeletePaymentCredentialProviderRes const DEFAULT_LIST_PAYMENT_PROVIDERS_RESPONSE: ListPaymentCredentialProvidersResponse = { credentialProviders: [], }; -const DEFAULT_GET_PAYMENT_MANAGER_RESPONSE = {} as GetPaymentManagerResponse; -const DEFAULT_LIST_PAYMENT_MANAGERS_RESPONSE: ListPaymentManagersResponse = { - paymentManagers: [], -}; -const DEFAULT_GET_PAYMENT_CONNECTOR_RESPONSE = {} as GetPaymentConnectorResponse; -const DEFAULT_LIST_PAYMENT_CONNECTORS_RESPONSE: ListPaymentConnectorsResponse = { - paymentConnectors: [], -}; -const DEFAULT_GET_PAYMENT_SESSION_RESPONSE = {} as GetPaymentSessionResponse; -const DEFAULT_LIST_PAYMENT_SESSIONS_RESPONSE: ListPaymentSessionsResponse = { - paymentSessions: [], -}; -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_GET_MEMORY_RESPONSE = {} as GetMemoryOutput; const DEFAULT_LIST_MEMORIES_RESPONSE: ListMemoriesOutput = { memories: [] }; const DEFAULT_GET_EVENT_RESPONSE: GetEventOutput = { event: undefined }; @@ -1357,10 +1333,6 @@ export class TestIdentityClient implements CoreIdentityClient { private updateOauth2Response: UpdateOauth2CredentialProviderResponse = DEFAULT_UPDATE_OAUTH2_RESPONSE; private getPaymentResponse: GetPaymentCredentialProviderResponse = DEFAULT_GET_PAYMENT_RESPONSE; - private listPaymentResponses = new Map< - string | undefined, - ListPaymentCredentialProvidersResponse - >(); private error?: Error; setGetPaymentResponse(response: GetPaymentCredentialProviderResponse): this { @@ -1368,14 +1340,6 @@ export class TestIdentityClient implements CoreIdentityClient { return this; } - setListPaymentResponse( - response: ListPaymentCredentialProvidersResponse, - forNextToken?: string, - ): this { - this.listPaymentResponses.set(forNextToken, response); - return this; - } - setGetApiKeyResponse(response: GetApiKeyCredentialProviderResponse): this { this.getApiKeyResponse = response; return this; @@ -1564,169 +1528,39 @@ export class TestIdentityClient implements CoreIdentityClient { args: [nextToken, maxResults, options], }); if (this.error) throw this.error; - return this.listPaymentResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_PROVIDERS_RESPONSE; + return DEFAULT_LIST_PAYMENT_PROVIDERS_RESPONSE; } } -// TestPaymentClient is the payment sub-client of TestCoreClient. Managers and -// connectors follow the harness shape (id arguments, list responses keyed by the -// requesting nextToken); the data-plane methods take SDK requests and key their -// list responses by the request's nextToken. +// Payment command tests use real Core clients; configure a stub explicitly if a +// future screen test needs one. export class TestPaymentClient implements CorePaymentClient { - readonly calls: RecordedCall[] = []; - private getManagerResponse = DEFAULT_GET_PAYMENT_MANAGER_RESPONSE; - private listManagersResponses = new Map(); - private getConnectorResponse = DEFAULT_GET_PAYMENT_CONNECTOR_RESPONSE; - private listConnectorsResponses = new Map(); - private getSessionResponse = DEFAULT_GET_PAYMENT_SESSION_RESPONSE; - private listSessionsResponses = new Map(); - private getInstrumentResponse = DEFAULT_GET_PAYMENT_INSTRUMENT_RESPONSE; - private getInstrumentBalanceResponse = DEFAULT_GET_PAYMENT_INSTRUMENT_BALANCE_RESPONSE; - private listInstrumentsResponses = new Map(); - private error?: Error; - - setGetManagerResponse(response: GetPaymentManagerResponse): this { - this.getManagerResponse = response; - return this; - } - - setListManagersResponse(response: ListPaymentManagersResponse, forNextToken?: string): this { - this.listManagersResponses.set(forNextToken, response); - return this; - } - - setGetConnectorResponse(response: GetPaymentConnectorResponse): this { - this.getConnectorResponse = response; - return this; - } - - setListConnectorsResponse(response: ListPaymentConnectorsResponse, forNextToken?: string): this { - this.listConnectorsResponses.set(forNextToken, response); - return this; - } - - setGetSessionResponse(response: GetPaymentSessionResponse): this { - this.getSessionResponse = response; - return this; - } - - setListSessionsResponse(response: ListPaymentSessionsResponse, forNextToken?: string): this { - this.listSessionsResponses.set(forNextToken, response); - return this; - } - - setGetInstrumentResponse(response: GetPaymentInstrumentResponse): this { - this.getInstrumentResponse = response; - return this; - } - - setGetInstrumentBalanceResponse(response: GetPaymentInstrumentBalanceResponse): this { - this.getInstrumentBalanceResponse = response; - return this; - } - - setListInstrumentsResponse( - response: ListPaymentInstrumentsResponse, - forNextToken?: string, - ): this { - this.listInstrumentsResponses.set(forNextToken, response); - return this; + async getPaymentManager(): Promise { + throw new Error("Unexpected payment call"); } - - // setError makes every subsequent call reject with `error` (undefined clears). - setError(error: Error | undefined): this { - this.error = error; - return this; + async listPaymentManagers(): Promise { + throw new Error("Unexpected payment call"); } - - async getPaymentManager(id: string, options: CoreOptions): Promise { - this.calls.push({ method: "getPaymentManager", args: [id, options] }); - if (this.error) throw this.error; - return this.getManagerResponse; + async getPaymentConnector(): Promise { + throw new Error("Unexpected payment call"); } - - async listPaymentManagers( - nextToken: string | undefined, - maxResults: number | undefined, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "listPaymentManagers", args: [nextToken, maxResults, options] }); - if (this.error) throw this.error; - return this.listManagersResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_MANAGERS_RESPONSE; + async listPaymentConnectors(): Promise { + throw new Error("Unexpected payment call"); } - - async getPaymentConnector( - managerId: string, - connectorId: string, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "getPaymentConnector", args: [managerId, connectorId, options] }); - if (this.error) throw this.error; - return this.getConnectorResponse; + async getPaymentSession(): Promise { + throw new Error("Unexpected payment call"); } - - async listPaymentConnectors( - managerId: string, - nextToken: string | undefined, - maxResults: number | undefined, - options: CoreOptions, - ): Promise { - this.calls.push({ - method: "listPaymentConnectors", - args: [managerId, nextToken, maxResults, options], - }); - if (this.error) throw this.error; - return this.listConnectorsResponses.get(nextToken) ?? DEFAULT_LIST_PAYMENT_CONNECTORS_RESPONSE; + async listPaymentSessions(): Promise { + throw new Error("Unexpected payment call"); } - - async getPaymentSession( - request: GetPaymentSessionInput, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "getPaymentSession", args: [request, options] }); - if (this.error) throw this.error; - return this.getSessionResponse; + async getPaymentInstrument(): Promise { + throw new Error("Unexpected payment call"); } - - async listPaymentSessions( - request: ListPaymentSessionsInput, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "listPaymentSessions", args: [request, options] }); - if (this.error) throw this.error; - return ( - this.listSessionsResponses.get(request.nextToken) ?? DEFAULT_LIST_PAYMENT_SESSIONS_RESPONSE - ); + async getPaymentInstrumentBalance(): Promise { + throw new Error("Unexpected payment call"); } - - async getPaymentInstrument( - request: GetPaymentInstrumentInput, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "getPaymentInstrument", args: [request, options] }); - if (this.error) throw this.error; - return this.getInstrumentResponse; - } - - async getPaymentInstrumentBalance( - request: GetPaymentInstrumentBalanceInput, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "getPaymentInstrumentBalance", args: [request, options] }); - if (this.error) throw this.error; - return this.getInstrumentBalanceResponse; - } - - async listPaymentInstruments( - request: ListPaymentInstrumentsInput, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "listPaymentInstruments", args: [request, options] }); - if (this.error) throw this.error; - return ( - this.listInstrumentsResponses.get(request.nextToken) ?? - DEFAULT_LIST_PAYMENT_INSTRUMENTS_RESPONSE - ); + async listPaymentInstruments(): Promise { + throw new Error("Unexpected payment call"); } } diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 0893c4e4a..d99b7bcb7 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -15,7 +15,6 @@ export { TestHarnessClient, TestIdentityClient, TestMemoryClient, - TestPaymentClient, TestRuntimeClient, TestEvalClient, type RecordedCall,