diff --git a/README.md b/README.md index 1e32e50fe..6314094eb 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ It gives you two ways to work, from the same binary: - **A scriptable CLI** — every operation is a flag-driven subcommand that emits JSON (`--json`), so it can be used by codeing agents and can drop cleanly into scripts, CI, and automation. -- **An interactive TUI** — bare Harness, Runtime, and Memory branches and leaves open - their corresponding menus and selection flows. +- **An interactive TUI** — bare Harness, Runtime, Memory, and Identity branches + and leaves open their corresponding menus and selection flows. ```bash agentcore # launch the interactive TUI @@ -26,8 +26,8 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Commands with operation flags run headlessly. Bare Harness, Runtime, and Memory branches -and leaves open their interactive flows. +Commands with operation flags run headlessly. Bare Harness, Runtime, Memory, and +Identity branches and leaves open their interactive flows. ``` agentcore # interactive TUI @@ -49,12 +49,18 @@ agentcore # interactive TUI │ ├── update │ └── delete ├── identity # manage AgentCore Identity resources -│ └── api-key-credential-provider -│ ├── create # create an API key credential provider -│ ├── get # get an API key credential provider -│ ├── list # list API key credential providers -│ ├── update # update an API key credential provider -│ └── delete # delete an API key credential provider +│ ├── api-key-credential-provider +│ │ ├── create # create an API key credential provider +│ │ ├── get # get an API key credential provider +│ │ ├── 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 ├── runtime # inspect deployed AgentCore Runtimes │ ├── get # fetch a Runtime by id │ ├── list # list Runtimes (server-side paginated) @@ -162,6 +168,17 @@ agentcore identity api-key-credential-provider list --max-results 10 agentcore identity api-key-credential-provider update --name my-provider --api-key agentcore identity api-key-credential-provider delete --name my-provider +# Manage OAuth2 credential providers (guided Custom OAuth2, or --provider-configuration for other vendors) +agentcore identity oauth2-credential-provider create \ + --name my-oauth-provider \ + --vendor CustomOauth2 \ + --client-id \ + --discovery-url https://issuer.example.com/.well-known/openid-configuration \ + --client-secret - +agentcore identity oauth2-credential-provider get --name my-oauth-provider +agentcore identity oauth2-credential-provider list --max-results 10 +agentcore identity oauth2-credential-provider delete --name my-oauth-provider + # Manage evaluators # Create an LLM-as-a-Judge evaluator with a rating-scale preset. agentcore eval evaluator llm-as-a-judge create \ @@ -311,6 +328,18 @@ agentcore memory event agentcore memory record ``` +The Identity TUI is read-only: bare `identity` branches and the `get`/`list` +leaves open interactive menus and detail views. Mutations (`create`, `update`, +`delete`) remain available through the CLI and are omitted from the TUI menus. + +```bash +agentcore identity +agentcore identity api-key-credential-provider list +agentcore identity api-key-credential-provider get +agentcore identity oauth2-credential-provider list +agentcore identity oauth2-credential-provider get +``` + --- # Architecture & patterns diff --git a/src/components/PaginatedTablePicker.tsx b/src/components/PaginatedTablePicker.tsx index 3149c65db..8b373f158 100644 --- a/src/components/PaginatedTablePicker.tsx +++ b/src/components/PaginatedTablePicker.tsx @@ -26,6 +26,7 @@ export interface PaginatedTablePickerProps string; emptyMessage: string; emptyPageMessage: string; + maxPageSize?: number; } export function PaginatedTablePicker>({ @@ -43,8 +44,9 @@ export function PaginatedTablePicker errorMessage, emptyMessage, emptyPageMessage, + maxPageSize, }: PaginatedTablePickerProps) { - const paging = usePagedList(); + const paging = usePagedList(maxPageSize); const list = useQuery({ queryKey: [...queryKey, paging.pageSize, paging.token], queryFn: () => loadPage(paging.token, paging.pageSize), diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ad8f804c2..39eb95534 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -48,6 +48,19 @@ import { import { MemoryEventScreen } from "../handlers/memory/event/screen.tsx"; import { MemoryEventGetScreen } from "../handlers/memory/event/get/screen.tsx"; import { MemoryEventListScreen } from "../handlers/memory/event/list/screen.tsx"; +import { IdentityScreen } from "../handlers/identity/screen.tsx"; +import { ApiKeyCredentialProviderScreen } from "../handlers/identity/api-key-credential-provider/screen.tsx"; +import { ApiKeyCredentialProviderListScreen } from "../handlers/identity/api-key-credential-provider/list/screen.tsx"; +import { + ApiKeyCredentialProviderGetScreen, + ApiKeyCredentialProviderGetJsonScreen, +} from "../handlers/identity/api-key-credential-provider/get/screen.tsx"; +import { Oauth2CredentialProviderScreen } from "../handlers/identity/oauth2-credential-provider/screen.tsx"; +import { Oauth2CredentialProviderListScreen } from "../handlers/identity/oauth2-credential-provider/list/screen.tsx"; +import { + Oauth2CredentialProviderGetScreen, + Oauth2CredentialProviderGetJsonScreen, +} from "../handlers/identity/oauth2-credential-provider/get/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -374,6 +387,48 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/memory/event/list/:memoryId/:actorId/:sessionId" element={} /> + } /> + } + /> + {/* Bare `get` (no name) has nothing to show — send the user to the list. */} + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> diff --git a/src/components/usePagedList.tsx b/src/components/usePagedList.tsx index fa6ed7a25..1901b4482 100644 --- a/src/components/usePagedList.tsx +++ b/src/components/usePagedList.tsx @@ -37,9 +37,13 @@ function initialPagination(pageSize: number): PaginationState { // usePagedList holds the server-side pagination state shared by the picker // tables: a terminal-height-derived page size and the trail of nextTokens // leading to the current page, so ←/h can walk back through cached pages. -export function usePagedList(): PagedList { +// +// maxPageSize caps the terminal-derived page size for APIs that constrain +// maxResults (e.g. identity list operation capped at 20) +export function usePagedList(maxPageSize?: number): PagedList { const { rows } = useWindowSize(); - const pageSize = Math.max(3, rows - CHROME_ROWS); + const fitsTerminal = Math.max(3, rows - CHROME_ROWS); + const pageSize = maxPageSize ? Math.min(fitsTerminal, maxPageSize) : fitsTerminal; const [state, setState] = useState(() => initialPagination(pageSize)); const pageSizeChanged = state.pageSize !== pageSize; diff --git a/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx new file mode 100644 index 000000000..13676a053 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + ApiKeyCredentialProviderItem, + GetApiKeyCredentialProviderResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + tick, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +const endpointUrl = "https://identity.test"; + +function providerItem( + overrides: Partial = {}, +): ApiKeyCredentialProviderItem { + return { + name: "api-key-1", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/api-key-1", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getResponse( + overrides: Partial = {}, +): GetApiKeyCredentialProviderResponse { + return { + name: "api-key-1", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/api-key-1", + apiKeySecretArn: { + secretArn: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:bedrock-agentcore-identity!default/apikey/api-key-1", + }, + apiKeySecretSource: "MANAGED", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + } as GetApiKeyCredentialProviderResponse; +} + +function coreWithProviders(providers: ApiKeyCredentialProviderItem[]): TestCoreClient { + const core = new TestCoreClient(); + core.identity.setListApiKeyResponse({ credentialProviders: providers }); + return core; +} + +describe("API key credential provider menu", () => { + test("offers only the read-only commands", async () => { + const screen = renderScreen("/agentcore/identity/api-key-credential-provider"); + + await waitForText(screen.lastFrame, "get an API key credential provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("list"); + expect(frame).not.toContain("create"); + expect(frame).not.toContain("update"); + expect(frame).not.toContain("delete"); + }); +}); + +describe("API key credential provider picker", () => { + test("renders provider name, created, and updated times", async () => { + const core = coreWithProviders([ + providerItem({ + name: "visible-provider", + createdTime: new Date("2026-07-18T00:00:00.000Z"), + lastUpdatedTime: new Date("2026-07-21T02:03:04.000Z"), + }), + ]); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, "visible-provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("created UTC"); + expect(frame).toContain("updated UTC"); + expect(frame).toContain("2026-07-21 02:03"); + }); + + test("calls listApiKeyCredentialProviders with exact Core options", async () => { + const core = coreWithProviders([providerItem()]); + renderScreen("/agentcore/identity/api-key-credential-provider/list", { core, endpointUrl }); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listApiKeyCredentialProviders"), + ); + expect( + core.identity.calls.filter((call) => call.method === "listApiKeyCredentialProviders"), + ).toEqual([ + { + method: "listApiKeyCredentialProviders", + args: [undefined, expect.any(Number), { region: "us-east-1", endpointUrl }], + }, + ]); + }); + + test("caps maxResults at the service limit of 20 on a tall terminal", async () => { + const core = coreWithProviders([providerItem()]); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + // Terminal taller than the 20-row service cap: page size must still clamp. + await screen.resize(120, 60); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listApiKeyCredentialProviders"), + ); + for (const call of core.identity.calls) { + if (call.method !== "listApiKeyCredentialProviders") continue; + expect(call.args[1] as number).toBeLessThanOrEqual(20); + } + }); + + test("shows first-page and later-page empty states", async () => { + const empty = renderScreen("/agentcore/identity/api-key-credential-provider/list"); + await waitForText(empty.lastFrame, "No API key credential providers found in this Region."); + empty.unmount(); + + const core = new TestCoreClient(); + core.identity.setListApiKeyResponse({ + credentialProviders: [providerItem({ name: "page-one" })], + nextToken: "page-2", + }); + core.identity.setListApiKeyResponse({ credentialProviders: [] }, "page-2"); + const paged = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + + await waitForText(paged.lastFrame, "page 1 · more →"); + await paged.write("l"); + await waitForText(paged.lastFrame, "No API key credential providers on this page."); + expect(paged.lastFrame()).not.toContain( + "No API key credential providers found in this Region.", + ); + }); + + test("bare get redirects to the picker", async () => { + const core = coreWithProviders([providerItem({ name: "redirected" })]); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get", { core }); + + await waitForText(screen.lastFrame, "redirected"); + expect(core.identity.calls[0]?.method).toBe("listApiKeyCredentialProviders"); + }); + + test("selection opens the matching provider detail", async () => { + const name = "api key blue"; + const core = coreWithProviders([providerItem({ name })]); + core.identity.setGetApiKeyResponse(getResponse({ name })); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, name); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → identity → api-key-credential-provider → get → ${name}`, + ); + await waitFor(() => + core.identity.calls.some( + (call) => call.method === "getApiKeyCredentialProvider" && call.args[0] === name, + ), + ); + }); +}); + +describe("API key credential provider detail", () => { + test("renders a resource summary with only the detail action", async () => { + const core = new TestCoreClient(); + core.identity.setGetApiKeyResponse(getResponse()); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + endpointUrl, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + const frame = screen.lastFrame()!; + expect(frame).toContain("api-key-1"); + expect(frame).toMatch(/secretSource\s+MANAGED/); + expect(frame).toContain("arn:aws:bedrock-agentcore"); + // The only action is "detail"; mutations are CLI-only, never surfaced here. + expect(frame).toContain("❯ detail"); + expect( + core.identity.calls.find((call) => call.method === "getApiKeyCredentialProvider"), + ).toEqual({ + method: "getApiKeyCredentialProvider", + args: ["api-key-1", { region: "us-east-1", endpointUrl }], + }); + }); + + test("opens the complete provider JSON", async () => { + const core = new TestCoreClient(); + core.identity.setGetApiKeyResponse(getResponse()); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("return"); + await waitForText( + screen.lastFrame, + "agentcore → identity → api-key-credential-provider → get → api-key-1 → json", + ); + expect(screen.lastFrame()).toContain('"credentialProviderArn"'); + }); + + test("retries a failed detail query", async () => { + const core = new TestCoreClient(); + core.identity.setError(new Error("provider unavailable")); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + }); + + await waitForText(screen.lastFrame, "provider unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + + core.identity.setError(undefined); + core.identity.setGetApiKeyResponse(getResponse()); + await screen.write("r"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + }); + + test("does not open cached detail after a background refresh fails", async () => { + const core = new TestCoreClient(); + core.identity.setGetApiKeyResponse(getResponse()); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }, + }); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + queryClient, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + core.identity.setError(new Error("background refresh failed")); + await queryClient.invalidateQueries({ + queryKey: ["api-key-credential-provider", "us-east-1", "api-key-1"], + }); + await waitForText(screen.lastFrame, "background refresh failed"); + + await screen.press("return"); + await tick(); + expect(screen.lastFrame()).toContain( + "agentcore → identity → api-key-credential-provider → get → api-key-1", + ); + expect(screen.lastFrame()).not.toContain("→ json"); + }); +}); diff --git a/src/handlers/identity/api-key-credential-provider/get/screen.tsx b/src/handlers/identity/api-key-credential-provider/get/screen.tsx new file mode 100644 index 000000000..277aff9fd --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/get/screen.tsx @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +function useApiKeyProviderDetail({ ctx, core }: ScreenProps, name: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["api-key-credential-provider", opts.region, name], + queryFn: () => core.identity.getApiKeyCredentialProvider(name!, opts), + enabled: name !== undefined, + }); +} + +export function ApiKeyCredentialProviderGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { name } = useParams(); + const detail = useApiKeyProviderDetail(props, name); + + return ( + + navigate( + `/agentcore/identity/api-key-credential-provider/get/${encodeURIComponent(name)}/json`, + ), + }, + ] + : [] + } + loadingLabel="Loading API key credential provider…" + onRetry={() => void detail.refetch()} + selectLabel="open detail" + /> + ); +} + +export function ApiKeyCredentialProviderGetJsonScreen(props: ScreenProps) { + const { name } = useParams(); + const detail = useApiKeyProviderDetail(props, name); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/identity/api-key-credential-provider/index.tsx b/src/handlers/identity/api-key-credential-provider/index.tsx index 9d975d25e..f82c8a649 100644 --- a/src/handlers/identity/api-key-credential-provider/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/index.tsx @@ -1,7 +1,7 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createCreateApiKeyCredentialProviderHandler } from "./create"; import { createDeleteApiKeyCredentialProviderHandler } from "./delete"; import { createGetApiKeyCredentialProviderHandler } from "./get"; @@ -10,7 +10,7 @@ import { createUpdateApiKeyCredentialProviderHandler } from "./update"; export function createApiKeyCredentialProviderHandler(core: Core, io: AppIO): Router { return new Router("api-key-credential-provider", "manage API key credential providers") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createCreateApiKeyCredentialProviderHandler(core, io)) .handler(createGetApiKeyCredentialProviderHandler(core)) .handler(createListApiKeyCredentialProvidersHandler(core)) diff --git a/src/handlers/identity/api-key-credential-provider/list/screen.tsx b/src/handlers/identity/api-key-credential-provider/list/screen.tsx new file mode 100644 index 000000000..d3db7ba41 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/list/screen.tsx @@ -0,0 +1,61 @@ +import type { ApiKeyCredentialProviderItem } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +// identity list APIs cap maxResults at 20 +const MAX_PAGE_SIZE = 20; + +interface ApiKeyProviderRow extends Record { + name: string; + createdAt: string; + updatedAt: string; +} + +export const apiKeyProviderColumns = [ + { key: "name", header: "name", flex: true }, + { key: "createdAt", header: "created UTC", width: 16, render: formatTimestamp }, + { key: "updatedAt", header: "updated UTC", width: 16, render: formatTimestamp }, +] satisfies DataTableColumn[]; + +function toRow(provider: ApiKeyCredentialProviderItem): ApiKeyProviderRow { + return { + name: provider.name ?? "", + createdAt: provider.createdTime?.toISOString() ?? "-", + updatedAt: provider.lastUpdatedTime?.toISOString() ?? "-", + }; +} + +export function ApiKeyCredentialProviderListScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.identity.listApiKeyCredentialProviders(token, pageSize, opts); + return { + items: response.credentialProviders ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={apiKeyProviderColumns} + getValue={(row) => row.name} + onSelect={(name) => + navigate(`/agentcore/identity/api-key-credential-provider/get/${encodeURIComponent(name)}`) + } + onBack={() => navigate("/agentcore/identity/api-key-credential-provider")} + maxPageSize={MAX_PAGE_SIZE} + loadingMessage="Loading API key credential providers…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No API key credential providers found in this Region." + emptyPageMessage="No API key credential providers on this page." + /> + ); +} diff --git a/src/handlers/identity/api-key-credential-provider/screen.tsx b/src/handlers/identity/api-key-credential-provider/screen.tsx new file mode 100644 index 000000000..806a39107 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/screen.tsx @@ -0,0 +1,14 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +const OMIT = ["create", "update", "delete"]; + +export function ApiKeyCredentialProviderScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx index eb81613f0..5349ef5cf 100644 --- a/src/handlers/identity/identity.test.tsx +++ b/src/handlers/identity/identity.test.tsx @@ -65,9 +65,9 @@ describe("identity command hierarchy", () => { }); test.each(["identity", "identity api-key-credential-provider"])( - "prints help for bare `%s` without an SDK call", + "prints help for `%s --json` without an SDK call", async (command) => { - const stdout = await run(command.split(" ")); + const stdout = await run([...command.split(" "), "--json"]); expect(stdout).toContain(`Usage: agentcore ${command}`); expect(stdout).toContain("Commands:"); @@ -75,6 +75,22 @@ describe("identity command hierarchy", () => { ); }); +describe("api-key-credential-provider TUI dispatch", () => { + test.each([ + ["identity", ["identity"]], + ["api-key-credential-provider", ["identity", "api-key-credential-provider"]], + ["create", ["identity", "api-key-credential-provider", "create"]], + ["get", ["identity", "api-key-credential-provider", "get"]], + ["list", ["identity", "api-key-credential-provider", "list"]], + ["update", ["identity", "api-key-credential-provider", "update"]], + ["delete", ["identity", "api-key-credential-provider", "delete"]], + ] as const)("opens the TUI for a bare `%s`", async (_label, args) => { + await expect(run([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }); +}); + describe("api-key-credential-provider CRUDL", () => { test("creates an API key credential provider", async () => { const stdout = await run([ @@ -118,7 +134,7 @@ describe("api-key-credential-provider CRUDL", () => { }); test("lists API key credential providers", async () => { - const stdout = await run(["identity", "api-key-credential-provider", "list"]); + const stdout = await run(["identity", "api-key-credential-provider", "list", "--json"]); matchGolden(FIXTURES, "list.golden.json", stdout); expect(JSON.parse(stdout).credentialProviders).toBeArray(); @@ -201,15 +217,21 @@ describe("api-key-credential-provider CRUDL", () => { ["identity", "api-key-credential-provider", "create", "--api-key", "x"], /--name/, ], - ["create bare", ["identity", "api-key-credential-provider", "create"], /--name/], - ["get bare", ["identity", "api-key-credential-provider", "get"], /--name/], - ["update bare", ["identity", "api-key-credential-provider", "update"], /--name/], + [ + "get --json (no name)", + ["identity", "api-key-credential-provider", "get", "--json"], + /--name/, + ], [ "update --name only", ["identity", "api-key-credential-provider", "update", "--name", "x"], /--api-key.*--api-key-secret-reference/, ], - ["delete bare", ["identity", "api-key-credential-provider", "delete"], /--name/], + [ + "delete --json (no name)", + ["identity", "api-key-credential-provider", "delete", "--json"], + /--name/, + ], ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { expect(run([...args])).rejects.toThrow(message); }); diff --git a/src/handlers/identity/index.tsx b/src/handlers/identity/index.tsx index eed7d0ecd..64011df40 100644 --- a/src/handlers/identity/index.tsx +++ b/src/handlers/identity/index.tsx @@ -1,13 +1,15 @@ +import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; +import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import type { Core } from "../types"; import { createApiKeyCredentialProviderHandler } from "./api-key-credential-provider"; import { createOauth2CredentialProviderHandler } from "./oauth2-credential-provider"; -import { createHelpDefault } from "../help"; export function createIdentityHandler(core: Core, io: AppIO): Router { return new Router("identity", "manage AgentCore Identity resources") - .default(createHelpDefault(io)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) .handler(createApiKeyCredentialProviderHandler(core, io)) .handler(createOauth2CredentialProviderHandler(core, io)); } diff --git a/src/handlers/identity/oauth2-credential-provider/get/screen.tsx b/src/handlers/identity/oauth2-credential-provider/get/screen.tsx new file mode 100644 index 000000000..badeae83b --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/get/screen.tsx @@ -0,0 +1,81 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +function useOauth2ProviderDetail({ ctx, core }: ScreenProps, name: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["oauth2-credential-provider", opts.region, name], + queryFn: () => core.identity.getOauth2CredentialProvider(name!, opts), + enabled: name !== undefined, + }); +} + +export function Oauth2CredentialProviderGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { name } = useParams(); + const detail = useOauth2ProviderDetail(props, name); + + return ( + + navigate( + `/agentcore/identity/oauth2-credential-provider/get/${encodeURIComponent(name)}/json`, + ), + }, + ] + : [] + } + loadingLabel="Loading OAuth2 credential provider…" + onRetry={() => void detail.refetch()} + selectLabel="open detail" + /> + ); +} + +export function Oauth2CredentialProviderGetJsonScreen(props: ScreenProps) { + const { name } = useParams(); + const detail = useOauth2ProviderDetail(props, name); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/identity/oauth2-credential-provider/index.tsx b/src/handlers/identity/oauth2-credential-provider/index.tsx index a312e2a63..3372cd7b1 100644 --- a/src/handlers/identity/oauth2-credential-provider/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/index.tsx @@ -1,7 +1,7 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createCreateOauth2CredentialProviderHandler } from "./create"; import { createDeleteOauth2CredentialProviderHandler } from "./delete"; import { createGetOauth2CredentialProviderHandler } from "./get"; @@ -10,7 +10,7 @@ import { createUpdateOauth2CredentialProviderHandler } from "./update"; export function createOauth2CredentialProviderHandler(core: Core, io: AppIO): Router { return new Router("oauth2-credential-provider", "manage OAuth2 credential providers") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createCreateOauth2CredentialProviderHandler(core, io)) .handler(createGetOauth2CredentialProviderHandler(core)) .handler(createListOauth2CredentialProvidersHandler(core)) diff --git a/src/handlers/identity/oauth2-credential-provider/list/screen.tsx b/src/handlers/identity/oauth2-credential-provider/list/screen.tsx new file mode 100644 index 000000000..b99dd53f7 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/list/screen.tsx @@ -0,0 +1,64 @@ +import type { Oauth2CredentialProviderItem } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +// The Identity list APIs cap maxResults at 20 +const MAX_PAGE_SIZE = 20; + +interface Oauth2ProviderRow extends Record { + name: string; + vendor: string; + createdAt: string; + updatedAt: string; +} + +export const oauth2ProviderColumns = [ + { key: "name", header: "name", flex: true }, + { key: "vendor", header: "vendor", width: 18 }, + { key: "createdAt", header: "created UTC", width: 16, render: formatTimestamp }, + { key: "updatedAt", header: "updated UTC", width: 16, render: formatTimestamp }, +] satisfies DataTableColumn[]; + +function toRow(provider: Oauth2CredentialProviderItem): Oauth2ProviderRow { + return { + name: provider.name ?? "", + vendor: provider.credentialProviderVendor ?? "-", + createdAt: provider.createdTime?.toISOString() ?? "-", + updatedAt: provider.lastUpdatedTime?.toISOString() ?? "-", + }; +} + +export function Oauth2CredentialProviderListScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.identity.listOauth2CredentialProviders(token, pageSize, opts); + return { + items: response.credentialProviders ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={oauth2ProviderColumns} + getValue={(row) => row.name} + onSelect={(name) => + navigate(`/agentcore/identity/oauth2-credential-provider/get/${encodeURIComponent(name)}`) + } + onBack={() => navigate("/agentcore/identity/oauth2-credential-provider")} + maxPageSize={MAX_PAGE_SIZE} + loadingMessage="Loading OAuth2 credential providers…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No OAuth2 credential providers found in this Region." + emptyPageMessage="No OAuth2 credential providers on this page." + /> + ); +} diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx index 0dec53411..02fe61667 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx @@ -97,7 +97,7 @@ describe("oauth2-credential-provider CRUDL", () => { }); test("lists OAuth2 credential providers", async () => { - const stdout = await run(["identity", "oauth2-credential-provider", "list"]); + const stdout = await run(["identity", "oauth2-credential-provider", "list", "--json"]); matchGolden(FIXTURES, "list.golden.json", stdout); expect(JSON.parse(stdout).credentialProviders).toBeArray(); diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx new file mode 100644 index 000000000..2bf9d4b05 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx @@ -0,0 +1,290 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + GetOauth2CredentialProviderResponse, + Oauth2CredentialProviderItem, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + tick, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +const endpointUrl = "https://identity.test"; + +function providerItem( + overrides: Partial = {}, +): Oauth2CredentialProviderItem { + return { + name: "oauth2-1", + credentialProviderVendor: "CustomOauth2", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/oauth2-1", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getResponse( + overrides: Partial = {}, +): GetOauth2CredentialProviderResponse { + return { + name: "oauth2-1", + credentialProviderVendor: "CustomOauth2", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/oauth2-1", + clientSecretArn: { + secretArn: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:bedrock-agentcore-identity!default/oauth2/oauth2-1", + }, + clientSecretSource: "MANAGED", + callbackUrl: "https://bedrock-agentcore.us-east-1.amazonaws.com/identities/oauth2/callback/abc", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + } as GetOauth2CredentialProviderResponse; +} + +function coreWithProviders(providers: Oauth2CredentialProviderItem[]): TestCoreClient { + const core = new TestCoreClient(); + core.identity.setListOauth2Response({ credentialProviders: providers }); + return core; +} + +describe("OAuth2 credential provider menu", () => { + test("offers only the read-only commands", async () => { + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider"); + + await waitForText(screen.lastFrame, "get an OAuth2 credential provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("list"); + expect(frame).not.toContain("create"); + expect(frame).not.toContain("update"); + expect(frame).not.toContain("delete"); + }); +}); + +describe("OAuth2 credential provider picker", () => { + test("renders provider name, vendor, created, and updated times", async () => { + const core = coreWithProviders([ + providerItem({ + name: "visible-provider", + credentialProviderVendor: "GithubOauth2", + lastUpdatedTime: new Date("2026-07-21T02:03:04.000Z"), + }), + ]); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, "visible-provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("vendor"); + expect(frame).toContain("GithubOauth2"); + expect(frame).toContain("2026-07-21 02:03"); + }); + + test("calls listOauth2CredentialProviders with exact Core options", async () => { + const core = coreWithProviders([providerItem()]); + renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core, endpointUrl }); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listOauth2CredentialProviders"), + ); + expect( + core.identity.calls.filter((call) => call.method === "listOauth2CredentialProviders"), + ).toEqual([ + { + method: "listOauth2CredentialProviders", + args: [undefined, expect.any(Number), { region: "us-east-1", endpointUrl }], + }, + ]); + }); + + test("caps maxResults at the service limit of 20 on a tall terminal", async () => { + const core = coreWithProviders([providerItem()]); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + // Terminal taller than the 20-row service cap: page size must still clamp. + await screen.resize(120, 60); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listOauth2CredentialProviders"), + ); + for (const call of core.identity.calls) { + if (call.method !== "listOauth2CredentialProviders") continue; + expect(call.args[1] as number).toBeLessThanOrEqual(20); + } + }); + + test("shows first-page and later-page empty states", async () => { + const empty = renderScreen("/agentcore/identity/oauth2-credential-provider/list"); + await waitForText(empty.lastFrame, "No OAuth2 credential providers found in this Region."); + empty.unmount(); + + const core = new TestCoreClient(); + core.identity.setListOauth2Response({ + credentialProviders: [providerItem({ name: "page-one" })], + nextToken: "page-2", + }); + core.identity.setListOauth2Response({ credentialProviders: [] }, "page-2"); + const paged = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + + await waitForText(paged.lastFrame, "page 1 · more →"); + await paged.write("l"); + await waitForText(paged.lastFrame, "No OAuth2 credential providers on this page."); + expect(paged.lastFrame()).not.toContain("No OAuth2 credential providers found in this Region."); + }); + + test("bare get redirects to the picker", async () => { + const core = coreWithProviders([providerItem({ name: "redirected" })]); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get", { core }); + + await waitForText(screen.lastFrame, "redirected"); + expect(core.identity.calls[0]?.method).toBe("listOauth2CredentialProviders"); + }); + + test("selection opens the matching provider detail", async () => { + const name = "oauth2 blue"; + const core = coreWithProviders([providerItem({ name })]); + core.identity.setGetOauth2Response(getResponse({ name })); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, name); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → identity → oauth2-credential-provider → get → ${name}`, + ); + await waitFor(() => + core.identity.calls.some( + (call) => call.method === "getOauth2CredentialProvider" && call.args[0] === name, + ), + ); + }); +}); + +describe("OAuth2 credential provider detail", () => { + test("renders a resource summary with only the detail action", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(getResponse()); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + endpointUrl, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + const frame = screen.lastFrame()!; + expect(frame).toContain("oauth2-1"); + expect(frame).toMatch(/vendor\s+CustomOauth2/); + expect(frame).toMatch(/secretSource\s+MANAGED/); + expect(frame).toContain("arn:aws:bedrock-agentcore"); + // The only action is "detail"; mutations are CLI-only, never surfaced here. + expect(frame).toContain("❯ detail"); + expect( + core.identity.calls.find((call) => call.method === "getOauth2CredentialProvider"), + ).toEqual({ + method: "getOauth2CredentialProvider", + args: ["oauth2-1", { region: "us-east-1", endpointUrl }], + }); + }); + + test("shows a callback URL only when the service provides one", async () => { + const withCallback = new TestCoreClient(); + withCallback.identity.setGetOauth2Response(getResponse()); + const shown = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core: withCallback, + }); + await waitForText(shown.lastFrame, "show the full JSON definition"); + expect(shown.lastFrame()).toContain("callbackUrl"); + shown.unmount(); + + const noCallback = new TestCoreClient(); + noCallback.identity.setGetOauth2Response(getResponse({ callbackUrl: undefined })); + const hidden = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core: noCallback, + }); + await waitForText(hidden.lastFrame, "show the full JSON definition"); + expect(hidden.lastFrame()).not.toContain("callbackUrl"); + }); + + test("shows status and failure reason for a failed provider", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response( + getResponse({ + status: "CREATE_FAILED", + failureReason: "authorization server metadata could not be loaded", + }), + ); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + }); + + await waitForText(screen.lastFrame, "authorization server metadata could not be loaded"); + const frame = screen.lastFrame()!; + expect(frame).toMatch(/status\s+CREATE_FAILED/); + expect(frame).toContain("failureReason"); + }); + + test("opens the complete provider JSON", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(getResponse()); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("return"); + await waitForText( + screen.lastFrame, + "agentcore → identity → oauth2-credential-provider → get → oauth2-1 → json", + ); + expect(screen.lastFrame()).toContain('"credentialProviderVendor"'); + }); + + test("retries a failed detail query", async () => { + const core = new TestCoreClient(); + core.identity.setError(new Error("provider unavailable")); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + }); + + await waitForText(screen.lastFrame, "provider unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + + core.identity.setError(undefined); + core.identity.setGetOauth2Response(getResponse()); + await screen.write("r"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + }); + + test("does not open cached detail after a background refresh fails", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(getResponse()); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }, + }); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + queryClient, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + core.identity.setError(new Error("background refresh failed")); + await queryClient.invalidateQueries({ + queryKey: ["oauth2-credential-provider", "us-east-1", "oauth2-1"], + }); + await waitForText(screen.lastFrame, "background refresh failed"); + + await screen.press("return"); + await tick(); + expect(screen.lastFrame()).toContain( + "agentcore → identity → oauth2-credential-provider → get → oauth2-1", + ); + expect(screen.lastFrame()).not.toContain("→ json"); + }); +}); diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx index a6b094674..90f8984eb 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx @@ -100,8 +100,8 @@ describe("oauth2-credential-provider command hierarchy", () => { ]); }); - test("prints help for bare `identity oauth2-credential-provider` without a Core call", async () => { - const { core, stdout } = await run(["identity", "oauth2-credential-provider"]); + test("prints help for `identity oauth2-credential-provider --json` without an SDK call", async () => { + const { core, stdout } = await run(["identity", "oauth2-credential-provider", "--json"]); expect(stdout).toContain("Usage: agentcore identity oauth2-credential-provider"); expect(stdout).toContain("Commands:"); @@ -109,6 +109,21 @@ describe("oauth2-credential-provider command hierarchy", () => { }); }); +describe("oauth2-credential-provider TUI dispatch", () => { + test.each([ + ["oauth2-credential-provider", ["identity", "oauth2-credential-provider"]], + ["create", ["identity", "oauth2-credential-provider", "create"]], + ["get", ["identity", "oauth2-credential-provider", "get"]], + ["list", ["identity", "oauth2-credential-provider", "list"]], + ["update", ["identity", "oauth2-credential-provider", "update"]], + ["delete", ["identity", "oauth2-credential-provider", "delete"]], + ] as const)("opens the TUI for a bare `%s`", async (_label, args) => { + await expect(run([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }); +}); + describe("oauth2-credential-provider flag validation", () => { test.each([ [ @@ -129,10 +144,12 @@ describe("oauth2-credential-provider flag validation", () => { ], /--client-secret.*--client-secret-reference/, ], - ["create bare", ["identity", "oauth2-credential-provider", "create"], /--name/], - ["get bare", ["identity", "oauth2-credential-provider", "get"], /--name/], - ["update bare", ["identity", "oauth2-credential-provider", "update"], /--name/], - ["delete bare", ["identity", "oauth2-credential-provider", "delete"], /--name/], + ["get --json (no name)", ["identity", "oauth2-credential-provider", "get", "--json"], /--name/], + [ + "delete --json (no name)", + ["identity", "oauth2-credential-provider", "delete", "--json"], + /--name/, + ], ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { expect(run([...args])).rejects.toThrow(message); }); diff --git a/src/handlers/identity/oauth2-credential-provider/screen.tsx b/src/handlers/identity/oauth2-credential-provider/screen.tsx new file mode 100644 index 000000000..978532c4c --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/screen.tsx @@ -0,0 +1,14 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +const OMIT = ["create", "update", "delete"]; + +export function Oauth2CredentialProviderScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/screen.tsx b/src/handlers/identity/screen.tsx new file mode 100644 index 000000000..a5e36b67c --- /dev/null +++ b/src/handlers/identity/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../components/RouterScreen"; +import type { ScreenProps } from "../types"; + +export function IdentityScreen(props: ScreenProps) { + return ; +} diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 5328fd5d3..5453a9aa6 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -920,12 +920,35 @@ type TestCoreClientOptions = { logger?: Logger; }; -class TestIdentityClient implements CoreIdentityClient { +export class TestIdentityClient implements CoreIdentityClient { readonly calls: RecordedCall[] = []; + private getApiKeyResponse: GetApiKeyCredentialProviderResponse = DEFAULT_GET_API_KEY_RESPONSE; + private listApiKeyResponses = new Map< + string | undefined, + ListApiKeyCredentialProvidersResponse + >(); private getOauth2Response: GetOauth2CredentialProviderResponse = DEFAULT_GET_OAUTH2_RESPONSE; + private listOauth2Responses = new Map< + string | undefined, + ListOauth2CredentialProvidersResponse + >(); private updateOauth2Response: UpdateOauth2CredentialProviderResponse = DEFAULT_UPDATE_OAUTH2_RESPONSE; + private error?: Error; + + setGetApiKeyResponse(response: GetApiKeyCredentialProviderResponse): this { + this.getApiKeyResponse = response; + return this; + } + + setListApiKeyResponse( + response: ListApiKeyCredentialProvidersResponse, + forNextToken?: string, + ): this { + this.listApiKeyResponses.set(forNextToken, response); + return this; + } setGetOauth2Response(response: GetOauth2CredentialProviderResponse): this { this.getOauth2Response = response; @@ -937,11 +960,25 @@ class TestIdentityClient implements CoreIdentityClient { return this; } + setListOauth2Response( + response: ListOauth2CredentialProvidersResponse, + forNextToken?: string, + ): this { + this.listOauth2Responses.set(forNextToken, response); + return this; + } + + setError(error: Error | undefined): this { + this.error = error; + return this; + } + async createApiKeyCredentialProvider( input: CreateApiKeyCredentialProviderInput, options: CoreOptions, ): Promise { this.calls.push({ method: "createApiKeyCredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return DEFAULT_CREATE_API_KEY_RESPONSE; } @@ -950,7 +987,8 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "getApiKeyCredentialProvider", args: [name, options] }); - return DEFAULT_GET_API_KEY_RESPONSE; + if (this.error) throw this.error; + return this.getApiKeyResponse; } async listApiKeyCredentialProviders( @@ -962,7 +1000,12 @@ class TestIdentityClient implements CoreIdentityClient { method: "listApiKeyCredentialProviders", args: [nextToken, maxResults, options], }); - return DEFAULT_LIST_API_KEYS_RESPONSE; + if (this.error) throw this.error; + return ( + this.listApiKeyResponses.get(nextToken) ?? + this.listApiKeyResponses.get(undefined) ?? + DEFAULT_LIST_API_KEYS_RESPONSE + ); } async updateApiKeyCredentialProvider( @@ -970,6 +1013,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "updateApiKeyCredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return DEFAULT_UPDATE_API_KEY_RESPONSE; } @@ -978,6 +1022,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "deleteApiKeyCredentialProvider", args: [name, options] }); + if (this.error) throw this.error; return DEFAULT_DELETE_API_KEY_RESPONSE; } @@ -986,6 +1031,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "createOauth2CredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return DEFAULT_CREATE_OAUTH2_RESPONSE; } @@ -994,6 +1040,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "getOauth2CredentialProvider", args: [name, options] }); + if (this.error) throw this.error; return this.getOauth2Response; } @@ -1006,7 +1053,12 @@ class TestIdentityClient implements CoreIdentityClient { method: "listOauth2CredentialProviders", args: [nextToken, maxResults, options], }); - return DEFAULT_LIST_OAUTH2_RESPONSE; + if (this.error) throw this.error; + return ( + this.listOauth2Responses.get(nextToken) ?? + this.listOauth2Responses.get(undefined) ?? + DEFAULT_LIST_OAUTH2_RESPONSE + ); } async updateOauth2CredentialProvider( @@ -1014,6 +1066,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "updateOauth2CredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return this.updateOauth2Response; } @@ -1022,6 +1075,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "deleteOauth2CredentialProvider", args: [name, options] }); + if (this.error) throw this.error; return DEFAULT_DELETE_OAUTH2_RESPONSE; } }