Skip to content

Commit 2430537

Browse files
authored
feat(assertions): allResources and allResourcesProperties methods (#22007)
Closes #21269 ---- ### 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*
1 parent 29254fe commit 2430537

File tree

5 files changed

+276
-14
lines changed

5 files changed

+276
-14
lines changed

packages/@aws-cdk/assertions/README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,18 @@ The following code asserts that the `Properties` section of a resource of type
102102

103103
```ts
104104
template.hasResourceProperties('Foo::Bar', {
105-
Foo: 'Bar',
105+
Lorem: 'Ipsum',
106+
Baz: 5,
107+
Qux: [ 'Waldo', 'Fred' ],
108+
});
109+
```
110+
111+
You can also assert that the `Properties` section of all resources of type
112+
`Foo::Bar` contains the specified properties -
113+
114+
```ts
115+
template.allResourcesProperties('Foo::Bar', {
116+
Lorem: 'Ipsum',
106117
Baz: 5,
107118
Qux: [ 'Waldo', 'Fred' ],
108119
});
@@ -113,7 +124,17 @@ can use the `hasResource()` API.
113124

114125
```ts
115126
template.hasResource('Foo::Bar', {
116-
Properties: { Foo: 'Bar' },
127+
Properties: { Lorem: 'Ipsum' },
128+
DependsOn: [ 'Waldo', 'Fred' ],
129+
});
130+
```
131+
132+
You can also assert the definitions of all resources of a type using the
133+
`allResources()` API.
134+
135+
```ts
136+
template.allResources('Foo::Bar', {
137+
Properties: { Lorem: 'Ipsum' },
117138
DependsOn: [ 'Waldo', 'Fred' ],
118139
});
119140
```

packages/@aws-cdk/assertions/lib/private/resources.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Match, Matcher } from '..';
22
import { AbsentMatch } from './matchers/absent';
3-
import { formatFailure, matchSection } from './section';
3+
import { formatAllMismatches, formatFailure, matchSection } from './section';
44
import { Resource, Template } from './template';
55

66
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 = {})
1414
return result.matches;
1515
}
1616

17+
export function allResources(template: Template, type: string, props: any): string | void {
18+
const section = template.Resources ?? {};
19+
const result = matchSection(filterType(section, type), props);
20+
if (result.match) {
21+
const matchCount = Object.keys(result.matches).length;
22+
if (result.analyzedCount > matchCount) {
23+
return [
24+
`Template has ${result.analyzedCount} resource(s) with type ${type}, but only ${matchCount} match as expected.`,
25+
formatAllMismatches(result.analyzed, result.matches),
26+
].join('\n');
27+
}
28+
} else {
29+
return [
30+
`Template has ${result.analyzedCount} resource(s) with type ${type}, but none match as expected.`,
31+
formatAllMismatches(result.analyzed),
32+
].join('\n');
33+
}
34+
}
35+
36+
export function allResourcesProperties(template: Template, type: string, props: any): string | void {
37+
let amended = template;
38+
39+
// special case to exclude AbsentMatch because adding an empty Properties object will affect its evaluation.
40+
if (!Matcher.isMatcher(props) || !(props instanceof AbsentMatch)) {
41+
// amended needs to be a deep copy to avoid modifying the template.
42+
amended = JSON.parse(JSON.stringify(template));
43+
amended = addEmptyProperties(amended);
44+
}
45+
46+
return allResources(amended, type, Match.objectLike({
47+
Properties: props,
48+
}));
49+
50+
}
51+
52+
1753
export function hasResource(template: Template, type: string, props: any): string | void {
1854
const section = template.Resources ?? {};
1955
const result = matchSection(filterType(section, type), props);

packages/@aws-cdk/assertions/lib/private/section.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,61 @@
11
import { Match } from '../match';
22
import { Matcher, MatchResult } from '../matcher';
33

4-
export type MatchSuccess = { match: true, matches: {[key: string]: any} };
5-
export type MatchFailure = { match: false, closestResult?: MatchResult, analyzedCount: number };
4+
export type MatchSuccess = { match: true, matches: { [key: string]: any }, analyzed: { [key: string]: any }, analyzedCount: number };
5+
export type MatchFailure = { match: false, closestResult?: MatchResult, analyzed: { [key: string]: any }, analyzedCount: number };
66

77
export function matchSection(section: any, props: any): MatchSuccess | MatchFailure {
88
const matcher = Matcher.isMatcher(props) ? props : Match.objectLike(props);
99
let closestResult: MatchResult | undefined = undefined;
10-
let matching: {[key: string]: any} = {};
11-
let count = 0;
10+
let matching: { [key: string]: any } = {};
11+
let analyzed: { [key: string]: any } = {};
1212

1313
eachEntryInSection(
1414
section,
1515

1616
(logicalId, entry) => {
17+
analyzed[logicalId] = entry;
1718
const result = matcher.test(entry);
1819
result.finished();
1920
if (!result.hasFailed()) {
2021
matching[logicalId] = entry;
2122
} else {
22-
count++;
2323
if (closestResult === undefined || closestResult.failCount > result.failCount) {
2424
closestResult = result;
2525
}
2626
}
2727
},
2828
);
2929
if (Object.keys(matching).length > 0) {
30-
return { match: true, matches: matching };
30+
return { match: true, matches: matching, analyzedCount: Object.keys(analyzed).length, analyzed: analyzed };
3131
} else {
32-
return { match: false, closestResult, analyzedCount: count };
32+
return { match: false, closestResult, analyzedCount: Object.keys(analyzed).length, analyzed: analyzed };
3333
}
3434
}
3535

3636
function eachEntryInSection(
3737
section: any,
38-
cb: (logicalId: string, entry: {[key: string]: any}) => void): void {
38+
cb: (logicalId: string, entry: { [key: string]: any }) => void): void {
3939

4040
for (const logicalId of Object.keys(section ?? {})) {
4141
const resource: { [key: string]: any } = section[logicalId];
4242
cb(logicalId, resource);
4343
}
4444
}
4545

46-
export function formatAllMatches(matches: {[key: string]: any}): string {
46+
export function formatAllMatches(matches: { [key: string]: any }): string {
4747
return [
4848
leftPad(JSON.stringify(matches, undefined, 2)),
4949
].join('\n');
5050
}
5151

52+
export function formatAllMismatches(analyzed: { [key: string]: any }, matches: { [key: string]: any } = {}): string {
53+
return [
54+
'The following resources do not match the given definition:',
55+
...Object.keys(analyzed).filter(id => !(id in matches)).map(id => `\t${id}`),
56+
].join('\n');
57+
}
58+
5259
export function formatFailure(closestResult: MatchResult): string {
5360
return [
5461
'The closest result is:',

packages/@aws-cdk/assertions/lib/template.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { checkTemplateForCyclicDependencies } from './private/cyclic';
88
import { findMappings, hasMapping } from './private/mappings';
99
import { findOutputs, hasOutput } from './private/outputs';
1010
import { findParameters, hasParameter } from './private/parameters';
11-
import { countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources';
11+
import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources';
1212
import { Template as TemplateType } from './private/template';
1313

1414
/**
@@ -114,7 +114,7 @@ export class Template {
114114
* By default, performs partial matching on the resource, via the `Match.objectLike()`.
115115
* To configure different behavour, use other matchers in the `Match` class.
116116
* @param type the resource type; ex: `AWS::S3::Bucket`
117-
* @param props the entire defintion of the resource as should be expected in the template.
117+
* @param props the entire definition of the resource as should be expected in the template.
118118
*/
119119
public hasResource(type: string, props: any): void {
120120
const matchError = hasResource(this.template, type, props);
@@ -134,6 +134,36 @@ export class Template {
134134
return findResources(this.template, type, props);
135135
}
136136

137+
/**
138+
* Assert that all resources of the given type contain the given definition in the
139+
* CloudFormation template.
140+
* By default, performs partial matching on the resource, via the `Match.objectLike()`.
141+
* To configure different behavour, use other matchers in the `Match` class.
142+
* @param type the resource type; ex: `AWS::S3::Bucket`
143+
* @param props the entire definition of the resources as they should be expected in the template.
144+
*/
145+
public allResources(type: string, props: any): void {
146+
const matchError = allResources(this.template, type, props);
147+
if (matchError) {
148+
throw new Error(matchError);
149+
}
150+
}
151+
152+
/**
153+
* Assert that all resources of the given type contain the given properties
154+
* CloudFormation template.
155+
* By default, performs partial matching on the `Properties` key of the resource, via the
156+
* `Match.objectLike()`. To configure different behavour, use other matchers in the `Match` class.
157+
* @param type the resource type; ex: `AWS::S3::Bucket`
158+
* @param props the 'Properties' section of the resource as should be expected in the template.
159+
*/
160+
public allResourcesProperties(type: string, props: any): void {
161+
const matchError = allResourcesProperties(this.template, type, props);
162+
if (matchError) {
163+
throw new Error(matchError);
164+
}
165+
}
166+
137167
/**
138168
* Assert that a Parameter with the given properties exists in the CloudFormation template.
139169
* By default, performs partial matching on the parameter, via the `Match.objectLike()`.

packages/@aws-cdk/assertions/test/template.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,174 @@ describe('Template', () => {
553553
});
554554
});
555555

556+
describe('allResources', () => {
557+
test('all resource of type match', () => {
558+
const stack = new Stack();
559+
const partialProps = { baz: 'qux', fred: 'waldo' };
560+
new CfnResource(stack, 'Foo', {
561+
type: 'Foo::Bar',
562+
properties: { ...partialProps, lorem: 'ipsum' },
563+
});
564+
new CfnResource(stack, 'Foo2', {
565+
type: 'Foo::Bar',
566+
properties: partialProps,
567+
});
568+
569+
const inspect = Template.fromStack(stack);
570+
expect(inspect.allResources('Foo::Bar', { Properties: partialProps }));
571+
});
572+
573+
test('no resources match', (done) => {
574+
const stack = new Stack();
575+
new CfnResource(stack, 'Foo', {
576+
type: 'Foo::Bar',
577+
properties: { lorem: 'ipsum' },
578+
});
579+
new CfnResource(stack, 'Foo2', {
580+
type: 'Foo::Bar',
581+
properties: { baz: 'qux' },
582+
});
583+
584+
const inspect = Template.fromStack(stack);
585+
expectToThrow(
586+
() => inspect.allResources('Foo::Bar', { Properties: { fred: 'waldo' } }),
587+
[
588+
'Template has 2 resource(s) with type Foo::Bar, but none match as expected.',
589+
'The following resources do not match the given definition:',
590+
/Foo/,
591+
/Foo2/,
592+
],
593+
done,
594+
);
595+
done();
596+
});
597+
598+
test('some resources match', (done) => {
599+
const stack = new Stack();
600+
new CfnResource(stack, 'Foo', {
601+
type: 'Foo::Bar',
602+
properties: { lorem: 'ipsum' },
603+
});
604+
new CfnResource(stack, 'Foo2', {
605+
type: 'Foo::Bar',
606+
properties: { baz: 'qux' },
607+
});
608+
609+
const inspect = Template.fromStack(stack);
610+
expectToThrow(
611+
() => inspect.allResources('Foo::Bar', { Properties: { lorem: 'ipsum' } }),
612+
[
613+
'Template has 2 resource(s) with type Foo::Bar, but only 1 match as expected.',
614+
'The following resources do not match the given definition:',
615+
/Foo2/,
616+
],
617+
done,
618+
);
619+
done();
620+
});
621+
622+
test('using a "not" matcher ', () => {
623+
const stack = new Stack();
624+
new CfnResource(stack, 'Foo', {
625+
type: 'Foo::Bar',
626+
properties: { lorem: 'ipsum' },
627+
});
628+
new CfnResource(stack, 'Foo2', {
629+
type: 'Foo::Bar',
630+
properties: { baz: 'baz' },
631+
});
632+
633+
const inspect = Template.fromStack(stack);
634+
expect(inspect.allResources('Foo::Bar', Match.not({ Properties: { baz: 'qux' } })));
635+
});
636+
});
637+
638+
describe('allResourcesProperties', () => {
639+
test('all resource of type match', () => {
640+
const stack = new Stack();
641+
const partialProps = { baz: 'qux', fred: 'waldo' };
642+
new CfnResource(stack, 'Foo', {
643+
type: 'Foo::Bar',
644+
properties: { ...partialProps, lorem: 'ipsum' },
645+
});
646+
new CfnResource(stack, 'Foo2', {
647+
type: 'Foo::Bar',
648+
properties: partialProps,
649+
});
650+
651+
const inspect = Template.fromStack(stack);
652+
expect(inspect.allResourcesProperties('Foo::Bar', partialProps));
653+
});
654+
655+
test('no resources match', (done) => {
656+
const stack = new Stack();
657+
new CfnResource(stack, 'Foo', {
658+
type: 'Foo::Bar',
659+
properties: { lorem: 'ipsum' },
660+
});
661+
new CfnResource(stack, 'Foo2', {
662+
type: 'Foo::Bar',
663+
properties: { baz: 'qux' },
664+
});
665+
new CfnResource(stack, 'NotFoo', {
666+
type: 'NotFoo::NotBar',
667+
properties: { fred: 'waldo' },
668+
});
669+
670+
const inspect = Template.fromStack(stack);
671+
expectToThrow(
672+
() => inspect.allResourcesProperties('Foo::Bar', { fred: 'waldo' }),
673+
[
674+
'Template has 2 resource(s) with type Foo::Bar, but none match as expected.',
675+
'The following resources do not match the given definition:',
676+
/Foo/,
677+
/Foo2/,
678+
],
679+
done,
680+
);
681+
done();
682+
});
683+
684+
test('some resources match', (done) => {
685+
const stack = new Stack();
686+
new CfnResource(stack, 'Foo', {
687+
type: 'Foo::Bar',
688+
properties: { lorem: 'ipsum' },
689+
});
690+
new CfnResource(stack, 'Foo2', {
691+
type: 'Foo::Bar',
692+
properties: { baz: 'qux' },
693+
});
694+
695+
const inspect = Template.fromStack(stack);
696+
expectToThrow(
697+
() => inspect.allResourcesProperties('Foo::Bar', { lorem: 'ipsum' }),
698+
[
699+
'Template has 2 resource(s) with type Foo::Bar, but only 1 match as expected.',
700+
'The following resources do not match the given definition:',
701+
/Foo2/,
702+
],
703+
done,
704+
);
705+
done();
706+
});
707+
708+
test('using a "not" matcher ', () => {
709+
const stack = new Stack();
710+
new CfnResource(stack, 'Foo', {
711+
type: 'Foo::Bar',
712+
properties: { lorem: 'ipsum' },
713+
});
714+
new CfnResource(stack, 'Foo2', {
715+
type: 'Foo::Bar',
716+
properties: { baz: 'baz' },
717+
});
718+
719+
const inspect = Template.fromStack(stack);
720+
expect(inspect.allResourcesProperties('Foo::Bar', Match.not({ baz: 'qux' })));
721+
});
722+
});
723+
556724
describe('hasOutput', () => {
557725
test('matching', () => {
558726
const stack = new Stack();

0 commit comments

Comments
 (0)