From 8e05d2659c23d1a632f028d0203e0ae7c3baa01a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 20:40:44 -0500 Subject: [PATCH] test: add unit tests for Batches 1-3 gateway functionality Comprehensive test coverage for MCP Gateway Phase 1 Batches 1-3: - Schema validation: gateway targets, outbound auth, credentials, deployed state - OAuth credential provider: CRUD operations, conflict handling, error paths - Pre-deploy identity: OAuth setup, credential collection, env var mapping - CLI validation: existing-endpoint path, credential validation - Deploy outputs: buildDeployedState with credentials, parseGatewayOutputs - External target creation: assignment, unassigned, duplicates, outboundAuth - Gateway target removal: listing, preview, removal operations - Preflight: gateway-only deploy validation - Credential references: cross-gateway warning on removal - Add command actions: buildGatewayTargetConfig mapping - UI: AddScreen/RemoveScreen enablement, ResourceGraph unassigned targets - Types: constants validation (AUTHORIZER_TYPE_OPTIONS, SKIP_FOR_NOW, SOURCE_OPTIONS) Adds 86 new test cases across 17 files. --- .../cloudformation/__tests__/outputs.test.ts | 114 ++++++++- .../commands/add/__tests__/actions.test.ts | 68 +++++ .../commands/add/__tests__/validate.test.ts | 113 ++++++++- src/cli/commands/add/actions.ts | 2 +- .../__tests__/pre-deploy-identity.test.ts | 238 ++++++++++++++++-- .../deploy/__tests__/preflight.test.ts | 109 +++++++- .../oauth2-credential-provider.test.ts | 236 +++++++++++++++++ .../mcp/__tests__/create-mcp.test.ts | 141 +++++++++-- .../__tests__/remove-gateway-target.test.ts | 237 +++++++++++++++++ .../remove/__tests__/remove-identity.test.ts | 121 +++++++++ .../__tests__/ResourceGraph.test.tsx | 38 +++ .../screens/add/__tests__/AddScreen.test.tsx | 17 ++ .../tui/screens/mcp/__tests__/types.test.ts | 20 ++ .../remove/__tests__/RemoveScreen.test.tsx | 48 ++++ .../__tests__/agentcore-project.test.ts | 54 ++++ .../schemas/__tests__/deployed-state.test.ts | 54 ++++ src/schema/schemas/__tests__/mcp.test.ts | 136 ++++++++++ 17 files changed, 1701 insertions(+), 45 deletions(-) create mode 100644 src/cli/commands/add/__tests__/actions.test.ts create mode 100644 src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-gateway-target.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-identity.test.ts create mode 100644 src/cli/tui/screens/add/__tests__/AddScreen.test.tsx create mode 100644 src/cli/tui/screens/mcp/__tests__/types.test.ts create mode 100644 src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index ce85fdcf..6d1091f3 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -1,4 +1,4 @@ -import { buildDeployedState } from '../outputs'; +import { buildDeployedState, parseGatewayOutputs } from '../outputs'; import { describe, expect, it } from 'vitest'; describe('buildDeployedState', () => { @@ -61,4 +61,116 @@ describe('buildDeployedState', () => { expect(result.targets.prod!.resources?.stackName).toBe('ProdStack'); expect(result.targets.dev!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/dev-key'); }); + + it('includes credentials in deployed state when provided', () => { + const agents = { + TestAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }, + }; + + const credentials = { + 'test-cred': { + credentialProviderArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-cred', + }, + }; + + const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, credentials); + + expect(result.targets.default!.resources?.credentials).toEqual(credentials); + }); + + it('omits credentials field when credentials is undefined', () => { + const agents = { + TestAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }, + }; + + const result = buildDeployedState('default', 'TestStack', agents, {}); + + expect(result.targets.default!.resources?.credentials).toBeUndefined(); + }); + + it('omits credentials field when credentials is empty object', () => { + const agents = { + TestAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }, + }; + + const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, {}); + + expect(result.targets.default!.resources?.credentials).toBeUndefined(); + }); +}); + +describe('parseGatewayOutputs', () => { + it('extracts gateway URL from outputs matching pattern', () => { + const outputs = { + GatewayMyGatewayUrlOutput3E11FAB4: 'https://api.gateway.url', + GatewayAnotherGatewayUrlOutputABC123: 'https://another.gateway.url', + UnrelatedOutput: 'some-value', + }; + + const gatewaySpecs = { + 'my-gateway': {}, + 'another-gateway': {}, + }; + + const result = parseGatewayOutputs(outputs, gatewaySpecs); + + expect(result).toEqual({ + 'my-gateway': { + gatewayId: 'my-gateway', + gatewayArn: 'https://api.gateway.url', + }, + 'another-gateway': { + gatewayId: 'another-gateway', + gatewayArn: 'https://another.gateway.url', + }, + }); + }); + + it('handles missing gateway outputs gracefully', () => { + const outputs = { + UnrelatedOutput: 'some-value', + AnotherOutput: 'another-value', + }; + + const gatewaySpecs = { + 'my-gateway': {}, + }; + + const result = parseGatewayOutputs(outputs, gatewaySpecs); + + expect(result).toEqual({}); + }); + + it('maps multiple gateways correctly', () => { + const outputs = { + GatewayFirstGatewayUrlOutput123: 'https://first.url', + GatewaySecondGatewayUrlOutput456: 'https://second.url', + GatewayThirdGatewayUrlOutput789: 'https://third.url', + }; + + const gatewaySpecs = { + 'first-gateway': {}, + 'second-gateway': {}, + 'third-gateway': {}, + }; + + const result = parseGatewayOutputs(outputs, gatewaySpecs); + + expect(Object.keys(result)).toHaveLength(3); + expect(result['first-gateway']?.gatewayArn).toBe('https://first.url'); + expect(result['second-gateway']?.gatewayArn).toBe('https://second.url'); + expect(result['third-gateway']?.gatewayArn).toBe('https://third.url'); + }); }); diff --git a/src/cli/commands/add/__tests__/actions.test.ts b/src/cli/commands/add/__tests__/actions.test.ts new file mode 100644 index 00000000..852523bc --- /dev/null +++ b/src/cli/commands/add/__tests__/actions.test.ts @@ -0,0 +1,68 @@ +import { buildGatewayTargetConfig } from '../actions.js'; +import type { ValidatedAddGatewayTargetOptions } from '../actions.js'; +import { describe, expect, it } from 'vitest'; + +describe('buildGatewayTargetConfig', () => { + it('maps name, gateway, language correctly', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.name).toBe('test-tool'); + expect(config.language).toBe('Python'); + expect(config.gateway).toBe('my-gateway'); + }); + + it('sets outboundAuth when credential provided with type != NONE', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + outboundAuthType: 'API_KEY', + credentialName: 'my-cred', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.outboundAuth).toEqual({ + type: 'API_KEY', + credentialName: 'my-cred', + }); + }); + + it('sets endpoint for existing-endpoint source', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + source: 'existing-endpoint', + endpoint: 'https://api.example.com', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.source).toBe('existing-endpoint'); + expect(config.endpoint).toBe('https://api.example.com'); + }); + + it('omits outboundAuth when type is NONE', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + outboundAuthType: 'NONE', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.outboundAuth).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index f53b7187..625e22b5 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -12,7 +12,15 @@ import { validateAddIdentityOptions, validateAddMemoryOptions, } from '../validate.js'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + }, +})); // Helper: valid base options for each type const validAgentOptionsByo: AddAgentOptions = { @@ -64,6 +72,8 @@ const validIdentityOptions: AddIdentityOptions = { }; describe('validate', () => { + afterEach(() => vi.clearAllMocks()); + describe('validateAddAgentOptions', () => { // AC1: All required fields validated it('returns error for missing required fields', () => { @@ -258,6 +268,107 @@ describe('validate', () => { const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); expect(result.valid).toBe(true); }); + // AC20: existing-endpoint source validation + it('passes for valid existing-endpoint with https', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'https://example.com/mcp', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + expect(options.language).toBe('Other'); + }); + + it('passes for valid existing-endpoint with http', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'http://localhost:3000/mcp', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + }); + + it('returns error for existing-endpoint without endpoint', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('--endpoint is required when source is existing-endpoint'); + }); + + it('returns error for existing-endpoint with non-http(s) URL', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'ftp://example.com/mcp', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('Endpoint must use http:// or https:// protocol'); + }); + + it('returns error for existing-endpoint with invalid URL', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'not-a-url', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('Endpoint must be a valid URL (e.g. https://example.com/mcp)'); + }); + + // AC21: credential validation through outbound auth + it('returns error when credential not found', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'existing-cred', type: 'ApiKey' }], + }); + + const options: AddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + outboundAuthType: 'API_KEY', + credentialName: 'missing-cred', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toContain('Credential "missing-cred" not found'); + }); + + it('returns error when no credentials configured', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [], + }); + + const options: AddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + outboundAuthType: 'API_KEY', + credentialName: 'any-cred', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toContain('No credentials are configured'); + }); + + it('passes when credential exists', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'valid-cred', type: 'ApiKey' }], + }); + + const options: AddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + outboundAuthType: 'API_KEY', + credentialName: 'valid-cred', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + }); }); describe('validateAddMemoryOptions', () => { diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 88c5dcc9..28b6d0cb 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -285,7 +285,7 @@ export async function handleAddGateway(options: ValidatedAddGatewayOptions): Pro } // MCP Tool handler -function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig { +export function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig { const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`; const description = options.description ?? `Tool for ${options.name}`; diff --git a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts index 780ec8e4..cc49fe31 100644 --- a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts +++ b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts @@ -1,14 +1,30 @@ -import { setupApiKeyProviders } from '../pre-deploy-identity.js'; +import { + getAllCredentials, + hasOwnedIdentityOAuthProviders, + setupApiKeyProviders, + setupOAuth2Providers, +} from '../pre-deploy-identity.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockKmsSend, mockControlSend, mockSetTokenVaultKmsKey, mockReadEnvFile, mockGetCredentialProvider } = - vi.hoisted(() => ({ - mockKmsSend: vi.fn(), - mockControlSend: vi.fn(), - mockSetTokenVaultKmsKey: vi.fn(), - mockReadEnvFile: vi.fn(), - mockGetCredentialProvider: vi.fn(), - })); +const { + mockKmsSend, + mockControlSend, + mockSetTokenVaultKmsKey, + mockReadEnvFile, + mockGetCredentialProvider, + mockOAuth2ProviderExists, + mockCreateOAuth2Provider, + mockUpdateOAuth2Provider, +} = vi.hoisted(() => ({ + mockKmsSend: vi.fn(), + mockControlSend: vi.fn(), + mockSetTokenVaultKmsKey: vi.fn(), + mockReadEnvFile: vi.fn(), + mockGetCredentialProvider: vi.fn(), + mockOAuth2ProviderExists: vi.fn(), + mockCreateOAuth2Provider: vi.fn(), + mockUpdateOAuth2Provider: vi.fn(), +})); vi.mock('@aws-sdk/client-kms', () => ({ KMSClient: class { @@ -35,17 +51,27 @@ vi.mock('../../identity/index.js', () => ({ updateApiKeyProvider: vi.fn(), })); +vi.mock('../../identity/oauth2-credential-provider.js', () => ({ + oAuth2ProviderExists: mockOAuth2ProviderExists, + createOAuth2Provider: mockCreateOAuth2Provider, + updateOAuth2Provider: mockUpdateOAuth2Provider, +})); + vi.mock('../../identity/create-identity.js', () => ({ - computeDefaultCredentialEnvVarName: vi.fn((name: string) => `${name}_API_KEY`), + computeDefaultCredentialEnvVarName: vi.fn((name: string) => `AGENTCORE_CREDENTIAL_${name.toUpperCase()}`), })); vi.mock('../../../../lib/index.js', () => ({ SecureCredentials: class { - static fromEnvVars() { - return { - merge: () => ({}), - get: () => undefined, - }; + constructor(private envVars: Record) {} + static fromEnvVars(envVars: Record) { + return new this(envVars); + } + merge(_other: any) { + return this; + } + get(key: string) { + return this.envVars[key]; } }, readEnvFile: mockReadEnvFile, @@ -173,3 +199,185 @@ describe('setupApiKeyProviders - KMS key reuse via GetTokenVault', () => { expect(mockKmsSend).not.toHaveBeenCalled(); }); }); + +describe('hasOwnedIdentityOAuthProviders', () => { + it('returns true when OAuthCredentialProvider exists', () => { + const projectSpec = { + credentials: [ + { name: 'oauth-cred', type: 'OAuthCredentialProvider' }, + { name: 'api-cred', type: 'ApiKeyCredentialProvider' }, + ], + }; + expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(true); + }); + + it('returns false when only ApiKey credentials exist', () => { + const projectSpec = { + credentials: [{ name: 'api-cred', type: 'ApiKeyCredentialProvider' }], + }; + expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(false); + }); + + it('returns false when no credentials exist', () => { + const projectSpec = { credentials: [] }; + expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(false); + }); +}); + +describe('getAllCredentials', () => { + it('returns API key env var for ApiKeyCredentialProvider', () => { + const projectSpec = { + credentials: [{ name: 'test-api', type: 'ApiKeyCredentialProvider' }], + }; + const result = getAllCredentials(projectSpec as any); + expect(result).toEqual([{ providerName: 'test-api', envVarName: 'AGENTCORE_CREDENTIAL_TEST-API' }]); + }); + + it('returns CLIENT_ID and CLIENT_SECRET vars for OAuthCredentialProvider', () => { + const projectSpec = { + credentials: [{ name: 'oauth-provider', type: 'OAuthCredentialProvider' }], + }; + const result = getAllCredentials(projectSpec as any); + expect(result).toEqual([ + { providerName: 'oauth-provider', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_PROVIDER_CLIENT_ID' }, + { providerName: 'oauth-provider', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_PROVIDER_CLIENT_SECRET' }, + ]); + }); + + it('handles both credential types together', () => { + const projectSpec = { + credentials: [ + { name: 'api-key', type: 'ApiKeyCredentialProvider' }, + { name: 'oauth-cred', type: 'OAuthCredentialProvider' }, + ], + }; + const result = getAllCredentials(projectSpec as any); + expect(result).toEqual([ + { providerName: 'api-key', envVarName: 'AGENTCORE_CREDENTIAL_API-KEY' }, + { providerName: 'oauth-cred', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_CRED_CLIENT_ID' }, + { providerName: 'oauth-cred', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_CRED_CLIENT_SECRET' }, + ]); + }); + + it('uppercases and replaces hyphens with underscores', () => { + const projectSpec = { + credentials: [{ name: 'my-oauth-provider', type: 'OAuthCredentialProvider' }], + }; + const result = getAllCredentials(projectSpec as any); + expect(result[0]!.envVarName).toBe('AGENTCORE_CREDENTIAL_MY_OAUTH_PROVIDER_CLIENT_ID'); + expect(result[1]!.envVarName).toBe('AGENTCORE_CREDENTIAL_MY_OAUTH_PROVIDER_CLIENT_SECRET'); + }); +}); + +describe('setupOAuth2Providers', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates OAuth2 provider when it does not exist', async () => { + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_SECRET: 'secret456', + }); + mockOAuth2ProviderExists.mockResolvedValue(false); + mockCreateOAuth2Provider.mockResolvedValue({ + success: true, + result: { credentialProviderArn: 'arn:provider', clientSecretArn: 'arn:secret', callbackUrl: 'https://callback' }, + }); + + const projectSpec = { + credentials: [ + { + name: 'test-oauth', + type: 'OAuthCredentialProvider', + vendor: 'Google', + discoveryUrl: 'https://accounts.google.com/.well-known/openid_configuration', + }, + ], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('created'); + expect(mockCreateOAuth2Provider).toHaveBeenCalledWith(expect.anything(), { + name: 'test-oauth', + vendor: 'Google', + discoveryUrl: 'https://accounts.google.com/.well-known/openid_configuration', + clientId: 'client123', + clientSecret: 'secret456', + }); + }); + + it('updates OAuth2 provider when it exists', async () => { + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_SECRET: 'secret456', + }); + mockOAuth2ProviderExists.mockResolvedValue(true); + mockUpdateOAuth2Provider.mockResolvedValue({ success: true, result: {} }); + + const projectSpec = { + credentials: [{ name: 'test-oauth', type: 'OAuthCredentialProvider' }], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('updated'); + expect(mockUpdateOAuth2Provider).toHaveBeenCalled(); + }); + + it('skips when env vars are missing', async () => { + mockReadEnvFile.mockResolvedValue({}); + + const projectSpec = { + credentials: [{ name: 'test-oauth', type: 'OAuthCredentialProvider' }], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('skipped'); + expect(result.results[0]!.error).toContain('Missing'); + }); + + it('returns error on failure', async () => { + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_SECRET: 'secret456', + }); + mockOAuth2ProviderExists.mockResolvedValue(false); + mockCreateOAuth2Provider.mockResolvedValue({ success: false, error: 'Creation failed' }); + + const projectSpec = { + credentials: [{ name: 'test-oauth', type: 'OAuthCredentialProvider' }], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(true); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toBe('Creation failed'); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 5c9cd839..6687147b 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -1,5 +1,110 @@ -import { formatError } from '../preflight.js'; -import { describe, expect, it } from 'vitest'; +import { formatError, validateProject } from '../preflight.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadProjectSpec, mockReadAWSDeploymentTargets, mockReadMcpSpec, mockReadDeployedState, mockConfigExists } = + vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockReadAWSDeploymentTargets: vi.fn(), + mockReadMcpSpec: vi.fn(), + mockReadDeployedState: vi.fn(), + mockConfigExists: vi.fn(), + })); + +const { mockValidate } = vi.hoisted(() => ({ + mockValidate: vi.fn(), +})); + +const { mockValidateAwsCredentials } = vi.hoisted(() => ({ + mockValidateAwsCredentials: vi.fn(), +})); + +const { mockRequireConfigRoot } = vi.hoisted(() => ({ + mockRequireConfigRoot: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + constructor(_options?: { baseDir?: string }) { + // mock constructor + } + readProjectSpec = mockReadProjectSpec; + readAWSDeploymentTargets = mockReadAWSDeploymentTargets; + readMcpSpec = mockReadMcpSpec; + readDeployedState = mockReadDeployedState; + configExists = mockConfigExists; + }, + requireConfigRoot: mockRequireConfigRoot, +})); + +vi.mock('../../../cdk/local-cdk-project.js', () => ({ + LocalCdkProject: class { + validate = mockValidate; + }, +})); + +vi.mock('../../../aws/account.js', () => ({ + validateAwsCredentials: mockValidateAwsCredentials, +})); + +describe('validateProject', () => { + afterEach(() => vi.clearAllMocks()); + + it('allows deploy when gateways exist but no agents', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + agents: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'test-gateway' }], + }); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); + + it('blocks deploy when no agents and no gateways', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + agents: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockReadMcpSpec.mockRejectedValue(new Error('No mcp.json')); + mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); + + await expect(validateProject()).rejects.toThrow( + 'No agents or gateways defined in project. Add at least one agent with "agentcore add agent" or gateway with "agentcore add gateway" before deploying.' + ); + }); + + it('allows deploy when both agents and gateways exist', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + agents: [{ name: 'test-agent' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'test-gateway' }], + }); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); +}); describe('formatError', () => { it('formats a simple Error', () => { diff --git a/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts b/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts new file mode 100644 index 00000000..23523dde --- /dev/null +++ b/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts @@ -0,0 +1,236 @@ +import { + createOAuth2Provider, + getOAuth2Provider, + oAuth2ProviderExists, + updateOAuth2Provider, +} from '../oauth2-credential-provider.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend, MockResourceNotFoundException } = vi.hoisted(() => ({ + mockSend: vi.fn(), + MockResourceNotFoundException: class extends Error { + constructor(message = 'not found') { + super(message); + this.name = 'ResourceNotFoundException'; + } + }, +})); + +vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ + BedrockAgentCoreControlClient: class { + send = mockSend; + }, + CreateOauth2CredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + GetOauth2CredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + UpdateOauth2CredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + ResourceNotFoundException: MockResourceNotFoundException, +})); + +function makeMockClient() { + return { send: mockSend } as any; +} + +describe('oAuth2ProviderExists', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns true when provider exists', async () => { + mockSend.mockResolvedValue({}); + + expect(await oAuth2ProviderExists(makeMockClient(), 'my-provider')).toBe(true); + }); + + it('returns false on ResourceNotFoundException', async () => { + mockSend.mockRejectedValue(new MockResourceNotFoundException()); + + expect(await oAuth2ProviderExists(makeMockClient(), 'my-provider')).toBe(false); + }); + + it('rethrows other errors', async () => { + mockSend.mockRejectedValue(new Error('other error')); + + await expect(oAuth2ProviderExists(makeMockClient(), 'my-provider')).rejects.toThrow('other error'); + }); +}); + +describe('createOAuth2Provider', () => { + afterEach(() => vi.clearAllMocks()); + + const mockParams = { + name: 'test-provider', + vendor: 'CustomOauth2', + discoveryUrl: 'https://example.com/.well-known/openid_configuration', + clientId: 'client123', + clientSecret: 'secret123', + }; + + it('returns success with full result', async () => { + const mockResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret' }, + callbackUrl: 'https://callback.example.com', + }; + mockSend.mockResolvedValue(mockResponse); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + callbackUrl: 'https://callback.example.com', + }, + }); + }); + + it('falls back to update on ConflictException', async () => { + const conflictError = new Error('conflict'); + Object.defineProperty(conflictError, 'name', { value: 'ConflictException' }); + + const updateResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }; + + mockSend.mockRejectedValueOnce(conflictError); + mockSend.mockResolvedValueOnce(updateResponse); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }, + }); + }); + + it('falls back to update on ResourceAlreadyExistsException', async () => { + const existsError = new Error('already exists'); + Object.defineProperty(existsError, 'name', { value: 'ResourceAlreadyExistsException' }); + + const updateResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }; + + mockSend.mockRejectedValueOnce(existsError); + mockSend.mockResolvedValueOnce(updateResponse); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }, + }); + }); + + it('returns error on other exceptions', async () => { + mockSend.mockRejectedValue(new Error('unexpected error')); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('unexpected error'); + }); + + it('returns error when no credentialProviderArn in response', async () => { + mockSend.mockResolvedValue({}); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: false, + error: 'No credential provider ARN in response', + }); + }); +}); + +describe('getOAuth2Provider', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns success with result', async () => { + const mockResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret' }, + callbackUrl: 'https://callback.example.com', + }; + mockSend.mockResolvedValue(mockResponse); + + const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + callbackUrl: 'https://callback.example.com', + }, + }); + }); + + it('returns error on failure', async () => { + mockSend.mockRejectedValue(new Error('get failed')); + + const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); + + expect(result.success).toBe(false); + expect(result.error).toBe('get failed'); + }); + + it('returns error when no ARN', async () => { + mockSend.mockResolvedValue({}); + + const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); + + expect(result).toEqual({ + success: false, + error: 'No credential provider ARN in response', + }); + }); +}); + +describe('updateOAuth2Provider', () => { + afterEach(() => vi.clearAllMocks()); + + const mockParams = { + name: 'test-provider', + vendor: 'CustomOauth2', + discoveryUrl: 'https://example.com/.well-known/openid_configuration', + clientId: 'client123', + clientSecret: 'secret123', + }; + + it('returns success with result', async () => { + const mockResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret' }, + }; + mockSend.mockResolvedValue(mockResponse); + + const result = await updateOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + }, + }); + }); + + it('returns error on failure', async () => { + mockSend.mockRejectedValue(new Error('update failed')); + + const result = await updateOAuth2Provider(makeMockClient(), mockParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('update failed'); + }); +}); diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index d8816eb9..a1ae39a2 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,42 +1,133 @@ -import { computeDefaultGatewayEnvVarName, computeDefaultMcpRuntimeEnvVarName } from '../create-mcp.js'; -import { describe, expect, it } from 'vitest'; +import { SKIP_FOR_NOW } from '../../../tui/screens/mcp/types.js'; +import type { AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; +import { createExternalGatewayTarget } from '../create-mcp.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -describe('computeDefaultGatewayEnvVarName', () => { - it('converts simple name to env var', () => { - expect(computeDefaultGatewayEnvVarName('mygateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); - }); +const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists, mockReadProjectSpec } = vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockConfigExists: vi.fn(), + mockReadProjectSpec: vi.fn(), +})); - it('replaces hyphens with underscores', () => { - expect(computeDefaultGatewayEnvVarName('my-gateway')).toBe('AGENTCORE_GATEWAY_MY_GATEWAY_URL'); - }); +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + readProjectSpec = mockReadProjectSpec; + }, +})); + +function makeExternalConfig(overrides: Partial = {}): AddGatewayTargetConfig { + return { + name: 'test-target', + description: 'Test target', + sourcePath: '/tmp/test', + language: 'Other', + source: 'existing-endpoint', + endpoint: 'https://api.example.com', + gateway: 'test-gateway', + host: 'Lambda', + toolDefinition: { name: 'test-tool', description: 'Test tool' }, + ...overrides, + } as AddGatewayTargetConfig; +} + +describe('createExternalGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); - it('uppercases the name', () => { - expect(computeDefaultGatewayEnvVarName('MyGateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); + it('creates target with endpoint and assigns to specified gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget(makeExternalConfig()); + + expect(mockWriteMcpSpec).toHaveBeenCalled(); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways[0]!; + expect(gateway.targets).toHaveLength(1); + expect(gateway.targets[0]!.name).toBe('test-target'); + expect(gateway.targets[0]!.endpoint).toBe('https://api.example.com'); + expect(gateway.targets[0]!.targetType).toBe('mcpServer'); }); - it('handles multiple hyphens', () => { - expect(computeDefaultGatewayEnvVarName('my-cool-gateway')).toBe('AGENTCORE_GATEWAY_MY_COOL_GATEWAY_URL'); + it('stores target in unassignedTargets when gateway is skip-for-now', async () => { + const mockMcpSpec = { agentCoreGateways: [] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW })); + + expect(mockWriteMcpSpec).toHaveBeenCalled(); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(written.unassignedTargets).toHaveLength(1); + expect(written.unassignedTargets[0]!.name).toBe('test-target'); + expect(written.unassignedTargets[0]!.endpoint).toBe('https://api.example.com'); }); - it('handles already uppercase name', () => { - expect(computeDefaultGatewayEnvVarName('GW')).toBe('AGENTCORE_GATEWAY_GW_URL'); + it('initializes unassignedTargets array if it does not exist in mcp spec', async () => { + const mockMcpSpec = { agentCoreGateways: [] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW })); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(Array.isArray(written.unassignedTargets)).toBe(true); }); -}); -describe('computeDefaultMcpRuntimeEnvVarName', () => { - it('converts simple name to env var', () => { - expect(computeDefaultMcpRuntimeEnvVarName('myruntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); + it('throws on duplicate target name in gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [{ name: 'test-target' }] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig())).rejects.toThrow( + 'Target "test-target" already exists in gateway "test-gateway"' + ); }); - it('replaces hyphens with underscores', () => { - expect(computeDefaultMcpRuntimeEnvVarName('my-runtime')).toBe('AGENTCORE_MCPRUNTIME_MY_RUNTIME_URL'); + it('throws on duplicate target name in unassigned targets', async () => { + const mockMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [{ name: 'test-target' }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW }))).rejects.toThrow( + 'Unassigned target "test-target" already exists' + ); }); - it('uppercases the name', () => { - expect(computeDefaultMcpRuntimeEnvVarName('MyRuntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); + it('throws when gateway not found', async () => { + const mockMcpSpec = { agentCoreGateways: [] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: 'nonexistent' }))).rejects.toThrow( + 'Gateway "nonexistent" not found' + ); }); - it('handles multiple hyphens', () => { - expect(computeDefaultMcpRuntimeEnvVarName('a-b-c')).toBe('AGENTCORE_MCPRUNTIME_A_B_C_URL'); + it('includes outboundAuth when configured', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget( + makeExternalConfig({ outboundAuth: { type: 'API_KEY', credentialName: 'my-cred' } }) + ); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const target = written.agentCoreGateways[0]!.targets[0]!; + expect(target.outboundAuth).toEqual({ type: 'API_KEY', credentialName: 'my-cred' }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-gateway-target.test.ts b/src/cli/operations/remove/__tests__/remove-gateway-target.test.ts new file mode 100644 index 00000000..ef7cd476 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-gateway-target.test.ts @@ -0,0 +1,237 @@ +import { + getRemovableGatewayTargets, + previewRemoveGatewayTarget, + removeGatewayTarget, +} from '../remove-gateway-target.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadMcpSpec, mockWriteMcpSpec, mockReadMcpDefs, mockWriteMcpDefs, mockConfigExists, mockGetProjectRoot } = + vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockReadMcpDefs: vi.fn(), + mockWriteMcpDefs: vi.fn(), + mockConfigExists: vi.fn(), + mockGetProjectRoot: vi.fn(), + })); + +const { mockExistsSync, mockRm } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockRm: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + readMcpDefs = mockReadMcpDefs; + writeMcpDefs = mockWriteMcpDefs; + getProjectRoot = mockGetProjectRoot; + }, +})); + +vi.mock('fs', () => ({ + existsSync: mockExistsSync, +})); + +vi.mock('fs/promises', () => ({ + rm: mockRm, +})); + +describe('getRemovableGatewayTargets', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns targets from all gateways with gateway name attached', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway-1', + targets: [{ name: 'target-1' }, { name: 'target-2' }], + }, + { + name: 'gateway-2', + targets: [{ name: 'target-3' }], + }, + ], + }); + + const result = await getRemovableGatewayTargets(); + + expect(result).toEqual([ + { name: 'target-1', type: 'gateway-target', gatewayName: 'gateway-1' }, + { name: 'target-2', type: 'gateway-target', gatewayName: 'gateway-1' }, + { name: 'target-3', type: 'gateway-target', gatewayName: 'gateway-2' }, + ]); + }); + + it('returns empty array when no gateways', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + }); + + const result = await getRemovableGatewayTargets(); + + expect(result).toEqual([]); + }); + + it('returns empty array when gateways have no targets', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'gateway-1', targets: [] }], + }); + + const result = await getRemovableGatewayTargets(); + + expect(result).toEqual([]); + }); +}); + +describe('previewRemoveGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); + + it('shows files that will be deleted for scaffolded targets', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [ + { + name: 'test-target', + compute: { + implementation: { path: 'app/test-target' }, + }, + toolDefinitions: [{ name: 'test-tool' }], + }, + ], + }, + ], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ + tools: { 'test-tool': { name: 'test-tool' } }, + }); + mockGetProjectRoot.mockReturnValue('/project'); + mockExistsSync.mockReturnValue(true); + + const target = { name: 'test-target', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await previewRemoveGatewayTarget(target); + + expect(result.summary).toContain('Removing gateway target: test-target (from test-gateway)'); + expect(result.summary).toContain('Deleting directory: app/test-target'); + expect(result.summary).toContain('Removing tool definition: test-tool'); + expect(result.directoriesToDelete).toEqual(['/project/app/test-target']); + }); + + it('shows correct gateway name in preview', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'my-gateway', + targets: [ + { + name: 'my-target', + toolDefinitions: [{ name: 'my-tool' }], + }, + ], + }, + ], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'my-target', type: 'gateway-target' as const, gatewayName: 'my-gateway' }; + const result = await previewRemoveGatewayTarget(target); + + expect(result.summary).toContain('Removing gateway target: my-target (from my-gateway)'); + }); + + it('handles external targets with no files to delete', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [ + { + name: 'external-target', + endpoint: 'https://api.example.com', + toolDefinitions: [{ name: 'external-tool' }], + }, + ], + }, + ], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'external-target', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await previewRemoveGatewayTarget(target); + + expect(result.summary).toContain('Removing gateway target: external-target (from test-gateway)'); + expect(result.directoriesToDelete).toEqual([]); + }); +}); + +describe('removeGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); + + it('removes target from gateway config and writes updated mcp.json', async () => { + const mockMcpSpec = { + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [{ name: 'target-1' }, { name: 'target-2' }], + }, + ], + }; + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'target-1', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await removeGatewayTarget(target); + + expect(result.ok).toBe(true); + expect(mockWriteMcpSpec).toHaveBeenCalledWith({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [{ name: 'target-2' }], + }, + ], + }); + }); + + it('handles last target in gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [{ name: 'last-target' }], + }, + ], + }; + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'last-target', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await removeGatewayTarget(target); + + expect(result.ok).toBe(true); + expect(mockWriteMcpSpec).toHaveBeenCalledWith({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [], + }, + ], + }); + }); +}); diff --git a/src/cli/operations/remove/__tests__/remove-identity.test.ts b/src/cli/operations/remove/__tests__/remove-identity.test.ts new file mode 100644 index 00000000..b6172a33 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-identity.test.ts @@ -0,0 +1,121 @@ +import { previewRemoveCredential } from '../remove-identity.js'; +import { describe, expect, it, vi } from 'vitest'; + +const { mockReadProjectSpec, mockConfigExists, mockReadMcpSpec } = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockConfigExists: vi.fn(), + mockReadMcpSpec: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + }, +})); + +describe('previewRemoveCredential', () => { + it('shows warning when credential is referenced by gateway targets outboundAuth', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'test-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [ + { + name: 'target1', + outboundAuth: { credentialName: 'test-cred' }, + }, + ], + }, + ], + }); + + const result = await previewRemoveCredential('test-cred'); + + expect(result.summary).toContain( + 'Warning: Credential "test-cred" is referenced by gateway targets: gateway1/target1. Removing it may break these targets.' + ); + }); + + it('lists which targets reference the credential', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'shared-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [ + { name: 'target1', outboundAuth: { credentialName: 'shared-cred' } }, + { name: 'target2', outboundAuth: { credentialName: 'other-cred' } }, + ], + }, + { + name: 'gateway2', + targets: [{ name: 'target3', outboundAuth: { credentialName: 'shared-cred' } }], + }, + ], + }); + + const result = await previewRemoveCredential('shared-cred'); + + expect(result.summary).toContain( + 'Warning: Credential "shared-cred" is referenced by gateway targets: gateway1/target1, gateway2/target3. Removing it may break these targets.' + ); + }); + + it('shows no warning when credential is not referenced', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'unused-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [{ name: 'target1', outboundAuth: { credentialName: 'other-cred' } }], + }, + ], + }); + + const result = await previewRemoveCredential('unused-cred'); + + const warningMessage = result.summary.find(s => s.includes('Warning')); + expect(warningMessage).toBeUndefined(); + }); + + it('checks across ALL gateways targets for references', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'test-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [{ name: 'target1' }], + }, + { + name: 'gateway2', + targets: [{ name: 'target2', outboundAuth: { credentialName: 'test-cred' } }], + }, + { + name: 'gateway3', + targets: [{ name: 'target3' }], + }, + ], + }); + + const result = await previewRemoveCredential('test-cred'); + + expect(result.summary).toContain( + 'Warning: Credential "test-cred" is referenced by gateway targets: gateway2/target2. Removing it may break these targets.' + ); + }); +}); diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index bbdf6883..358e1386 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -137,4 +137,42 @@ describe('ResourceGraph', () => { expect(lastFrame()).toContain('memory'); expect(lastFrame()).toContain('credential'); }); + + it('renders ⚠ indicator when unassigned targets exist in mcp spec', () => { + const mcp: AgentCoreMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [{ name: 'unassigned-target', targetType: 'mcpServer' }], + } as unknown as AgentCoreMcpSpec; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('⚠ Unassigned Targets'); + expect(lastFrame()).toContain('⚠'); + }); + + it('shows unassigned target names', () => { + const mcp: AgentCoreMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [ + { name: 'target-1', targetType: 'mcpServer' }, + { name: 'target-2', targetType: 'mcpServer' }, + ], + } as unknown as AgentCoreMcpSpec; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('target-1'); + expect(lastFrame()).toContain('target-2'); + }); + + it('does not render unassigned section when no unassigned targets', () => { + const mcp: AgentCoreMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [], + } as unknown as AgentCoreMcpSpec; + + const { lastFrame } = render(); + + expect(lastFrame()).not.toContain('⚠ Unassigned Targets'); + }); }); diff --git a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx new file mode 100644 index 00000000..546cdb15 --- /dev/null +++ b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx @@ -0,0 +1,17 @@ +import { AddScreen } from '../AddScreen.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('AddScreen', () => { + it('gateway and gateway-target options are present and not disabled', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Gateway'); + expect(lastFrame()).toContain('MCP Tool'); + expect(lastFrame()).not.toContain('Add an agent first'); + }); +}); diff --git a/src/cli/tui/screens/mcp/__tests__/types.test.ts b/src/cli/tui/screens/mcp/__tests__/types.test.ts new file mode 100644 index 00000000..31c1e9db --- /dev/null +++ b/src/cli/tui/screens/mcp/__tests__/types.test.ts @@ -0,0 +1,20 @@ +import { AUTHORIZER_TYPE_OPTIONS, SKIP_FOR_NOW, SOURCE_OPTIONS } from '../types.js'; +import { describe, expect, it } from 'vitest'; + +describe('MCP types constants', () => { + it('AUTHORIZER_TYPE_OPTIONS: AWS_IAM is first option', () => { + expect(AUTHORIZER_TYPE_OPTIONS[0]?.id).toBe('AWS_IAM'); + }); + + it('SKIP_FOR_NOW equals skip-for-now', () => { + expect(SKIP_FOR_NOW).toBe('skip-for-now'); + }); + + it('SOURCE_OPTIONS has entries for existing-endpoint and create-new', () => { + const existingEndpoint = SOURCE_OPTIONS.find((opt: { id: string }) => opt.id === 'existing-endpoint'); + const createNew = SOURCE_OPTIONS.find((opt: { id: string }) => opt.id === 'create-new'); + + expect(existingEndpoint).toBeDefined(); + expect(createNew).toBeDefined(); + }); +}); diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx new file mode 100644 index 00000000..127a86e7 --- /dev/null +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -0,0 +1,48 @@ +import { RemoveScreen } from '../RemoveScreen.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('RemoveScreen', () => { + it('gateway and gateway-target options enabled when counts > 0', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Gateway'); + expect(lastFrame()).toContain('MCP Tool'); + expect(lastFrame()).not.toContain('No gateways to remove'); + expect(lastFrame()).not.toContain('No gateway targets to remove'); + }); + + it('gateway and gateway-target options disabled when counts = 0', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('No gateways to remove'); + expect(lastFrame()).toContain('No gateway targets to remove'); + }); +}); diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 64565f66..f6beaf74 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -263,6 +263,60 @@ describe('CredentialSchema', () => { }); expect(result.success).toBe(false); }); + + it('ApiKeyCredentialProvider with name passes', () => { + const result = CredentialSchema.safeParse({ + type: 'ApiKeyCredentialProvider', + name: 'MyApiKey', + }); + expect(result.success).toBe(true); + }); + + it('OAuthCredentialProvider with name and discoveryUrl passes', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + }); + expect(result.success).toBe(true); + }); + + it('OAuthCredentialProvider with scopes omitted passes', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + }); + expect(result.success).toBe(true); + }); + + it('OAuthCredentialProvider without discoveryUrl fails', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + }); + expect(result.success).toBe(false); + }); + + it('invalid type fails discriminated union', () => { + const result = CredentialSchema.safeParse({ + type: 'InvalidCredentialType', + name: 'MyCred', + }); + expect(result.success).toBe(false); + }); + + it('vendor defaults to CustomOauth2', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + }); + expect(result.success).toBe(true); + if (result.success && result.data.type === 'OAuthCredentialProvider') { + expect(result.data.vendor).toBe('CustomOauth2'); + } + }); }); describe('AgentCoreProjectSpecSchema', () => { diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index dd57d183..74c9f6ee 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -1,5 +1,6 @@ import { AgentCoreDeployedStateSchema, + CredentialDeployedStateSchema, CustomJwtAuthorizerSchema, DeployedResourceStateSchema, DeployedStateSchema, @@ -137,6 +138,47 @@ describe('VpcConfigSchema', () => { }); }); +describe('CredentialDeployedStateSchema', () => { + it('accepts valid credential state with all fields', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + callbackUrl: 'https://callback.example.com', + }); + expect(result.success).toBe(true); + }); + + it('accepts credential state with only required credentialProviderArn', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + }); + expect(result.success).toBe(true); + }); + + it('accepts credential state with optional clientSecretArn', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + }); + expect(result.success).toBe(true); + }); + + it('accepts credential state with optional callbackUrl', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + callbackUrl: 'https://callback.example.com', + }); + expect(result.success).toBe(true); + }); + + it('rejects credential state without credentialProviderArn', () => { + const result = CredentialDeployedStateSchema.safeParse({ + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + }); + expect(result.success).toBe(false); + }); +}); + describe('DeployedResourceStateSchema', () => { it('accepts empty resource state', () => { expect(DeployedResourceStateSchema.safeParse({}).success).toBe(true); @@ -162,6 +204,18 @@ describe('DeployedResourceStateSchema', () => { }); expect(result.success).toBe(true); }); + + it('accepts resource state with credentials', () => { + const result = DeployedResourceStateSchema.safeParse({ + credentials: { + MyCred: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + }, + }, + }); + expect(result.success).toBe(true); + }); }); describe('DeployedStateSchema', () => { diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 2414406b..8c95c268 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -390,6 +390,110 @@ describe('AgentCoreMcpRuntimeToolSchema', () => { }); }); +describe('AgentCoreGatewayTargetSchema with outbound auth', () => { + const validToolDef = { + name: 'myTool', + description: 'A test tool', + inputSchema: { type: 'object' as const }, + }; + + it('outboundAuth with type OAUTH but no credentialName fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + outboundAuth: { type: 'OAUTH' }, + }); + expect(result.success).toBe(false); + }); + + it('outboundAuth with type NONE and no credentialName passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + outboundAuth: { type: 'NONE' }, + }); + expect(result.success).toBe(true); + }); + + it('outboundAuth with type OAUTH and credentialName passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + outboundAuth: { type: 'OAUTH', credentialName: 'my-oauth-cred' }, + }); + expect(result.success).toBe(true); + }); + + it('mcpServer target with endpoint and no compute passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result.success).toBe(true); + }); + + it('mcpServer target with compute and no endpoint passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + compute: { + host: 'AgentCoreRuntime', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + }, + }); + expect(result.success).toBe(true); + }); + + it('mcpServer target with neither endpoint nor compute fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + }); + expect(result.success).toBe(false); + }); + + it('Lambda target without compute fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + }); + expect(result.success).toBe(false); + }); + + it('Lambda target without toolDefinitions fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(false); + }); +}); + describe('AgentCoreMcpSpecSchema', () => { it('accepts valid MCP spec', () => { const validToolDef = { @@ -427,4 +531,36 @@ describe('AgentCoreMcpSpecSchema', () => { }); expect(result.success).toBe(false); }); + + it('spec with unassignedTargets array parses correctly', () => { + const validToolDef = { + name: 'tool', + description: 'A tool', + inputSchema: { type: 'object' as const }, + }; + + const result = AgentCoreMcpSpecSchema.safeParse({ + agentCoreGateways: [], + unassignedTargets: [ + { + name: 'unassigned-target', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('spec without unassignedTargets parses correctly', () => { + const result = AgentCoreMcpSpecSchema.safeParse({ + agentCoreGateways: [], + }); + expect(result.success).toBe(true); + }); });