diff --git a/packages/@aws-cdk/assertions/README.md b/packages/@aws-cdk/assertions/README.md index 62ae9b8ae0aaa..d2894fafe1df8 100644 --- a/packages/@aws-cdk/assertions/README.md +++ b/packages/@aws-cdk/assertions/README.md @@ -102,7 +102,18 @@ The following code asserts that the `Properties` section of a resource of type ```ts template.hasResourceProperties('Foo::Bar', { - Foo: 'Bar', + Lorem: 'Ipsum', + Baz: 5, + Qux: [ 'Waldo', 'Fred' ], +}); +``` + +You can also assert that the `Properties` section of all resources of type +`Foo::Bar` contains the specified properties - + +```ts +template.allResourcesProperties('Foo::Bar', { + Lorem: 'Ipsum', Baz: 5, Qux: [ 'Waldo', 'Fred' ], }); @@ -113,7 +124,17 @@ can use the `hasResource()` API. ```ts template.hasResource('Foo::Bar', { - Properties: { Foo: 'Bar' }, + Properties: { Lorem: 'Ipsum' }, + DependsOn: [ 'Waldo', 'Fred' ], +}); +``` + +You can also assert the definitions of all resources of a type using the +`allResources()` API. + +```ts +template.allResources('Foo::Bar', { + Properties: { Lorem: 'Ipsum' }, DependsOn: [ 'Waldo', 'Fred' ], }); ``` diff --git a/packages/@aws-cdk/assertions/lib/private/resources.ts b/packages/@aws-cdk/assertions/lib/private/resources.ts index 1312a5bfa4ed7..155156df5b9a1 100644 --- a/packages/@aws-cdk/assertions/lib/private/resources.ts +++ b/packages/@aws-cdk/assertions/lib/private/resources.ts @@ -1,6 +1,6 @@ import { Match, Matcher } from '..'; import { AbsentMatch } from './matchers/absent'; -import { formatFailure, matchSection } from './section'; +import { formatAllMismatches, formatFailure, matchSection } from './section'; import { Resource, Template } from './template'; export function findResources(template: Template, type: string, props: any = {}): { [key: string]: { [key: string]: any } } { @@ -14,6 +14,42 @@ export function findResources(template: Template, type: string, props: any = {}) return result.matches; } +export function allResources(template: Template, type: string, props: any): string | void { + const section = template.Resources ?? {}; + const result = matchSection(filterType(section, type), props); + if (result.match) { + const matchCount = Object.keys(result.matches).length; + if (result.analyzedCount > matchCount) { + return [ + `Template has ${result.analyzedCount} resource(s) with type ${type}, but only ${matchCount} match as expected.`, + formatAllMismatches(result.analyzed, result.matches), + ].join('\n'); + } + } else { + return [ + `Template has ${result.analyzedCount} resource(s) with type ${type}, but none match as expected.`, + formatAllMismatches(result.analyzed), + ].join('\n'); + } +} + +export function allResourcesProperties(template: Template, type: string, props: any): string | void { + 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); + } + + return allResources(amended, type, Match.objectLike({ + Properties: props, + })); + +} + + export function hasResource(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/assertions/lib/private/section.ts b/packages/@aws-cdk/assertions/lib/private/section.ts index 2468e47f33c1c..3d81f2f1ce682 100644 --- a/packages/@aws-cdk/assertions/lib/private/section.ts +++ b/packages/@aws-cdk/assertions/lib/private/section.ts @@ -1,25 +1,25 @@ import { Match } from '../match'; import { Matcher, MatchResult } from '../matcher'; -export type MatchSuccess = { match: true, matches: {[key: string]: any} }; -export type MatchFailure = { match: false, closestResult?: MatchResult, analyzedCount: number }; +export type MatchSuccess = { match: true, matches: { [key: string]: any }, analyzed: { [key: string]: any }, analyzedCount: number }; +export type MatchFailure = { match: false, closestResult?: MatchResult, analyzed: { [key: string]: any }, analyzedCount: number }; export function matchSection(section: any, props: any): MatchSuccess | MatchFailure { const matcher = Matcher.isMatcher(props) ? props : Match.objectLike(props); let closestResult: MatchResult | undefined = undefined; - let matching: {[key: string]: any} = {}; - let count = 0; + let matching: { [key: string]: any } = {}; + let analyzed: { [key: string]: any } = {}; eachEntryInSection( section, (logicalId, entry) => { + analyzed[logicalId] = entry; const result = matcher.test(entry); result.finished(); if (!result.hasFailed()) { matching[logicalId] = entry; } else { - count++; if (closestResult === undefined || closestResult.failCount > result.failCount) { closestResult = result; } @@ -27,15 +27,15 @@ export function matchSection(section: any, props: any): MatchSuccess | MatchFail }, ); if (Object.keys(matching).length > 0) { - return { match: true, matches: matching }; + return { match: true, matches: matching, analyzedCount: Object.keys(analyzed).length, analyzed: analyzed }; } else { - return { match: false, closestResult, analyzedCount: count }; + return { match: false, closestResult, analyzedCount: Object.keys(analyzed).length, analyzed: analyzed }; } } function eachEntryInSection( section: any, - cb: (logicalId: string, entry: {[key: string]: any}) => void): void { + cb: (logicalId: string, entry: { [key: string]: any }) => void): void { for (const logicalId of Object.keys(section ?? {})) { const resource: { [key: string]: any } = section[logicalId]; @@ -43,12 +43,19 @@ function eachEntryInSection( } } -export function formatAllMatches(matches: {[key: string]: any}): string { +export function formatAllMatches(matches: { [key: string]: any }): string { return [ leftPad(JSON.stringify(matches, undefined, 2)), ].join('\n'); } +export function formatAllMismatches(analyzed: { [key: string]: any }, matches: { [key: string]: any } = {}): string { + return [ + 'The following resources do not match the given definition:', + ...Object.keys(analyzed).filter(id => !(id in matches)).map(id => `\t${id}`), + ].join('\n'); +} + export function formatFailure(closestResult: MatchResult): string { return [ 'The closest result is:', diff --git a/packages/@aws-cdk/assertions/lib/template.ts b/packages/@aws-cdk/assertions/lib/template.ts index ccc77ad555ec7..8eadc4b15aea0 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, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources'; +import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources'; import { Template as TemplateType } from './private/template'; /** @@ -114,7 +114,7 @@ export class Template { * By default, performs partial matching on the resource, via the `Match.objectLike()`. * To configure different behavour, use other matchers in the `Match` class. * @param type the resource type; ex: `AWS::S3::Bucket` - * @param props the entire defintion of the resource as should be expected in the template. + * @param props the entire definition of the resource as should be expected in the template. */ public hasResource(type: string, props: any): void { const matchError = hasResource(this.template, type, props); @@ -134,6 +134,36 @@ export class Template { return findResources(this.template, type, props); } + /** + * Assert that all resources of the given type contain the given definition in the + * CloudFormation template. + * By default, performs partial matching on the resource, via the `Match.objectLike()`. + * To configure different behavour, use other matchers in the `Match` class. + * @param type the resource type; ex: `AWS::S3::Bucket` + * @param props the entire definition of the resources as they should be expected in the template. + */ + public allResources(type: string, props: any): void { + const matchError = allResources(this.template, type, props); + if (matchError) { + throw new Error(matchError); + } + } + + /** + * Assert that all resources of the given type contain the given properties + * CloudFormation template. + * By default, performs partial matching on the `Properties` key of the resource, via the + * `Match.objectLike()`. To configure different behavour, use other matchers in the `Match` class. + * @param type the resource type; ex: `AWS::S3::Bucket` + * @param props the 'Properties' section of the resource as should be expected in the template. + */ + public allResourcesProperties(type: string, props: any): void { + const matchError = allResourcesProperties(this.template, type, props); + if (matchError) { + throw new Error(matchError); + } + } + /** * Assert that a Parameter with the given properties exists in the CloudFormation template. * By default, performs partial matching on the parameter, via the `Match.objectLike()`. diff --git a/packages/@aws-cdk/assertions/test/template.test.ts b/packages/@aws-cdk/assertions/test/template.test.ts index 13354ca3614eb..7eb60db07a630 100644 --- a/packages/@aws-cdk/assertions/test/template.test.ts +++ b/packages/@aws-cdk/assertions/test/template.test.ts @@ -553,6 +553,174 @@ describe('Template', () => { }); }); + describe('allResources', () => { + test('all resource of type match', () => { + const stack = new Stack(); + const partialProps = { baz: 'qux', fred: 'waldo' }; + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { ...partialProps, lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: partialProps, + }); + + const inspect = Template.fromStack(stack); + expect(inspect.allResources('Foo::Bar', { Properties: partialProps })); + }); + + test('no resources match', (done) => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.allResources('Foo::Bar', { Properties: { fred: 'waldo' } }), + [ + 'Template has 2 resource(s) with type Foo::Bar, but none match as expected.', + 'The following resources do not match the given definition:', + /Foo/, + /Foo2/, + ], + done, + ); + done(); + }); + + test('some resources match', (done) => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.allResources('Foo::Bar', { Properties: { lorem: 'ipsum' } }), + [ + 'Template has 2 resource(s) with type Foo::Bar, but only 1 match as expected.', + 'The following resources do not match the given definition:', + /Foo2/, + ], + done, + ); + done(); + }); + + test('using a "not" matcher ', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: { baz: 'baz' }, + }); + + const inspect = Template.fromStack(stack); + expect(inspect.allResources('Foo::Bar', Match.not({ Properties: { baz: 'qux' } }))); + }); + }); + + describe('allResourcesProperties', () => { + test('all resource of type match', () => { + const stack = new Stack(); + const partialProps = { baz: 'qux', fred: 'waldo' }; + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { ...partialProps, lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: partialProps, + }); + + const inspect = Template.fromStack(stack); + expect(inspect.allResourcesProperties('Foo::Bar', partialProps)); + }); + + test('no resources match', (done) => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + new CfnResource(stack, 'NotFoo', { + type: 'NotFoo::NotBar', + properties: { fred: 'waldo' }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.allResourcesProperties('Foo::Bar', { fred: 'waldo' }), + [ + 'Template has 2 resource(s) with type Foo::Bar, but none match as expected.', + 'The following resources do not match the given definition:', + /Foo/, + /Foo2/, + ], + done, + ); + done(); + }); + + test('some resources match', (done) => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.allResourcesProperties('Foo::Bar', { lorem: 'ipsum' }), + [ + 'Template has 2 resource(s) with type Foo::Bar, but only 1 match as expected.', + 'The following resources do not match the given definition:', + /Foo2/, + ], + done, + ); + done(); + }); + + test('using a "not" matcher ', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { lorem: 'ipsum' }, + }); + new CfnResource(stack, 'Foo2', { + type: 'Foo::Bar', + properties: { baz: 'baz' }, + }); + + const inspect = Template.fromStack(stack); + expect(inspect.allResourcesProperties('Foo::Bar', Match.not({ baz: 'qux' }))); + }); + }); + describe('hasOutput', () => { test('matching', () => { const stack = new Stack();