diff --git a/packages/@aws-cdk/assertions/lib/match.ts b/packages/@aws-cdk/assertions/lib/match.ts index 8e4d83a398347..70ad96dbee300 100644 --- a/packages/@aws-cdk/assertions/lib/match.ts +++ b/packages/@aws-cdk/assertions/lib/match.ts @@ -1,4 +1,5 @@ import { Matcher, MatchResult } from './matcher'; +import { AbsentMatch } from './private/matchers/absent'; import { getType } from './private/type'; /** @@ -329,17 +330,3 @@ class AnyMatch extends Matcher { return result; } } - -class AbsentMatch extends Matcher { - constructor(public readonly name: string) { - super(); - } - - public test(actual: any): MatchResult { - const result = new MatchResult(actual); - if (actual !== undefined) { - result.push(this, [], `Received ${actual}, but key should be absent`); - } - return result; - } -} \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/lib/private/matchers/absent.ts b/packages/@aws-cdk/assertions/lib/private/matchers/absent.ts new file mode 100644 index 0000000000000..0681f8ada8214 --- /dev/null +++ b/packages/@aws-cdk/assertions/lib/private/matchers/absent.ts @@ -0,0 +1,15 @@ +import { Matcher, MatchResult } from '../../matcher'; + +export class AbsentMatch extends Matcher { + constructor(public readonly name: string) { + super(); + } + + public test(actual: any): MatchResult { + const result = new MatchResult(actual); + if (actual !== undefined) { + result.push(this, [], `Received ${actual}, but key should be absent`); + } + return result; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/lib/private/resources.ts b/packages/@aws-cdk/assertions/lib/private/resources.ts index aeb2037d81ad4..68e8e6c2ddff8 100644 --- a/packages/@aws-cdk/assertions/lib/private/resources.ts +++ b/packages/@aws-cdk/assertions/lib/private/resources.ts @@ -1,3 +1,5 @@ +import { Match, Matcher } from '..'; +import { AbsentMatch } from './matchers/absent'; import { formatFailure, matchSection } from './section'; import { Resource, Template } from './template'; @@ -15,7 +17,6 @@ export function findResources(template: Template, type: string, props: any = {}) export function hasResource(template: Template, type: string, props: any): string | void { const section = template.Resources; const result = matchSection(filterType(section, type), props); - if (result.match) { return; } @@ -30,6 +31,20 @@ export function hasResource(template: Template, type: string, props: any): strin ].join('\n'); } +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)); + + // special case to exclude AbsentMatch because adding an empty Properties object will affect its evaluation. + if (!Matcher.isMatcher(props) || !(props instanceof AbsentMatch)) { + amended = addEmptyProperties(amended); + } + + return hasResource(amended, type, Match.objectLike({ + Properties: props, + })); +} + export function countResources(template: Template, type: string): number { const section = template.Resources; const types = filterType(section, type); @@ -37,6 +52,18 @@ export function countResources(template: Template, type: string): number { return Object.entries(types).length; } +function addEmptyProperties(template: Template): Template { + let section = template.Resources; + + Object.keys(section).map((key) => { + if (!section[key].hasOwnProperty('Properties')) { + section[key].Properties = {}; + } + }); + + return template; +} + function filterType(section: { [key: string]: Resource }, type: string): { [key: string]: Resource } { return Object.entries(section ?? {}) .filter(([_, v]) => v.Type === type) diff --git a/packages/@aws-cdk/assertions/lib/template.ts b/packages/@aws-cdk/assertions/lib/template.ts index d3871c6cda36b..01e0d3376dc8c 100644 --- a/packages/@aws-cdk/assertions/lib/template.ts +++ b/packages/@aws-cdk/assertions/lib/template.ts @@ -3,7 +3,7 @@ import { Match } from './match'; import { Matcher } from './matcher'; import { findMappings, hasMapping } from './private/mappings'; import { findOutputs, hasOutput } from './private/outputs'; -import { countResources, findResources, hasResource } from './private/resources'; +import { countResources, findResources, hasResource, hasResourceProperties } from './private/resources'; import { Template as TemplateType } from './private/template'; /** @@ -74,9 +74,10 @@ export class Template { * @param props the 'Properties' section of the resource as should be expected in the template. */ public hasResourceProperties(type: string, props: any): void { - this.hasResource(type, Match.objectLike({ - Properties: Matcher.isMatcher(props) ? props : Match.objectLike(props), - })); + const matchError = hasResourceProperties(this.template, type, props); + if (matchError) { + throw new Error(matchError); + } } /** diff --git a/packages/@aws-cdk/assertions/test/template.test.ts b/packages/@aws-cdk/assertions/test/template.test.ts index 92f169488fd69..3384cda21207f 100644 --- a/packages/@aws-cdk/assertions/test/template.test.ts +++ b/packages/@aws-cdk/assertions/test/template.test.ts @@ -270,7 +270,24 @@ describe('Template', () => { }); describe('hasResourceProperties', () => { - test('absent', () => { + test('exact match', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + properties: { baz: 'qux' }, + }); + + const inspect = Template.fromStack(stack); + inspect.hasResourceProperties('Foo::Bar', { baz: 'qux' }); + + expect(() => inspect.hasResourceProperties('Foo::Bar', { baz: 'waldo' })) + .toThrow(/Expected waldo but received qux at \/Properties\/baz/); + + expect(() => inspect.hasResourceProperties('Foo::Bar', { baz: 'qux', fred: 'waldo' })) + .toThrow(/Missing key at \/Properties\/fred/); + }); + + test('absent - with properties', () => { const stack = new Stack(); new CfnResource(stack, 'Foo', { type: 'Foo::Bar', @@ -278,25 +295,31 @@ describe('Template', () => { }); const inspect = Template.fromStack(stack); + inspect.hasResourceProperties('Foo::Bar', { bar: Match.absent(), }); + expect(() => inspect.hasResourceProperties('Foo::Bar', { baz: Match.absent(), })).toThrow(/key should be absent at \/Properties\/baz/); }); - test('absent - no properties on template', () => { + test('absent - no properties', () => { const stack = new Stack(); new CfnResource(stack, 'Foo', { type: 'Foo::Bar', }); const inspect = Template.fromStack(stack); + + expect(() => inspect.hasResourceProperties('Foo::Bar', { bar: Match.absent(), baz: 'qux' })) + .toThrow(/Missing key at \/Properties\/baz/); + inspect.hasResourceProperties('Foo::Bar', Match.absent()); }); - test('not', () => { + test('not - with properties', () => { const stack = new Stack(); new CfnResource(stack, 'Foo', { type: 'Foo::Bar', @@ -308,6 +331,16 @@ describe('Template', () => { baz: 'boo', })); }); + + test('not - no properties', () => { + const stack = new Stack(); + new CfnResource(stack, 'Foo', { + type: 'Foo::Bar', + }); + + const inspect = Template.fromStack(stack); + inspect.hasResourceProperties('Foo::Bar', Match.not({ baz: 'qux' })); + }); }); describe('getResources', () => {