From 743a1106b758ddaf09231112e9df91f15d93f558 Mon Sep 17 00:00:00 2001 From: Dahlberg Victor Date: Thu, 20 Feb 2025 15:19:18 +0100 Subject: [PATCH] feat(assertions): added getResourceId method to Template --- packages/aws-cdk-lib/assertions/README.md | 30 +++++ .../assertions/lib/private/resources.ts | 26 ++++- .../aws-cdk-lib/assertions/lib/template.ts | 24 +++- .../assertions/test/template.test.ts | 108 ++++++++++++++++++ 4 files changed, 186 insertions(+), 2 deletions(-) diff --git a/packages/aws-cdk-lib/assertions/README.md b/packages/aws-cdk-lib/assertions/README.md index 21941354d08d8..115cb2dc08326 100644 --- a/packages/aws-cdk-lib/assertions/README.md +++ b/packages/aws-cdk-lib/assertions/README.md @@ -135,6 +135,36 @@ Beyond assertions, the module provides APIs to retrieve matching resources. The `findResources()` API is complementary to the `hasResource()` API, except, instead of asserting its presence, it returns the set of matching resources. +Similarly, the `getResourceId()` API is complementary to the `findResources()` API, +except it expects only one matching resource, and returns the matched resource's resource id. +Useful for asserting that certain cloudformation resources correlate expectedly. + +```ts +// Assert that a certain bucket denies unsecure communication +const bucket = template.getResourceId('AWS::S3::Bucket', { + Properties: { + BucketName: 'my-bucket', + } +}) + +template.hasResourceProperties('AWS::S3::BucketPolicy', { + Bucket: { + Ref: bucket, + }, + PolicyDocument: { + Statement: [ + { + Effect: 'Deny', + Action: 's3:*', + Principal: { AWS: '*' }, + Condition: { Bool: { 'aws:SecureTransport': 'false' } }, + }, + ], + } +}) + +``` + By default, the `hasResource()` and `hasResourceProperties()` APIs perform deep partial object matching. This behavior can be configured using matchers. See subsequent section on [special matchers](#special-matchers). diff --git a/packages/aws-cdk-lib/assertions/lib/private/resources.ts b/packages/aws-cdk-lib/assertions/lib/private/resources.ts index 708794ce00536..6d86b60ac3bb0 100644 --- a/packages/aws-cdk-lib/assertions/lib/private/resources.ts +++ b/packages/aws-cdk-lib/assertions/lib/private/resources.ts @@ -1,6 +1,6 @@ import { Match, Matcher } from '..'; import { AbsentMatch } from './matchers/absent'; -import { formatAllMismatches, matchSection, formatSectionMatchFailure } from './section'; +import { formatAllMismatches, matchSection, formatSectionMatchFailure, formatAllMatches } from './section'; import { Resource, Template } from './template'; export function findResources(template: Template, type: string, props: any = {}): { [key: string]: { [key: string]: any } } { @@ -14,6 +14,30 @@ export function findResources(template: Template, type: string, props: any = {}) return result.matches; } +export function getResourceId(template: Template, type: string, props: any = {}): { resourceId?: string; matchError?: string } { + const section = template.Resources ?? {}; + const result = matchSection(filterType(section, type), props); + + if (!result.match) { + return { + matchError: formatSectionMatchFailure(`resources with type ${type}`, result), + }; + } + + const resourceIds = Object.keys(result.matches); + + if (resourceIds.length !== 1) { + return { + matchError: [ + `Template has ${resourceIds.length} matches, expected only one.`, + formatAllMatches(result.matches), + ].join('\n'), + }; + } + + return { resourceId: resourceIds[0] }; +} + export function allResources(template: Template, type: string, props: any): string | void { const section = template.Resources ?? {}; const result = matchSection(filterType(section, type), props); diff --git a/packages/aws-cdk-lib/assertions/lib/template.ts b/packages/aws-cdk-lib/assertions/lib/template.ts index eb6ebeab5c058..ba8a0ebd3117f 100644 --- a/packages/aws-cdk-lib/assertions/lib/template.ts +++ b/packages/aws-cdk-lib/assertions/lib/template.ts @@ -7,7 +7,7 @@ import { checkTemplateForCyclicDependencies } from './private/cyclic'; import { findMappings, hasMapping } from './private/mappings'; import { findOutputs, hasOutput } from './private/outputs'; import { findParameters, hasParameter } from './private/parameters'; -import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources'; +import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, getResourceId, hasResource, hasResourceProperties } from './private/resources'; import { Template as TemplateType } from './private/template'; import { Stack, Stage } from '../../core'; import { AssertionError } from './private/error'; @@ -134,6 +134,28 @@ export class Template { return findResources(this.template, type, props); } + /** + * Get the Resource ID of a matching resource, expects only to find one match. + * Throws AssertionError if none or multiple resources were found. + * @param type the resource type; ex: `AWS::S3::Bucket` + * @param props by default, matches all resources with the given type. + * @returns The resource id of the matched resource. + * Performs a partial match via `Match.objectLike()`. + */ + public getResourceId(type: string, props: any = {}): string { + const { resourceId, matchError } = getResourceId(this.template, type, props); + + if (matchError) { + throw new AssertionError(matchError); + } + + if (!resourceId) { + throw new AssertionError('unexpected: resourceId was undefined'); + } + + return resourceId; + } + /** * Assert that all resources of the given type contain the given definition in the * CloudFormation template. diff --git a/packages/aws-cdk-lib/assertions/test/template.test.ts b/packages/aws-cdk-lib/assertions/test/template.test.ts index c657383bea455..3b1526daa8305 100644 --- a/packages/aws-cdk-lib/assertions/test/template.test.ts +++ b/packages/aws-cdk-lib/assertions/test/template.test.ts @@ -561,6 +561,114 @@ describe('Template', () => { }); }); + describe('getResourceId', () => { + test('matching resource type', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux', fred: 'waldo' }, + }); + + const inspect = Template.fromStack(stack); + expect(inspect.getResourceId('Foo::Bar')).toEqual('Foo'); + }); + + test('no matching resource type', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Baz', + properties: { baz: 'qux', fred: 'waldo' }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.getResourceId('Foo::Bar'), + ['Template has 0 resources with type Foo::Bar'], + ); + }); + + test('matching resource props', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux', fred: 'waldo' }, + }); + + const inspect = Template.fromStack(stack); + expect(inspect.getResourceId('Foo::Bar', { Properties: { fred: 'waldo' } })).toEqual('Foo'); + }); + + test('no matching resource props', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux', fred: 'waldo' }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.getResourceId('Foo::Bar', { Properties: { baz: 'quz' } }), + [ + 'Template has 1 resources with type Foo::Bar, but none match as expected', + /Expected quz but received qux/, + ], + ); + }); + + test('multiple matching resources', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { type: 'Foo::Bar' }); + new CfnResource(stack, 'Bar', { type: 'Foo::Bar' }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.getResourceId('Foo::Bar'), + [ + 'Template has 2 matches, expected only one.', + /Foo/, /Foo::Bar/, + /Bar/, /Foo::Bar/, + ], + ); + }); + + test('multiple matching resources props', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux', fred: 'waldo' }, + }); + new CfnResource(stack, 'Bar', { + type: 'Foo::Bar', + properties: { baz: 'qux', fred: 'waldo' }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.getResourceId('Foo::Bar', { Properties: { baz: 'qux' } }), + [ + 'Template has 2 matches, expected only one.', + /Foo/, /Foo::Bar/, + /Bar/, /Foo::Bar/, + ], + ); + }); + + test('multiple resources only one match', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux', fred: 'waldo' }, + }); + new CfnResource(stack, 'Bar', { + type: 'Foo::Bar', + properties: { bax: 'qux', fred: 'waldo' }, + }); + + const inspect = Template.fromStack(stack); + expect(inspect.getResourceId('Foo::Bar', { Properties: { bax: 'qux' } })).toEqual('Bar'); + }); + }); + describe('allResources', () => { test('all resource of type match', () => { const stack = new Stack();