Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(assertions): allResources and allResourcesProperties methods #22007

Merged
merged 11 commits into from
Nov 2, 2022
25 changes: 23 additions & 2 deletions packages/@aws-cdk/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,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' ],
});
Expand All @@ -108,7 +119,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' ],
});
```
Expand Down
38 changes: 37 additions & 1 deletion packages/@aws-cdk/assertions/lib/private/resources.ts
Original file line number Diff line number Diff line change
@@ -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 } } {
Expand All @@ -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);
Expand Down
25 changes: 16 additions & 9 deletions packages/@aws-cdk/assertions/lib/private/section.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,61 @@
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 };
Comment on lines +4 to +5
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we can technically remove analyzedCount here, but I kept it since it's being used in other places and convenient to use.

If we want I can remove this field and update the other places to use Object.keys(analyzed).length


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;
}
}
},
);
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];
cb(logicalId, resource);
}
}

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:',
Expand Down
36 changes: 33 additions & 3 deletions packages/@aws-cdk/assertions/lib/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -31,7 +31,7 @@ export class Template {
* JSON object.
* @param template the CloudFormation template formatted as a nested set of records
*/
public static fromJSON(template: { [key: string] : any }): Template {
public static fromJSON(template: { [key: string]: any }): Template {
return new Template(template);
}

Expand Down Expand Up @@ -106,7 +106,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);
Expand All @@ -126,6 +126,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()`.
Expand Down
168 changes: 168 additions & 0 deletions packages/@aws-cdk/assertions/test/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down