Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion src/cli/cloudformation/__tests__/outputs.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildDeployedState } from '../outputs';
import { buildDeployedState, parseGatewayOutputs } from '../outputs';
import { describe, expect, it } from 'vitest';

describe('buildDeployedState', () => {
Expand Down Expand Up @@ -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');
});
});
68 changes: 68 additions & 0 deletions src/cli/commands/add/__tests__/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
113 changes: 112 additions & 1 deletion src/cli/commands/add/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/add/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
Loading
Loading