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): added getResourceId method to Template #33521

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/aws-cdk-lib/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,36 @@ Beyond assertions, the module provides APIs to retrieve matching resources.
The `findResources()` API is complementary to the `hasResource()` API, except,
instead of asserting its presence, it returns the set of matching resources.

Similarly, the `getResourceId()` API is complementary to the `findResources()` API,
except it expects only one matching resource, and returns the matched resource's resource id.
Useful for asserting that certain cloudformation resources correlate expectedly.

```ts
// Assert that a certain bucket denies unsecure communication
const bucket = template.getResourceId('AWS::S3::Bucket', {
Properties: {
BucketName: 'my-bucket',
}
})

template.hasResourceProperties('AWS::S3::BucketPolicy', {
Bucket: {
Ref: bucket,
},
PolicyDocument: {
Statement: [
{
Effect: 'Deny',
Action: 's3:*',
Principal: { AWS: '*' },
Condition: { Bool: { 'aws:SecureTransport': 'false' } },
},
],
}
})

```

By default, the `hasResource()` and `hasResourceProperties()` APIs perform deep
partial object matching. This behavior can be configured using matchers.
See subsequent section on [special matchers](#special-matchers).
Expand Down
26 changes: 25 additions & 1 deletion packages/aws-cdk-lib/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 { formatAllMismatches, matchSection, formatSectionMatchFailure } from './section';
import { formatAllMismatches, matchSection, formatSectionMatchFailure, formatAllMatches } 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,30 @@ export function findResources(template: Template, type: string, props: any = {})
return result.matches;
}

export function getResourceId(template: Template, type: string, props: any = {}): { resourceId?: string; matchError?: string } {
const section = template.Resources ?? {};
const result = matchSection(filterType(section, type), props);

if (!result.match) {
return {
matchError: formatSectionMatchFailure(`resources with type ${type}`, result),
};
}

const resourceIds = Object.keys(result.matches);

if (resourceIds.length !== 1) {
return {
matchError: [
`Template has ${resourceIds.length} matches, expected only one.`,
formatAllMatches(result.matches),
].join('\n'),
};
}

return { resourceId: resourceIds[0] };
}

export function allResources(template: Template, type: string, props: any): string | void {
const section = template.Resources ?? {};
const result = matchSection(filterType(section, type), props);
Expand Down
24 changes: 23 additions & 1 deletion packages/aws-cdk-lib/assertions/lib/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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 { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, hasResource, hasResourceProperties } from './private/resources';
import { allResources, allResourcesProperties, countResources, countResourcesProperties, findResources, getResourceId, hasResource, hasResourceProperties } from './private/resources';
import { Template as TemplateType } from './private/template';
import { Stack, Stage } from '../../core';
import { AssertionError } from './private/error';
Expand Down Expand Up @@ -134,6 +134,28 @@ export class Template {
return findResources(this.template, type, props);
}

/**
* Get the Resource ID of a matching resource, expects only to find one match.
* Throws AssertionError if none or multiple resources were found.
* @param type the resource type; ex: `AWS::S3::Bucket`
* @param props by default, matches all resources with the given type.
* @returns The resource id of the matched resource.
* Performs a partial match via `Match.objectLike()`.
*/
public getResourceId(type: string, props: any = {}): string {
const { resourceId, matchError } = getResourceId(this.template, type, props);

if (matchError) {
throw new AssertionError(matchError);
}

if (!resourceId) {
throw new AssertionError('unexpected: resourceId was undefined');
}

return resourceId;
}

/**
* Assert that all resources of the given type contain the given definition in the
* CloudFormation template.
Expand Down
108 changes: 108 additions & 0 deletions packages/aws-cdk-lib/assertions/test/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,114 @@ describe('Template', () => {
});
});

describe('getResourceId', () => {
test('matching resource type', () => {
const stack = new Stack();
new CfnResource(stack, 'Foo', {
type: 'Foo::Bar',
properties: { baz: 'qux', fred: 'waldo' },
});

const inspect = Template.fromStack(stack);
expect(inspect.getResourceId('Foo::Bar')).toEqual('Foo');
});

test('no matching resource type', () => {
const stack = new Stack();
new CfnResource(stack, 'Foo', {
type: 'Foo::Baz',
properties: { baz: 'qux', fred: 'waldo' },
});

const inspect = Template.fromStack(stack);
expectToThrow(
() => inspect.getResourceId('Foo::Bar'),
['Template has 0 resources with type Foo::Bar'],
);
});

test('matching resource props', () => {
const stack = new Stack();
new CfnResource(stack, 'Foo', {
type: 'Foo::Bar',
properties: { baz: 'qux', fred: 'waldo' },
});

const inspect = Template.fromStack(stack);
expect(inspect.getResourceId('Foo::Bar', { Properties: { fred: 'waldo' } })).toEqual('Foo');
});

test('no matching resource props', () => {
const stack = new Stack();
new CfnResource(stack, 'Foo', {
type: 'Foo::Bar',
properties: { baz: 'qux', fred: 'waldo' },
});

const inspect = Template.fromStack(stack);
expectToThrow(
() => inspect.getResourceId('Foo::Bar', { Properties: { baz: 'quz' } }),
[
'Template has 1 resources with type Foo::Bar, but none match as expected',
/Expected quz but received qux/,
],
);
});

test('multiple matching resources', () => {
const stack = new Stack();
new CfnResource(stack, 'Foo', { type: 'Foo::Bar' });
new CfnResource(stack, 'Bar', { type: 'Foo::Bar' });

const inspect = Template.fromStack(stack);
expectToThrow(
() => inspect.getResourceId('Foo::Bar'),
[
'Template has 2 matches, expected only one.',
/Foo/, /Foo::Bar/,
/Bar/, /Foo::Bar/,
],
);
});

test('multiple matching resources props', () => {
const stack = new Stack();
new CfnResource(stack, 'Foo', {
type: 'Foo::Bar',
properties: { baz: 'qux', fred: 'waldo' },
});
new CfnResource(stack, 'Bar', {
type: 'Foo::Bar',
properties: { baz: 'qux', fred: 'waldo' },
});

const inspect = Template.fromStack(stack);
expectToThrow(
() => inspect.getResourceId('Foo::Bar', { Properties: { baz: 'qux' } }),
[
'Template has 2 matches, expected only one.',
/Foo/, /Foo::Bar/,
/Bar/, /Foo::Bar/,
],
);
});

test('multiple resources only one match', () => {
const stack = new Stack();
new CfnResource(stack, 'Foo', {
type: 'Foo::Bar',
properties: { baz: 'qux', fred: 'waldo' },
});
new CfnResource(stack, 'Bar', {
type: 'Foo::Bar',
properties: { bax: 'qux', fred: 'waldo' },
});

const inspect = Template.fromStack(stack);
expect(inspect.getResourceId('Foo::Bar', { Properties: { bax: 'qux' } })).toEqual('Bar');
});
});

describe('allResources', () => {
test('all resource of type match', () => {
const stack = new Stack();
Expand Down