262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {
|
|
CreateStackCommand,
|
|
CreateChangeSetCommand,
|
|
DescribeChangeSetCommand,
|
|
DeleteStackCommand,
|
|
DescribeStackEventsCommand,
|
|
DescribeStacksCommand,
|
|
GetTemplateCommand,
|
|
ListStackResourcesCommand,
|
|
ListStacksCommand,
|
|
UpdateStackCommand,
|
|
type CloudFormationClient,
|
|
} from '@aws-sdk/client-cloudformation'
|
|
import {AwsCloudFormationAdapter} from './AwsCloudFormationAdapter'
|
|
|
|
function fakeClient(handler: (command: unknown) => unknown): CloudFormationClient {
|
|
return {
|
|
send: async (command: unknown) => handler(command),
|
|
} as unknown as CloudFormationClient
|
|
}
|
|
|
|
const ordersStackSummary = {
|
|
StackId: 'arn:aws:cloudformation:us-east-1:000000000000:stack/orders-stack/abc123',
|
|
StackName: 'orders-stack',
|
|
CreationTime: new Date('2026-01-02T03:04:05.000Z'),
|
|
StackStatus: 'CREATE_COMPLETE',
|
|
}
|
|
|
|
const ordersStack = {
|
|
StackId: 'arn:aws:cloudformation:us-east-1:000000000000:stack/orders-stack/abc123',
|
|
StackName: 'orders-stack',
|
|
CreationTime: new Date('2026-01-02T03:04:05.000Z'),
|
|
LastUpdatedTime: new Date('2026-01-02T03:04:06.000Z'),
|
|
StackStatus: 'CREATE_COMPLETE',
|
|
Capabilities: [],
|
|
Outputs: [{OutputKey: 'BucketName', OutputValue: 'orders-bucket', Description: 'The bucket name'}],
|
|
Tags: [{Key: 'env', Value: 'dev'}],
|
|
EnableTerminationProtection: false,
|
|
}
|
|
|
|
const bucketResource = {
|
|
LogicalResourceId: 'OrdersBucket',
|
|
PhysicalResourceId: 'orders-bucket',
|
|
ResourceType: 'AWS::S3::Bucket',
|
|
ResourceStatus: 'CREATE_COMPLETE',
|
|
}
|
|
|
|
describe('AwsCloudFormationAdapter', () => {
|
|
test('lists every page and maps stacks to normalized resources', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
if (!(command instanceof ListStacksCommand)) throw new Error('Unexpected command')
|
|
if (!command.input.NextToken) return {StackSummaries: [ordersStackSummary], NextToken: 'next'}
|
|
return {StackSummaries: [{...ordersStackSummary, StackName: 'accounts-stack'}]}
|
|
}))
|
|
|
|
const result = await adapter.list()
|
|
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0]).toMatchObject({
|
|
id: 'orders-stack',
|
|
name: 'orders-stack',
|
|
cloud: 'aws',
|
|
service: 'iac',
|
|
type: 'stack',
|
|
region: null,
|
|
createdAt: '2026-01-02T03:04:05.000Z',
|
|
status: 'CREATE_COMPLETE',
|
|
})
|
|
expect(result[0].metadata).toMatchObject({
|
|
stackId: ordersStackSummary.StackId,
|
|
})
|
|
})
|
|
|
|
test('filters stacks by search term', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({
|
|
StackSummaries: [ordersStackSummary, {...ordersStackSummary, StackName: 'accounts-stack'}],
|
|
})))
|
|
|
|
const result = await adapter.list({search: 'ORDERS'})
|
|
|
|
expect(result.map((resource) => resource.name)).toEqual(['orders-stack'])
|
|
})
|
|
|
|
test('gets and maps one stack, including its resources', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
|
|
if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: [bucketResource]}
|
|
if (command instanceof DescribeStackEventsCommand) return {StackEvents: [{LogicalResourceId: 'OrdersBucket', ResourceStatus: 'CREATE_COMPLETE', Timestamp: new Date('2026-01-02T03:04:07.000Z')}]}
|
|
if (command instanceof GetTemplateCommand) return {TemplateBody: '{"Resources":{}}'}
|
|
throw new Error('Unexpected command')
|
|
}))
|
|
|
|
const result = await adapter.get('orders-stack')
|
|
|
|
expect(result?.id).toBe('orders-stack')
|
|
expect(result?.status).toBe('CREATE_COMPLETE')
|
|
expect(result?.metadata.outputs).toEqual([{key: 'BucketName', value: 'orders-bucket', description: 'The bucket name'}])
|
|
expect(result?.metadata.tags).toEqual([{key: 'env', value: 'dev'}])
|
|
expect(result?.metadata.resources).toEqual([{
|
|
logicalId: 'OrdersBucket',
|
|
physicalId: 'orders-bucket',
|
|
resourceType: 'AWS::S3::Bucket',
|
|
status: 'CREATE_COMPLETE',
|
|
}])
|
|
expect(result?.metadata.templateBody).toBe('{"Resources":{}}')
|
|
})
|
|
|
|
test('paginates ListStackResources and combines resources from every page', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
|
|
if (command instanceof ListStackResourcesCommand) {
|
|
if (!command.input.NextToken) {
|
|
return {StackResourceSummaries: [bucketResource], NextToken: 'next-page'}
|
|
}
|
|
return {StackResourceSummaries: [{...bucketResource, LogicalResourceId: 'OrdersQueue', PhysicalResourceId: 'orders-queue', ResourceType: 'AWS::SQS::Queue'}]}
|
|
}
|
|
if (command instanceof DescribeStackEventsCommand) return {StackEvents: []}
|
|
if (command instanceof GetTemplateCommand) return {TemplateBody: '{}'}
|
|
throw new Error('Unexpected command')
|
|
}))
|
|
|
|
const result = await adapter.get('orders-stack')
|
|
|
|
expect(result?.metadata.resources).toEqual([
|
|
{
|
|
logicalId: 'OrdersBucket',
|
|
physicalId: 'orders-bucket',
|
|
resourceType: 'AWS::S3::Bucket',
|
|
status: 'CREATE_COMPLETE',
|
|
},
|
|
{
|
|
logicalId: 'OrdersQueue',
|
|
physicalId: 'orders-queue',
|
|
resourceType: 'AWS::SQS::Queue',
|
|
status: 'CREATE_COMPLETE',
|
|
},
|
|
])
|
|
})
|
|
|
|
test('returns null when CloudFormation reports a missing stack', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
|
|
throw Object.assign(new Error('Stack with id missing-stack does not exist'), {name: 'ValidationError'})
|
|
}))
|
|
|
|
await expect(adapter.get('missing-stack')).resolves.toBeNull()
|
|
})
|
|
|
|
test('creates a stack from a stack name and template body', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
expect(command).toBeInstanceOf(CreateStackCommand)
|
|
expect((command as CreateStackCommand).input).toEqual({
|
|
StackName: 'orders-stack',
|
|
TemplateBody: '{"Resources":{}}',
|
|
})
|
|
return {StackId: ordersStack.StackId}
|
|
}))
|
|
|
|
const result = await adapter.create({values: {stackName: 'orders-stack', templateBody: '{"Resources":{}}'}})
|
|
|
|
expect(result.id).toBe('orders-stack')
|
|
expect(result.status).toBe('CREATE_IN_PROGRESS')
|
|
expect(result.metadata.stackId).toBe(ordersStack.StackId)
|
|
})
|
|
|
|
test('passes parameters and capabilities to CloudFormation', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
if (command instanceof CreateStackCommand) {
|
|
expect(command.input).toMatchObject({
|
|
Parameters: [{ParameterKey: 'Environment', ParameterValue: 'test'}],
|
|
Capabilities: ['CAPABILITY_NAMED_IAM'],
|
|
})
|
|
return {StackId: ordersStack.StackId}
|
|
}
|
|
if (command instanceof CreateChangeSetCommand) {
|
|
expect(command.input).toMatchObject({
|
|
Parameters: [{ParameterKey: 'Environment', ParameterValue: 'test'}],
|
|
Capabilities: ['CAPABILITY_NAMED_IAM'],
|
|
})
|
|
return {Id: 'change-set-id'}
|
|
}
|
|
if (command instanceof DescribeChangeSetCommand) return {ChangeSetName: 'planned', Status: 'CREATE_COMPLETE', ExecutionStatus: 'AVAILABLE'}
|
|
throw new Error('Unexpected command')
|
|
}))
|
|
|
|
await adapter.create({values: {stackName: 'orders-stack', templateBody: '{}', parameters: '{"Environment":"test"}', capabilities: 'CAPABILITY_NAMED_IAM'}})
|
|
await adapter.createCloudFormationChangeSet('orders-stack', {name: 'planned', templateBody: '{}', parameters: {Environment: 'test'}, capabilities: ['CAPABILITY_NAMED_IAM']})
|
|
})
|
|
|
|
test('creates a stack from an S3 template URL', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
expect(command).toBeInstanceOf(CreateStackCommand)
|
|
expect((command as CreateStackCommand).input).toEqual({
|
|
StackName: 'orders-stack',
|
|
TemplateURL: 'https://templates.s3.amazonaws.com/orders.yaml',
|
|
})
|
|
return {StackId: ordersStack.StackId}
|
|
}))
|
|
|
|
await expect(adapter.create({values: {stackName: 'orders-stack', templateUrl: 'https://templates.s3.amazonaws.com/orders.yaml'}})).resolves.toMatchObject({id: 'orders-stack'})
|
|
})
|
|
|
|
test('rejects a missing stack name before calling CloudFormation', async () => {
|
|
let called = false
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
|
|
called = true
|
|
return {}
|
|
}))
|
|
|
|
await expect(adapter.create({values: {templateBody: '{}'}})).rejects.toThrow('stackName is required')
|
|
expect(called).toBeFalse()
|
|
})
|
|
|
|
test('rejects missing template input before calling CloudFormation', async () => {
|
|
let called = false
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
|
|
called = true
|
|
return {}
|
|
}))
|
|
|
|
await expect(adapter.create({values: {stackName: 'orders-stack'}})).rejects.toThrow('templateBody or templateUrl is required')
|
|
expect(called).toBeFalse()
|
|
})
|
|
|
|
test('deletes the requested stack', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
expect(command).toBeInstanceOf(DeleteStackCommand)
|
|
expect((command as DeleteStackCommand).input.StackName).toBe('orders-stack')
|
|
return {}
|
|
}))
|
|
|
|
await adapter.delete('orders-stack')
|
|
})
|
|
|
|
test('updates a stack template then reads back the stack workspace', async () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
|
|
if (command instanceof UpdateStackCommand) {
|
|
expect(command.input).toEqual({StackName: 'orders-stack', TemplateBody: '{"Resources":{"Queue":{"Type":"AWS::SQS::Queue"}}}'})
|
|
return {StackId: ordersStack.StackId}
|
|
}
|
|
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
|
|
if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: []}
|
|
if (command instanceof DescribeStackEventsCommand) return {StackEvents: []}
|
|
if (command instanceof GetTemplateCommand) return {TemplateBody: '{}'}
|
|
throw new Error('Unexpected command')
|
|
}))
|
|
|
|
await expect(adapter.update('orders-stack', {values: {templateBody: '{"Resources":{"Queue":{"Type":"AWS::SQS::Queue"}}}'}})).resolves.toMatchObject({id: 'orders-stack'})
|
|
})
|
|
|
|
test('returns the AWS CloudFormation schema', () => {
|
|
const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({})))
|
|
|
|
expect(adapter.schema()).toMatchObject({
|
|
cloud: 'aws',
|
|
service: 'iac',
|
|
displayName: 'CloudFormation',
|
|
actions: ['list', 'create', 'update', 'delete', 'inspect'],
|
|
})
|
|
})
|
|
})
|