/** * CloudFormation naming integration tests. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { CloudFormationClient, CreateStackCommand, DescribeStacksCommand, DescribeStackResourcesCommand, DeleteStackCommand, } from '@aws-sdk/client-cloudformation'; import { makeClient, uniqueName, sleep } from './setup'; describe('CloudFormation Naming', () => { let cfn: CloudFormationClient; let autoStackName: string; let explicitStackName: string; const token = Date.now().toString(36); beforeAll(() => { cfn = makeClient(CloudFormationClient); autoStackName = `cfn-auto-naming-${token}`; explicitStackName = `cfn-explicit-naming-${token}`; }); afterAll(async () => { for (const stackName of [autoStackName, explicitStackName]) { try { await cfn.send(new DeleteStackCommand({ StackName: stackName })); } catch { // ignore } } }); async function waitForStackTerminalState(stackName: string, expectedSuccess = true) { const successStates = new Set(['CREATE_COMPLETE', 'UPDATE_COMPLETE']); const failureStates = new Set([ 'CREATE_FAILED', 'ROLLBACK_IN_PROGRESS', 'ROLLBACK_FAILED', 'ROLLBACK_COMPLETE', 'DELETE_FAILED', 'DELETE_COMPLETE', ]); for (let i = 0; i < 40; i++) { const resp = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); const status = resp.Stacks?.[0]?.StackStatus; if (successStates.has(status!)) { return { ok: expectedSuccess, status }; } if (failureStates.has(status!)) { return { ok: !expectedSuccess, status }; } await sleep(1000); } return { ok: false, status: 'TIMEOUT' }; } async function getResources(stackName: string) { const resp = await cfn.send(new DescribeStackResourcesCommand({ StackName: stackName })); return resp.StackResources || []; } function physicalId(resources: { LogicalResourceId?: string; PhysicalResourceId?: string }[], logicalId: string) { return resources.find((r) => r.LogicalResourceId === logicalId)?.PhysicalResourceId; } describe('Auto-generated names', () => { it('should create stack with auto-generated names', async () => { const template = { Resources: { AutoBucket: { Type: 'AWS::S3::Bucket' }, AutoQueue: { Type: 'AWS::SQS::Queue' }, AutoTopic: { Type: 'AWS::SNS::Topic' }, AutoParameter: { Type: 'AWS::SSM::Parameter', Properties: { Type: 'String', Value: 'v1' }, }, CrossRefQueue: { Type: 'AWS::SQS::Queue', Properties: { QueueName: { 'Fn::Sub': '${AutoBucket}-cross' } }, }, }, }; await cfn.send( new CreateStackCommand({ StackName: autoStackName, TemplateBody: JSON.stringify(template), }) ); }); it('should reach CREATE_COMPLETE', async () => { const result = await waitForStackTerminalState(autoStackName, true); expect(result.ok).toBe(true); }); it('should have valid auto-generated resource names', async () => { const resources = await getResources(autoStackName); expect(resources.length).toBeGreaterThan(0); const autoBucket = physicalId(resources, 'AutoBucket'); const autoQueue = physicalId(resources, 'AutoQueue'); const autoTopic = physicalId(resources, 'AutoTopic'); const autoParam = physicalId(resources, 'AutoParameter'); const crossQueue = physicalId(resources, 'CrossRefQueue'); // S3 bucket constraints expect(autoBucket).toBeTruthy(); expect(autoBucket!.length).toBeGreaterThanOrEqual(3); expect(autoBucket!.length).toBeLessThanOrEqual(63); expect(/^[a-z0-9.-]+$/.test(autoBucket!)).toBe(true); expect(autoBucket).toBe(autoBucket!.toLowerCase()); // SQS queue constraints expect(autoQueue).toBeTruthy(); const queueName = autoQueue!.includes('/') ? autoQueue!.slice(autoQueue!.lastIndexOf('/') + 1) : autoQueue!; expect(queueName.length).toBeGreaterThan(0); expect(queueName.length).toBeLessThanOrEqual(80); // SNS topic constraints expect(autoTopic).toBeTruthy(); const topicName = autoTopic!.includes(':') ? autoTopic!.slice(autoTopic!.lastIndexOf(':') + 1) : autoTopic!; expect(topicName.length).toBeGreaterThan(0); expect(topicName.length).toBeLessThanOrEqual(256); // SSM parameter constraints expect(autoParam).toBeTruthy(); expect(autoParam!.length).toBeLessThanOrEqual(2048); // Cross-reference queue uses bucket name expect(crossQueue).toBeTruthy(); const crossQueueName = crossQueue!.includes('/') ? crossQueue!.slice(crossQueue!.lastIndexOf('/') + 1) : crossQueue!; expect(crossQueueName.startsWith(`${autoBucket}-cross`)).toBe(true); }); it('should delete auto stack', async () => { await cfn.send(new DeleteStackCommand({ StackName: autoStackName })); }); }); describe('Explicit names', () => { const explicitBucket = `cfn-explicit-${token}`; const explicitQueue = `cfn-explicit-${token}`; const explicitTopic = `cfn-explicit-${token}`; const explicitParam = `/cfn-explicit/${token}`; it('should create stack with explicit names', async () => { const template = { Resources: { NamedBucket: { Type: 'AWS::S3::Bucket', Properties: { BucketName: explicitBucket }, }, NamedQueue: { Type: 'AWS::SQS::Queue', Properties: { QueueName: explicitQueue }, }, NamedTopic: { Type: 'AWS::SNS::Topic', Properties: { TopicName: explicitTopic }, }, NamedParameter: { Type: 'AWS::SSM::Parameter', Properties: { Name: explicitParam, Type: 'String', Value: 'explicit' }, }, }, }; await cfn.send( new CreateStackCommand({ StackName: explicitStackName, TemplateBody: JSON.stringify(template), }) ); }); it('should reach CREATE_COMPLETE', async () => { const result = await waitForStackTerminalState(explicitStackName, true); expect(result.ok).toBe(true); }); it('should respect explicit names', async () => { const resources = await getResources(explicitStackName); const actualBucket = physicalId(resources, 'NamedBucket'); const actualQueue = physicalId(resources, 'NamedQueue'); const actualTopic = physicalId(resources, 'NamedTopic'); const actualParam = physicalId(resources, 'NamedParameter'); expect(actualBucket).toBe(explicitBucket); expect(actualQueue).toContain(explicitQueue); expect(actualTopic).toContain(explicitTopic); expect(actualParam).toBe(explicitParam); }); it('should delete explicit stack', async () => { await cfn.send(new DeleteStackCommand({ StackName: explicitStackName })); }); }); });