From 80cb527c01173a060064606b8fe286d5510f145e Mon Sep 17 00:00:00 2001 From: Daniel Neilson <53624638+ddneilson@users.noreply.github.com> Date: Fri, 2 Sep 2022 17:07:32 -0500 Subject: [PATCH] feat(assertions): add function for verifying the number of matching resource properties (#21707) This PR adds the `Template.resourcePropertiesCountIs()` method for counting the number of resources of a specified type whose `Properties` section matches given properties. Implements: https://github.com/aws/aws-cdk/issues/21706 ---- ### All Submissions: * [X] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/assertions/README.md | 11 +++ .../assertions/lib/private/resources.ts | 26 +++++- packages/@aws-cdk/assertions/lib/template.ts | 16 +++- .../@aws-cdk/assertions/test/template.test.ts | 87 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/assertions/README.md b/packages/@aws-cdk/assertions/README.md index 01b93e7055664..83f86fe6e2f95 100644 --- a/packages/@aws-cdk/assertions/README.md +++ b/packages/@aws-cdk/assertions/README.md @@ -76,6 +76,17 @@ in a template. template.resourceCountIs('Foo::Bar', 2); ``` +You can also count the number of resources of a specific type whose `Properties` +section contains the specified properties: + +```ts +template.resourcePropertiesCountIs('Foo::Bar', { + Foo: 'Bar', + Baz: 5, + Qux: [ 'Waldo', 'Fred' ], +}, 1); +``` + ## Resource Matching & Retrieval Beyond resource counting, the module also allows asserting that a resource with diff --git a/packages/@aws-cdk/assertions/lib/private/resources.ts b/packages/@aws-cdk/assertions/lib/private/resources.ts index 00a57c05f9b26..1312a5bfa4ed7 100644 --- a/packages/@aws-cdk/assertions/lib/private/resources.ts +++ b/packages/@aws-cdk/assertions/lib/private/resources.ts @@ -32,11 +32,12 @@ export function hasResource(template: Template, type: string, props: any): strin } export function hasResourceProperties(template: Template, type: string, props: any): string | void { - // amended needs to be a deep copy to avoid modifying the template. - let amended = JSON.parse(JSON.stringify(template)); + let amended = template; // special case to exclude AbsentMatch because adding an empty Properties object will affect its evaluation. if (!Matcher.isMatcher(props) || !(props instanceof AbsentMatch)) { + // amended needs to be a deep copy to avoid modifying the template. + amended = JSON.parse(JSON.stringify(template)); amended = addEmptyProperties(amended); } @@ -52,6 +53,27 @@ export function countResources(template: Template, type: string): number { return Object.entries(types).length; } +export function countResourcesProperties(template: Template, type: string, props: any): number { + let amended = template; + + // special case to exclude AbsentMatch because adding an empty Properties object will affect its evaluation. + if (!Matcher.isMatcher(props) || !(props instanceof AbsentMatch)) { + // amended needs to be a deep copy to avoid modifying the template. + amended = JSON.parse(JSON.stringify(template)); + amended = addEmptyProperties(amended); + } + + const section = amended.Resources ?? {}; + const result = matchSection(filterType(section, type), Match.objectLike({ + Properties: props, + })); + + if (result.match) { + return Object.keys(result.matches).length; + } + return 0; +} + function addEmptyProperties(template: Template): Template { let section = template.Resources ?? {}; diff --git a/packages/@aws-cdk/assertions/lib/template.ts b/packages/@aws-cdk/assertions/lib/template.ts index 6399d3a971897..0dffd428da27a 100644 --- a/packages/@aws-cdk/assertions/lib/template.ts +++ b/packages/@aws-cdk/assertions/lib/template.ts @@ -8,7 +8,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 { countResources, findResources, hasResource, hasResourceProperties } from './private/resources'; +import { countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources'; import { Template as TemplateType } from './private/template'; /** @@ -71,6 +71,20 @@ export class Template { } } + /** + * Assert that the given number of resources of the given type and properties exists in the + * CloudFormation template. + * @param type the resource type; ex: `AWS::S3::Bucket` + * @param props the 'Properties' section of the resource as should be expected in the template. + * @param count number of expected instances + */ + public resourcePropertiesCountIs(type: string, props: any, count: number): void { + const counted = countResourcesProperties(this.template, type, props); + if (counted !== count) { + throw new Error(`Expected ${count} resources of type ${type} but found ${counted}`); + } + } + /** * Assert that a resource of the given type and properties exists in the * CloudFormation template. diff --git a/packages/@aws-cdk/assertions/test/template.test.ts b/packages/@aws-cdk/assertions/test/template.test.ts index c298f75cec161..bae0a27ce2b0d 100644 --- a/packages/@aws-cdk/assertions/test/template.test.ts +++ b/packages/@aws-cdk/assertions/test/template.test.ts @@ -124,6 +124,93 @@ describe('Template', () => { }); }); + describe('resourcePropertiesCountIs', () => { + test('resource exists', () => { + const stack = new Stack(); + new CfnResource(stack, 'Resource', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + + const inspect = Template.fromStack(stack); + inspect.resourcePropertiesCountIs('Foo::Bar', { baz: 'qux' }, 1); + + expect(() => { + inspect.resourcePropertiesCountIs('Foo::Bar', { baz: 'qux' }, 0); + }).toThrow('Expected 0 resources of type Foo::Bar but found 1'); + expect(() => { + inspect.resourcePropertiesCountIs('Foo::Bar', { baz: 'qux' }, 2); + }).toThrow('Expected 2 resources of type Foo::Bar but found 1'); + expect(() => { + inspect.resourcePropertiesCountIs('Foo::Bar', { baz: 'nope' }, 1); + }).toThrow('Expected 1 resources of type Foo::Bar but found 0'); + expect(() => { + inspect.resourcePropertiesCountIs('Foo::Baz', { baz: 'qux' }, 1); + }).toThrow('Expected 1 resources of type Foo::Baz but found 0'); + }); + test('no resource', () => { + const stack = new Stack(); + + const inspect = Template.fromStack(stack); + inspect.resourcePropertiesCountIs('Foo::Bar', { baz: 'qux' }, 0); + + expect(() => { + inspect.resourcePropertiesCountIs('Foo::Bar', { baz: 'qux' }, 1); + }).toThrow('Expected 1 resources of type Foo::Bar but found 0'); + }); + test('absent - with properties', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + + const inspect = Template.fromStack(stack); + inspect.resourcePropertiesCountIs('Foo::Bar', { + bar: Match.absent(), + }, 1); + inspect.resourcePropertiesCountIs('Foo::Bar', { + baz: Match.absent(), + }, 0); + }); + test('absent - no properties', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + }); + + const inspect = Template.fromStack(stack); + inspect.resourcePropertiesCountIs('Foo::Bar', { + bar: Match.absent(), + baz: 'qux', + }, 0); + inspect.resourcePropertiesCountIs('Foo::Bar', Match.absent(), 1); + }); + test('not - with properties', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + + const inspect = Template.fromStack(stack); + inspect.resourcePropertiesCountIs('Foo::Bar', Match.not({ + baz: 'boo', + }), 1); + }); + test('not - no properties', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + }); + + const inspect = Template.fromStack(stack); + inspect.resourcePropertiesCountIs('Foo::Bar', Match.not({ + baz: 'qux', + }), 1); + }); + }); + describe('templateMatches', () => { test('matches', () => { const app = new App();