Skip to content

Commit a96b0f1

Browse files
authored
feat(assertions): added getResourceId method to Template (#33521)
### Reason for this change Sometimes you want to correlate how cloudformation resources correlate to each other. CDK synthesizes the template expectedly with `Ref` and `Fn:GetAtt`. Currently you'll have to do something like this to verify that a bucketpolicy is attached to the correct bucket: ```ts const resources = template.findResources('AWS::S3::Bucket', { Properties: { BucketName: 'my-bucket', } }) const keys = Object.keys(resources) if (keys.length === 0) { throw new Error('Resource not found.') } if (keys.length !== 1) { throw new Error('Resource is not unique.') } const bucket = keys[0] template.hasResourceProperties('AWS::S3::BucketPolicy', { Bucket: { Ref: bucket, }, // .... }) ``` ### Description of changes Added method `getResourceId` on `Template` to retrieve a distinct match's resource id. ```ts // throws AssertionError on none or multiple matches const bucket = template.getResourceId('AWS::S3::Bucket', { Properties: { BucketName: 'my-bucket', } }) template.hasResourceProperties('AWS::S3::BucketPolicy', { Bucket: { Ref: bucket, }, // .... }) ``` ### Description of how you validated changes Unit tests. Integration tests not applicable. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 92dba49 commit a96b0f1

File tree

4 files changed

+186
-2
lines changed

4 files changed

+186
-2
lines changed

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

+30
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,36 @@ Beyond assertions, the module provides APIs to retrieve matching resources.
135135
The `findResources()` API is complementary to the `hasResource()` API, except,
136136
instead of asserting its presence, it returns the set of matching resources.
137137

138+
Similarly, the `getResourceId()` API is complementary to the `findResources()` API,
139+
except it expects only one matching resource, and returns the matched resource's resource id.
140+
Useful for asserting that certain cloudformation resources correlate expectedly.
141+
142+
```ts
143+
// Assert that a certain bucket denies unsecure communication
144+
const bucket = template.getResourceId('AWS::S3::Bucket', {
145+
Properties: {
146+
BucketName: 'my-bucket',
147+
}
148+
})
149+
150+
template.hasResourceProperties('AWS::S3::BucketPolicy', {
151+
Bucket: {
152+
Ref: bucket,
153+
},
154+
PolicyDocument: {
155+
Statement: [
156+
{
157+
Effect: 'Deny',
158+
Action: 's3:*',
159+
Principal: { AWS: '*' },
160+
Condition: { Bool: { 'aws:SecureTransport': 'false' } },
161+
},
162+
],
163+
}
164+
})
165+
166+
```
167+
138168
By default, the `hasResource()` and `hasResourceProperties()` APIs perform deep
139169
partial object matching. This behavior can be configured using matchers.
140170
See subsequent section on [special matchers](#special-matchers).

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

+25-1
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 { formatAllMismatches, matchSection, formatSectionMatchFailure } from './section';
3+
import { formatAllMismatches, matchSection, formatSectionMatchFailure, formatAllMatches } 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,30 @@ export function findResources(template: Template, type: string, props: any = {})
1414
return result.matches;
1515
}
1616

17+
export function getResourceId(template: Template, type: string, props: any = {}): { resourceId?: string; matchError?: string } {
18+
const section = template.Resources ?? {};
19+
const result = matchSection(filterType(section, type), props);
20+
21+
if (!result.match) {
22+
return {
23+
matchError: formatSectionMatchFailure(`resources with type ${type}`, result),
24+
};
25+
}
26+
27+
const resourceIds = Object.keys(result.matches);
28+
29+
if (resourceIds.length !== 1) {
30+
return {
31+
matchError: [
32+
`Template has ${resourceIds.length} matches, expected only one.`,
33+
formatAllMatches(result.matches),
34+
].join('\n'),
35+
};
36+
}
37+
38+
return { resourceId: resourceIds[0] };
39+
}
40+
1741
export function allResources(template: Template, type: string, props: any): string | void {
1842
const section = template.Resources ?? {};
1943
const result = matchSection(filterType(section, type), props);

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

+23-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { checkTemplateForCyclicDependencies } from './private/cyclic';
77
import { findMappings, hasMapping } from './private/mappings';
88
import { findOutputs, hasOutput } from './private/outputs';
99
import { findParameters, hasParameter } from './private/parameters';
10-
import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources';
10+
import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, getResourceId, hasResource, hasResourceProperties } from './private/resources';
1111
import { Template as TemplateType } from './private/template';
1212
import { Stack, Stage } from '../../core';
1313
import { AssertionError } from './private/error';
@@ -134,6 +134,28 @@ export class Template {
134134
return findResources(this.template, type, props);
135135
}
136136

137+
/**
138+
* Get the Resource ID of a matching resource, expects only to find one match.
139+
* Throws AssertionError if none or multiple resources were found.
140+
* @param type the resource type; ex: `AWS::S3::Bucket`
141+
* @param props by default, matches all resources with the given type.
142+
* @returns The resource id of the matched resource.
143+
* Performs a partial match via `Match.objectLike()`.
144+
*/
145+
public getResourceId(type: string, props: any = {}): string {
146+
const { resourceId, matchError } = getResourceId(this.template, type, props);
147+
148+
if (matchError) {
149+
throw new AssertionError(matchError);
150+
}
151+
152+
if (!resourceId) {
153+
throw new AssertionError('unexpected: resourceId was undefined');
154+
}
155+
156+
return resourceId;
157+
}
158+
137159
/**
138160
* Assert that all resources of the given type contain the given definition in the
139161
* CloudFormation template.

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

+108
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,114 @@ describe('Template', () => {
561561
});
562562
});
563563

564+
describe('getResourceId', () => {
565+
test('matching resource type', () => {
566+
const stack = new Stack();
567+
new CfnResource(stack, 'Foo', {
568+
type: 'Foo::Bar',
569+
properties: { baz: 'qux', fred: 'waldo' },
570+
});
571+
572+
const inspect = Template.fromStack(stack);
573+
expect(inspect.getResourceId('Foo::Bar')).toEqual('Foo');
574+
});
575+
576+
test('no matching resource type', () => {
577+
const stack = new Stack();
578+
new CfnResource(stack, 'Foo', {
579+
type: 'Foo::Baz',
580+
properties: { baz: 'qux', fred: 'waldo' },
581+
});
582+
583+
const inspect = Template.fromStack(stack);
584+
expectToThrow(
585+
() => inspect.getResourceId('Foo::Bar'),
586+
['Template has 0 resources with type Foo::Bar'],
587+
);
588+
});
589+
590+
test('matching resource props', () => {
591+
const stack = new Stack();
592+
new CfnResource(stack, 'Foo', {
593+
type: 'Foo::Bar',
594+
properties: { baz: 'qux', fred: 'waldo' },
595+
});
596+
597+
const inspect = Template.fromStack(stack);
598+
expect(inspect.getResourceId('Foo::Bar', { Properties: { fred: 'waldo' } })).toEqual('Foo');
599+
});
600+
601+
test('no matching resource props', () => {
602+
const stack = new Stack();
603+
new CfnResource(stack, 'Foo', {
604+
type: 'Foo::Bar',
605+
properties: { baz: 'qux', fred: 'waldo' },
606+
});
607+
608+
const inspect = Template.fromStack(stack);
609+
expectToThrow(
610+
() => inspect.getResourceId('Foo::Bar', { Properties: { baz: 'quz' } }),
611+
[
612+
'Template has 1 resources with type Foo::Bar, but none match as expected',
613+
/Expected quz but received qux/,
614+
],
615+
);
616+
});
617+
618+
test('multiple matching resources', () => {
619+
const stack = new Stack();
620+
new CfnResource(stack, 'Foo', { type: 'Foo::Bar' });
621+
new CfnResource(stack, 'Bar', { type: 'Foo::Bar' });
622+
623+
const inspect = Template.fromStack(stack);
624+
expectToThrow(
625+
() => inspect.getResourceId('Foo::Bar'),
626+
[
627+
'Template has 2 matches, expected only one.',
628+
/Foo/, /Foo::Bar/,
629+
/Bar/, /Foo::Bar/,
630+
],
631+
);
632+
});
633+
634+
test('multiple matching resources props', () => {
635+
const stack = new Stack();
636+
new CfnResource(stack, 'Foo', {
637+
type: 'Foo::Bar',
638+
properties: { baz: 'qux', fred: 'waldo' },
639+
});
640+
new CfnResource(stack, 'Bar', {
641+
type: 'Foo::Bar',
642+
properties: { baz: 'qux', fred: 'waldo' },
643+
});
644+
645+
const inspect = Template.fromStack(stack);
646+
expectToThrow(
647+
() => inspect.getResourceId('Foo::Bar', { Properties: { baz: 'qux' } }),
648+
[
649+
'Template has 2 matches, expected only one.',
650+
/Foo/, /Foo::Bar/,
651+
/Bar/, /Foo::Bar/,
652+
],
653+
);
654+
});
655+
656+
test('multiple resources only one match', () => {
657+
const stack = new Stack();
658+
new CfnResource(stack, 'Foo', {
659+
type: 'Foo::Bar',
660+
properties: { baz: 'qux', fred: 'waldo' },
661+
});
662+
new CfnResource(stack, 'Bar', {
663+
type: 'Foo::Bar',
664+
properties: { bax: 'qux', fred: 'waldo' },
665+
});
666+
667+
const inspect = Template.fromStack(stack);
668+
expect(inspect.getResourceId('Foo::Bar', { Properties: { bax: 'qux' } })).toEqual('Bar');
669+
});
670+
});
671+
564672
describe('allResources', () => {
565673
test('all resource of type match', () => {
566674
const stack = new Stack();

0 commit comments

Comments
 (0)