Skip to content

Commit 497d7ca

Browse files
authored
test: add unit tests for Batches 1-3 gateway functionality (#415)
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.
1 parent 45cdbbd commit 497d7ca

17 files changed

Lines changed: 1701 additions & 45 deletions

File tree

src/cli/cloudformation/__tests__/outputs.test.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildDeployedState } from '../outputs';
1+
import { buildDeployedState, parseGatewayOutputs } from '../outputs';
22
import { describe, expect, it } from 'vitest';
33

44
describe('buildDeployedState', () => {
@@ -61,4 +61,116 @@ describe('buildDeployedState', () => {
6161
expect(result.targets.prod!.resources?.stackName).toBe('ProdStack');
6262
expect(result.targets.dev!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/dev-key');
6363
});
64+
65+
it('includes credentials in deployed state when provided', () => {
66+
const agents = {
67+
TestAgent: {
68+
runtimeId: 'rt-123',
69+
runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123',
70+
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
71+
},
72+
};
73+
74+
const credentials = {
75+
'test-cred': {
76+
credentialProviderArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-cred',
77+
},
78+
};
79+
80+
const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, credentials);
81+
82+
expect(result.targets.default!.resources?.credentials).toEqual(credentials);
83+
});
84+
85+
it('omits credentials field when credentials is undefined', () => {
86+
const agents = {
87+
TestAgent: {
88+
runtimeId: 'rt-123',
89+
runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123',
90+
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
91+
},
92+
};
93+
94+
const result = buildDeployedState('default', 'TestStack', agents, {});
95+
96+
expect(result.targets.default!.resources?.credentials).toBeUndefined();
97+
});
98+
99+
it('omits credentials field when credentials is empty object', () => {
100+
const agents = {
101+
TestAgent: {
102+
runtimeId: 'rt-123',
103+
runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123',
104+
roleArn: 'arn:aws:iam::123456789012:role/TestRole',
105+
},
106+
};
107+
108+
const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, {});
109+
110+
expect(result.targets.default!.resources?.credentials).toBeUndefined();
111+
});
112+
});
113+
114+
describe('parseGatewayOutputs', () => {
115+
it('extracts gateway URL from outputs matching pattern', () => {
116+
const outputs = {
117+
GatewayMyGatewayUrlOutput3E11FAB4: 'https://api.gateway.url',
118+
GatewayAnotherGatewayUrlOutputABC123: 'https://another.gateway.url',
119+
UnrelatedOutput: 'some-value',
120+
};
121+
122+
const gatewaySpecs = {
123+
'my-gateway': {},
124+
'another-gateway': {},
125+
};
126+
127+
const result = parseGatewayOutputs(outputs, gatewaySpecs);
128+
129+
expect(result).toEqual({
130+
'my-gateway': {
131+
gatewayId: 'my-gateway',
132+
gatewayArn: 'https://api.gateway.url',
133+
},
134+
'another-gateway': {
135+
gatewayId: 'another-gateway',
136+
gatewayArn: 'https://another.gateway.url',
137+
},
138+
});
139+
});
140+
141+
it('handles missing gateway outputs gracefully', () => {
142+
const outputs = {
143+
UnrelatedOutput: 'some-value',
144+
AnotherOutput: 'another-value',
145+
};
146+
147+
const gatewaySpecs = {
148+
'my-gateway': {},
149+
};
150+
151+
const result = parseGatewayOutputs(outputs, gatewaySpecs);
152+
153+
expect(result).toEqual({});
154+
});
155+
156+
it('maps multiple gateways correctly', () => {
157+
const outputs = {
158+
GatewayFirstGatewayUrlOutput123: 'https://first.url',
159+
GatewaySecondGatewayUrlOutput456: 'https://second.url',
160+
GatewayThirdGatewayUrlOutput789: 'https://third.url',
161+
};
162+
163+
const gatewaySpecs = {
164+
'first-gateway': {},
165+
'second-gateway': {},
166+
'third-gateway': {},
167+
};
168+
169+
const result = parseGatewayOutputs(outputs, gatewaySpecs);
170+
171+
expect(Object.keys(result)).toHaveLength(3);
172+
expect(result['first-gateway']?.gatewayArn).toBe('https://first.url');
173+
expect(result['second-gateway']?.gatewayArn).toBe('https://second.url');
174+
expect(result['third-gateway']?.gatewayArn).toBe('https://third.url');
175+
});
64176
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { buildGatewayTargetConfig } from '../actions.js';
2+
import type { ValidatedAddGatewayTargetOptions } from '../actions.js';
3+
import { describe, expect, it } from 'vitest';
4+
5+
describe('buildGatewayTargetConfig', () => {
6+
it('maps name, gateway, language correctly', () => {
7+
const options: ValidatedAddGatewayTargetOptions = {
8+
name: 'test-tool',
9+
language: 'Python',
10+
gateway: 'my-gateway',
11+
host: 'Lambda',
12+
};
13+
14+
const config = buildGatewayTargetConfig(options);
15+
16+
expect(config.name).toBe('test-tool');
17+
expect(config.language).toBe('Python');
18+
expect(config.gateway).toBe('my-gateway');
19+
});
20+
21+
it('sets outboundAuth when credential provided with type != NONE', () => {
22+
const options: ValidatedAddGatewayTargetOptions = {
23+
name: 'test-tool',
24+
language: 'Python',
25+
gateway: 'my-gateway',
26+
host: 'Lambda',
27+
outboundAuthType: 'API_KEY',
28+
credentialName: 'my-cred',
29+
};
30+
31+
const config = buildGatewayTargetConfig(options);
32+
33+
expect(config.outboundAuth).toEqual({
34+
type: 'API_KEY',
35+
credentialName: 'my-cred',
36+
});
37+
});
38+
39+
it('sets endpoint for existing-endpoint source', () => {
40+
const options: ValidatedAddGatewayTargetOptions = {
41+
name: 'test-tool',
42+
language: 'Python',
43+
gateway: 'my-gateway',
44+
host: 'Lambda',
45+
source: 'existing-endpoint',
46+
endpoint: 'https://api.example.com',
47+
};
48+
49+
const config = buildGatewayTargetConfig(options);
50+
51+
expect(config.source).toBe('existing-endpoint');
52+
expect(config.endpoint).toBe('https://api.example.com');
53+
});
54+
55+
it('omits outboundAuth when type is NONE', () => {
56+
const options: ValidatedAddGatewayTargetOptions = {
57+
name: 'test-tool',
58+
language: 'Python',
59+
gateway: 'my-gateway',
60+
host: 'Lambda',
61+
outboundAuthType: 'NONE',
62+
};
63+
64+
const config = buildGatewayTargetConfig(options);
65+
66+
expect(config.outboundAuth).toBeUndefined();
67+
});
68+
});

src/cli/commands/add/__tests__/validate.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@ import {
1212
validateAddIdentityOptions,
1313
validateAddMemoryOptions,
1414
} from '../validate.js';
15-
import { describe, expect, it } from 'vitest';
15+
import { afterEach, describe, expect, it, vi } from 'vitest';
16+
17+
const mockReadProjectSpec = vi.fn();
18+
19+
vi.mock('../../../../lib/index.js', () => ({
20+
ConfigIO: class {
21+
readProjectSpec = mockReadProjectSpec;
22+
},
23+
}));
1624

1725
// Helper: valid base options for each type
1826
const validAgentOptionsByo: AddAgentOptions = {
@@ -64,6 +72,8 @@ const validIdentityOptions: AddIdentityOptions = {
6472
};
6573

6674
describe('validate', () => {
75+
afterEach(() => vi.clearAllMocks());
76+
6777
describe('validateAddAgentOptions', () => {
6878
// AC1: All required fields validated
6979
it('returns error for missing required fields', () => {
@@ -258,6 +268,107 @@ describe('validate', () => {
258268
const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions });
259269
expect(result.valid).toBe(true);
260270
});
271+
// AC20: existing-endpoint source validation
272+
it('passes for valid existing-endpoint with https', async () => {
273+
const options: AddGatewayTargetOptions = {
274+
name: 'test-tool',
275+
source: 'existing-endpoint',
276+
endpoint: 'https://example.com/mcp',
277+
};
278+
const result = await validateAddGatewayTargetOptions(options);
279+
expect(result.valid).toBe(true);
280+
expect(options.language).toBe('Other');
281+
});
282+
283+
it('passes for valid existing-endpoint with http', async () => {
284+
const options: AddGatewayTargetOptions = {
285+
name: 'test-tool',
286+
source: 'existing-endpoint',
287+
endpoint: 'http://localhost:3000/mcp',
288+
};
289+
const result = await validateAddGatewayTargetOptions(options);
290+
expect(result.valid).toBe(true);
291+
});
292+
293+
it('returns error for existing-endpoint without endpoint', async () => {
294+
const options: AddGatewayTargetOptions = {
295+
name: 'test-tool',
296+
source: 'existing-endpoint',
297+
};
298+
const result = await validateAddGatewayTargetOptions(options);
299+
expect(result.valid).toBe(false);
300+
expect(result.error).toBe('--endpoint is required when source is existing-endpoint');
301+
});
302+
303+
it('returns error for existing-endpoint with non-http(s) URL', async () => {
304+
const options: AddGatewayTargetOptions = {
305+
name: 'test-tool',
306+
source: 'existing-endpoint',
307+
endpoint: 'ftp://example.com/mcp',
308+
};
309+
const result = await validateAddGatewayTargetOptions(options);
310+
expect(result.valid).toBe(false);
311+
expect(result.error).toBe('Endpoint must use http:// or https:// protocol');
312+
});
313+
314+
it('returns error for existing-endpoint with invalid URL', async () => {
315+
const options: AddGatewayTargetOptions = {
316+
name: 'test-tool',
317+
source: 'existing-endpoint',
318+
endpoint: 'not-a-url',
319+
};
320+
const result = await validateAddGatewayTargetOptions(options);
321+
expect(result.valid).toBe(false);
322+
expect(result.error).toBe('Endpoint must be a valid URL (e.g. https://example.com/mcp)');
323+
});
324+
325+
// AC21: credential validation through outbound auth
326+
it('returns error when credential not found', async () => {
327+
mockReadProjectSpec.mockResolvedValue({
328+
credentials: [{ name: 'existing-cred', type: 'ApiKey' }],
329+
});
330+
331+
const options: AddGatewayTargetOptions = {
332+
name: 'test-tool',
333+
language: 'Python',
334+
outboundAuthType: 'API_KEY',
335+
credentialName: 'missing-cred',
336+
};
337+
const result = await validateAddGatewayTargetOptions(options);
338+
expect(result.valid).toBe(false);
339+
expect(result.error).toContain('Credential "missing-cred" not found');
340+
});
341+
342+
it('returns error when no credentials configured', async () => {
343+
mockReadProjectSpec.mockResolvedValue({
344+
credentials: [],
345+
});
346+
347+
const options: AddGatewayTargetOptions = {
348+
name: 'test-tool',
349+
language: 'Python',
350+
outboundAuthType: 'API_KEY',
351+
credentialName: 'any-cred',
352+
};
353+
const result = await validateAddGatewayTargetOptions(options);
354+
expect(result.valid).toBe(false);
355+
expect(result.error).toContain('No credentials are configured');
356+
});
357+
358+
it('passes when credential exists', async () => {
359+
mockReadProjectSpec.mockResolvedValue({
360+
credentials: [{ name: 'valid-cred', type: 'ApiKey' }],
361+
});
362+
363+
const options: AddGatewayTargetOptions = {
364+
name: 'test-tool',
365+
language: 'Python',
366+
outboundAuthType: 'API_KEY',
367+
credentialName: 'valid-cred',
368+
};
369+
const result = await validateAddGatewayTargetOptions(options);
370+
expect(result.valid).toBe(true);
371+
});
261372
});
262373

263374
describe('validateAddMemoryOptions', () => {

src/cli/commands/add/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ export async function handleAddGateway(options: ValidatedAddGatewayOptions): Pro
285285
}
286286

287287
// MCP Tool handler
288-
function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig {
288+
export function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig {
289289
const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`;
290290

291291
const description = options.description ?? `Tool for ${options.name}`;

0 commit comments

Comments
 (0)