Skip to content

Commit

Permalink
feat(assertions): added getResourceId method to Template
Browse files Browse the repository at this point in the history
  • Loading branch information
Dahlberg Victor committed Feb 20, 2025
1 parent ff9fc77 commit 2812d88
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 2 deletions.
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

0 comments on commit 2812d88

Please sign in to comment.