From b8a61a061865a1eba87e8e5ebfb1f4cf9be97db8 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Fri, 20 Oct 2023 15:37:15 +1100 Subject: [PATCH 1/9] feat(assertions): add stack tagging assertions Adds a Tag class to the assertions library that permits assertions against tags on synthesized CDK stacks. --- packages/aws-cdk-lib/assertions/README.md | 34 +++++++ packages/aws-cdk-lib/assertions/lib/index.ts | 3 +- packages/aws-cdk-lib/assertions/lib/tags.ts | 69 ++++++++++++++ .../aws-cdk-lib/assertions/test/tags.test.ts | 95 +++++++++++++++++++ .../rosetta/assertions/default.ts-fixture | 2 +- 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 packages/aws-cdk-lib/assertions/lib/tags.ts create mode 100644 packages/aws-cdk-lib/assertions/test/tags.test.ts diff --git a/packages/aws-cdk-lib/assertions/README.md b/packages/aws-cdk-lib/assertions/README.md index 7c78cd55d514d..d9c2f100f77f0 100644 --- a/packages/aws-cdk-lib/assertions/README.md +++ b/packages/aws-cdk-lib/assertions/README.md @@ -595,3 +595,37 @@ Annotations.fromStack(stack).hasError( Match.stringLikeRegexp('.*Foo::Bar.*'), ); ``` + +## Asserting Stack tags + +Tags applied to a `Stack` are not part of the rendered template: instead, they +are included as properties in the Cloud Assembly Manifest. To test that stacks +are tagged as expected, simple assertions can be written. + +Given the following setup: + +```ts nofixture +import { App, Stack } from 'aws-cdk-lib'; +import { Tags } from 'aws-cdk-lib/assertions'; + +const app = new App(); +const stack = new Stack(app, 'MyStack', { + tags: { + 'tag-name': 'tag-value' + } +}); +``` + +It is possible to test against these values: + +```ts +// using a default 'objectLike' Matcher +Tags.fromStack(stack).hasValues({ + 'tag-name': 'tag-value' +}); + +// or another Matcher +Tags.fromStack(stack).hasValues({ + 'tag-name': Match.anyValue() +}); +``` diff --git a/packages/aws-cdk-lib/assertions/lib/index.ts b/packages/aws-cdk-lib/assertions/lib/index.ts index eccbfac38637f..07abe01428c46 100644 --- a/packages/aws-cdk-lib/assertions/lib/index.ts +++ b/packages/aws-cdk-lib/assertions/lib/index.ts @@ -2,4 +2,5 @@ export * from './capture'; export * from './template'; export * from './match'; export * from './matcher'; -export * from './annotations'; \ No newline at end of file +export * from './annotations'; +export * from './tags'; diff --git a/packages/aws-cdk-lib/assertions/lib/tags.ts b/packages/aws-cdk-lib/assertions/lib/tags.ts new file mode 100644 index 0000000000000..44bce6a79490d --- /dev/null +++ b/packages/aws-cdk-lib/assertions/lib/tags.ts @@ -0,0 +1,69 @@ +import { Match } from './match'; +import { Matcher } from './matcher'; +import { Stack, Stage } from '../../core'; + +type ManifestTags = { [key: string]: string }; + +/** + * Allows assertions on the tags associated with a synthesized CDK stack's + * manifest. Stack tags are not part of the synthesized template, so can only be + * checked from the manifest in this manner. + */ +export class Tags { + /** + * Find tags associated with a synthesized CDK `Stack`. + * + * @param stack the CDK Stack to find tags on. + */ + public static fromStack(stack: Stack): Tags { + return new Tags(getManifestTags(stack)); + } + + private readonly _tags: ManifestTags; + + private constructor(tags: ManifestTags) { + this._tags = tags; + } + + /** + * Assert that the given Matcher or object matches the tags associated with + * the synthesized CDK Stack's manifest. + * + * @param tags the expected set of tags. This should be a + * string or Matcher object. + */ + public hasValues(tags: any): void { + const matcher = Matcher.isMatcher(tags) ? tags : Match.objectLike(tags); + + const result = matcher.test(this.all()); + if (result.hasFailed()) { + throw new Error( + 'Stack tags did not match as expected:\n' + result.renderMismatch(), + ); + } + } + + /** + * Get the tags associated with the manifest. This will be an empty object if + * no tags were supplied. + * + * @returns The tags associated with the stack's synthesized manifest. + */ + public all(): ManifestTags { + return this._tags; + } +} + +function getManifestTags(stack: Stack): ManifestTags { + const root = stack.node.root; + if (!Stage.isStage(root)) { + throw new Error('unexpected: all stacks must be part of a Stage or an App'); + } + + // synthesis is not forced: the stack will only be synthesized once regardless + // of the number of times this is called. + const assembly = root.synth(); + + const artifact = assembly.getStackArtifact(stack.artifactId); + return artifact.tags; +} diff --git a/packages/aws-cdk-lib/assertions/test/tags.test.ts b/packages/aws-cdk-lib/assertions/test/tags.test.ts new file mode 100644 index 0000000000000..c9e745d875afc --- /dev/null +++ b/packages/aws-cdk-lib/assertions/test/tags.test.ts @@ -0,0 +1,95 @@ +import { IConstruct } from 'constructs'; +import { + Annotations, + App, + Aspects, + CfnResource, + IAspect, + Stack, +} from '../../core'; +import { Tags as _Tags, Match, Tags } from '../lib'; + +describe('Tags', () => { + let app: App; + + beforeEach(() => { + app = new App(); + }); + + describe('hasValues', () => { + test('simple match', () => { + const stack = new Stack(app, 'stack', { + tags: { 'tag-one': 'tag-one-value' }, + }); + const tags = Tags.fromStack(stack); + tags.hasValues({ + 'tag-one': 'tag-one-value', + }); + }); + + test('with matchers', () => { + const stack = new Stack(app, 'stack', { + tags: { 'tag-one': 'tag-one-value' }, + }); + const tags = Tags.fromStack(stack); + tags.hasValues({ + 'tag-one': Match.anyValue(), + }); + }); + + test('no tags with absent matcher will fail', () => { + const stack = new Stack(app, 'stack'); + const tags = Tags.fromStack(stack); + + expect(() => tags.hasValues(Match.absent())).toThrow( + /Received \[object Object], but key should be absent/, + ); + }); + + test('no tags matches empty object successfully', () => { + const stack = new Stack(app, 'stack'); + const tags = Tags.fromStack(stack); + + tags.hasValues(Match.objectEquals({})); + }); + + test('no match', () => { + const stack = new Stack(app, 'stack', { + tags: { 'tag-one': 'tag-one-value' }, + }); + const tags = Tags.fromStack(stack); + + expect(() => + tags.hasValues({ + 'tag-one': 'mismatched value', + }), + ).toThrow(/Expected mismatched value but received tag-one-value/); + }); + }); + + describe('all', () => { + test('simple match', () => { + const stack = new Stack(app, 'stack', { + tags: { 'tag-one': 'tag-one-value' }, + }); + const tags = Tags.fromStack(stack); + expect(tags.all()).toStrictEqual({ + 'tag-one': 'tag-one-value', + }); + }); + + test('no tags', () => { + const stack = new Stack(app, 'stack'); + const tags = Tags.fromStack(stack); + + expect(tags.all()).toStrictEqual({}); + }); + + test('empty tags', () => { + const stack = new Stack(app, 'stack', { tags: {} }); + const tags = Tags.fromStack(stack); + + expect(tags.all()).toStrictEqual({}); + }); + }); +}); diff --git a/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture b/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture index 2f579f3932980..6a15c717d091d 100644 --- a/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture +++ b/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture @@ -1,6 +1,6 @@ import { Construct } from 'constructs'; import { Aspects, CfnResource, Stack } from 'aws-cdk-lib'; -import { Annotations, Capture, Match, Template } from 'aws-cdk-lib/assertions'; +import { Annotations, Capture, Match, Tags, Template } from 'aws-cdk-lib/assertions'; interface Expect { toEqual(what: any): void; From b9ed0dcd249e1346c0f2539ebfa7e0f351708bf9 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Wed, 27 Mar 2024 17:00:09 +1100 Subject: [PATCH 2/9] fix: update formatting of import Review feedback Co-authored-by: Parker Scanlon <69879391+scanlonp@users.noreply.github.com> --- packages/aws-cdk-lib/assertions/test/tags.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/aws-cdk-lib/assertions/test/tags.test.ts b/packages/aws-cdk-lib/assertions/test/tags.test.ts index c9e745d875afc..d58f758b4ff64 100644 --- a/packages/aws-cdk-lib/assertions/test/tags.test.ts +++ b/packages/aws-cdk-lib/assertions/test/tags.test.ts @@ -1,12 +1,5 @@ import { IConstruct } from 'constructs'; -import { - Annotations, - App, - Aspects, - CfnResource, - IAspect, - Stack, -} from '../../core'; +import { Annotations, App, Aspects, CfnResource, IAspect, Stack } from '../../core'; import { Tags as _Tags, Match, Tags } from '../lib'; describe('Tags', () => { From 24d02228b0c77a728c781c85038eca14e3afaaa1 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Wed, 27 Mar 2024 13:31:31 +0000 Subject: [PATCH 3/9] fix(assertions): remove unused imports --- packages/aws-cdk-lib/assertions/test/tags.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/aws-cdk-lib/assertions/test/tags.test.ts b/packages/aws-cdk-lib/assertions/test/tags.test.ts index d58f758b4ff64..736b3615411e0 100644 --- a/packages/aws-cdk-lib/assertions/test/tags.test.ts +++ b/packages/aws-cdk-lib/assertions/test/tags.test.ts @@ -1,6 +1,5 @@ -import { IConstruct } from 'constructs'; -import { Annotations, App, Aspects, CfnResource, IAspect, Stack } from '../../core'; -import { Tags as _Tags, Match, Tags } from '../lib'; +import { App, Stack } from '../../core'; +import { Match, Tags } from '../lib'; describe('Tags', () => { let app: App; From 2d37f1f13e0208aa8b06704dcd63d41d1b3408c8 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Wed, 27 Mar 2024 13:37:36 +0000 Subject: [PATCH 4/9] feat(assertions): add hasNone() to Tags API Convenience method over `hasValues(Match.exact({}))`. Required as `Match.absent()` (the natural choice) will not act as the user expects. --- packages/aws-cdk-lib/assertions/lib/tags.ts | 13 ++++++++++++ .../aws-cdk-lib/assertions/test/tags.test.ts | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/aws-cdk-lib/assertions/lib/tags.ts b/packages/aws-cdk-lib/assertions/lib/tags.ts index 44bce6a79490d..9948813f52ddc 100644 --- a/packages/aws-cdk-lib/assertions/lib/tags.ts +++ b/packages/aws-cdk-lib/assertions/lib/tags.ts @@ -43,6 +43,19 @@ export class Tags { } } + /** + * Assert that the there are no tags associated with + * the synthesized CDK Stack's manifest. + * + * This is a convenience method over `hasValues(Match.exact({}))`, and is present because the more + * + * @param tags the expected set of tags. This should be a + * string or Matcher object. + */ + public hasNone(): void { + this.hasValues(Match.exact({})); + } + /** * Get the tags associated with the manifest. This will be an empty object if * no tags were supplied. diff --git a/packages/aws-cdk-lib/assertions/test/tags.test.ts b/packages/aws-cdk-lib/assertions/test/tags.test.ts index 736b3615411e0..12afd80da95cd 100644 --- a/packages/aws-cdk-lib/assertions/test/tags.test.ts +++ b/packages/aws-cdk-lib/assertions/test/tags.test.ts @@ -42,7 +42,7 @@ describe('Tags', () => { const stack = new Stack(app, 'stack'); const tags = Tags.fromStack(stack); - tags.hasValues(Match.objectEquals({})); + tags.hasValues(Match.exact({})); }); test('no match', () => { @@ -59,6 +59,25 @@ describe('Tags', () => { }); }); + describe('hasNone', () => { + test.each([undefined, {}])('matches empty: %s', (v) => { + const stack = new Stack(app, 'stack', { tags: v }); + const tags = Tags.fromStack(stack); + + tags.hasNone(); + }); + + test.each([]>[ + { ['tagOne']: 'single-tag' }, + { ['tagOne']: 'first-value', ['tag-two']: 'second-value' }, + ])('does not match with values: %s', (v) => { + const stack = new Stack(app, 'stack', { tags: v }); + const tags = Tags.fromStack(stack); + + expect(() => tags.hasNone()).toThrow(/unexpected key/i); + }); + }); + describe('all', () => { test('simple match', () => { const stack = new Stack(app, 'stack', { From 458bc7521488e2325fc83fbc2be9e7c4a49b6414 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Wed, 27 Mar 2024 13:41:05 +0000 Subject: [PATCH 5/9] fix(assertions): throw useful error when `Tags.hasValues()` is called with the `absent` matcher Since the value is defaulted to `{}`, the absent matcher will not work as expected. We throw an error here instead of changing the Matcher under the covers. This ensures the API is transparent about the limitation, rather than covering it up. --- packages/aws-cdk-lib/assertions/lib/tags.ts | 9 +++++++++ packages/aws-cdk-lib/assertions/test/tags.test.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/aws-cdk-lib/assertions/lib/tags.ts b/packages/aws-cdk-lib/assertions/lib/tags.ts index 9948813f52ddc..305c9f83c0389 100644 --- a/packages/aws-cdk-lib/assertions/lib/tags.ts +++ b/packages/aws-cdk-lib/assertions/lib/tags.ts @@ -33,6 +33,15 @@ export class Tags { * string or Matcher object. */ public hasValues(tags: any): void { + // The Cloud Assembly API defaults tags to {} when undefined. Using + // Match.absent() will not work as the caller expects, so we push them + // towards a working API. + if (Matcher.isMatcher(tags) && tags.name === 'absent') { + throw new Error( + 'Match.absent() will never match Tags because "{}" is the default value. Use Tags.hasNone() instead.', + ); + } + const matcher = Matcher.isMatcher(tags) ? tags : Match.objectLike(tags); const result = matcher.test(this.all()); diff --git a/packages/aws-cdk-lib/assertions/test/tags.test.ts b/packages/aws-cdk-lib/assertions/test/tags.test.ts index 12afd80da95cd..50507025fc850 100644 --- a/packages/aws-cdk-lib/assertions/test/tags.test.ts +++ b/packages/aws-cdk-lib/assertions/test/tags.test.ts @@ -33,8 +33,10 @@ describe('Tags', () => { const stack = new Stack(app, 'stack'); const tags = Tags.fromStack(stack); + // Since the tags are defaulted to the empty object, using the `absent()` + // matcher will never work, instead throwing an error. expect(() => tags.hasValues(Match.absent())).toThrow( - /Received \[object Object], but key should be absent/, + /^match.absent\(\) will never match Tags/i, ); }); From ae015e9ff30af3384f09357d713d32393594f1f5 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Thu, 28 Mar 2024 02:37:53 +0000 Subject: [PATCH 6/9] tests(assertions): add more complex matcher tests --- .../aws-cdk-lib/assertions/test/tags.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/aws-cdk-lib/assertions/test/tags.test.ts b/packages/aws-cdk-lib/assertions/test/tags.test.ts index 50507025fc850..e6125ffec229f 100644 --- a/packages/aws-cdk-lib/assertions/test/tags.test.ts +++ b/packages/aws-cdk-lib/assertions/test/tags.test.ts @@ -29,6 +29,34 @@ describe('Tags', () => { }); }); + describe('given multiple tags', () => { + const stack = new Stack(app, 'stack', { + tags: { + 'tag-one': 'tag-one-value', + 'tag-two': 'tag-2-value', + 'tag-three': 'tag-3-value', + 'tag-four': 'tag-4-value', + }, + }); + const tags = Tags.fromStack(stack); + + test('partial match succeeds', ()=>{ + tags.hasValues({ + 'tag-one': Match.anyValue(), + }); + }); + + test('complex match succeeds', ()=>{ + tags.hasValues(Match.objectEquals({ + 'tag-one': Match.anyValue(), + 'non-existent': Match.absent(), + 'tag-three': Match.stringLikeRegexp('-3-'), + 'tag-two': 'tag-2-value', + 'tag-four': Match.anyValue(), + })); + }); + }); + test('no tags with absent matcher will fail', () => { const stack = new Stack(app, 'stack'); const tags = Tags.fromStack(stack); From 8d6044e49eaa27c8edf62b34fe0e8d5d14bd72b9 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Thu, 28 Mar 2024 02:55:48 +0000 Subject: [PATCH 7/9] docs(annotations): add more scenarios to Tags documentation --- packages/aws-cdk-lib/assertions/README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/aws-cdk-lib/assertions/README.md b/packages/aws-cdk-lib/assertions/README.md index d9c2f100f77f0..caf0623cedcc7 100644 --- a/packages/aws-cdk-lib/assertions/README.md +++ b/packages/aws-cdk-lib/assertions/README.md @@ -624,8 +624,26 @@ Tags.fromStack(stack).hasValues({ 'tag-name': 'tag-value' }); -// or another Matcher +// ... with Matchers embedded Tags.fromStack(stack).hasValues({ - 'tag-name': Match.anyValue() + 'tag-name': Match.stringLikeRegexp('-value$') }); + +// or another object Matcher at the top level +Tags.fromStack(stack).hasValues(Match.objectEquals({ + 'tag-name': Match.anyValue() +})); +``` + +When tags are not defined on the stack, it is represented as an empty object +rather than `undefined`. To make this more obvious, there is a `hasNone()` +method that can be used in place of `Match.exactly({})`. If `Match.absent()` is +passed, an error will result. + +```ts nofixture +// no tags present +Tags.fromStack(stack).hasNone(); + +// don't use absent() at the top level, it won't work +expect(() => { Tags.fromStack(stack).hasValues(Match.absent()); }).toThrow(); ``` From d71091d8f4b0b2280686f016d1fd6f39ae554527 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Sat, 6 Apr 2024 05:39:30 +0000 Subject: [PATCH 8/9] docs(assertions): fix `Tags` usage examples Improvements to allow Rosetta to test the documentation. --- packages/aws-cdk-lib/assertions/README.md | 22 ++++++++++--------- .../rosetta/assertions/default.ts-fixture | 1 + 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/aws-cdk-lib/assertions/README.md b/packages/aws-cdk-lib/assertions/README.md index caf0623cedcc7..21941354d08d8 100644 --- a/packages/aws-cdk-lib/assertions/README.md +++ b/packages/aws-cdk-lib/assertions/README.md @@ -611,27 +611,29 @@ import { Tags } from 'aws-cdk-lib/assertions'; const app = new App(); const stack = new Stack(app, 'MyStack', { tags: { - 'tag-name': 'tag-value' - } + 'tag-name': 'tag-value', + }, }); ``` It is possible to test against these values: ```ts +const tags = Tags.fromStack(stack); + // using a default 'objectLike' Matcher -Tags.fromStack(stack).hasValues({ - 'tag-name': 'tag-value' +tags.hasValues({ + 'tag-name': 'tag-value', }); // ... with Matchers embedded -Tags.fromStack(stack).hasValues({ - 'tag-name': Match.stringLikeRegexp('-value$') +tags.hasValues({ + 'tag-name': Match.stringLikeRegexp('value'), }); // or another object Matcher at the top level -Tags.fromStack(stack).hasValues(Match.objectEquals({ - 'tag-name': Match.anyValue() +tags.hasValues(Match.objectEquals({ + 'tag-name': Match.anyValue(), })); ``` @@ -640,10 +642,10 @@ rather than `undefined`. To make this more obvious, there is a `hasNone()` method that can be used in place of `Match.exactly({})`. If `Match.absent()` is passed, an error will result. -```ts nofixture +```ts // no tags present Tags.fromStack(stack).hasNone(); // don't use absent() at the top level, it won't work -expect(() => { Tags.fromStack(stack).hasValues(Match.absent()); }).toThrow(); +expect(() => { Tags.fromStack(stack).hasValues(Match.absent()); }).toThrow(/will never match/i); ``` diff --git a/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture b/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture index 6a15c717d091d..2ce0763c64e4d 100644 --- a/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture +++ b/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture @@ -4,6 +4,7 @@ import { Annotations, Capture, Match, Tags, Template } from 'aws-cdk-lib/asserti interface Expect { toEqual(what: any): void; + toThrow(what?: any): void; } declare function expect(what: any): Expect; From 9ad981b7e9c324374d65650b44bb7d6b2fb82611 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Sat, 6 Apr 2024 05:41:16 +0000 Subject: [PATCH 9/9] fix(assertions): revise doc for `hasNone()` Bring doc up to scratch, removing copy-pasta and properly explaining usage intent. --- packages/aws-cdk-lib/assertions/lib/tags.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/aws-cdk-lib/assertions/lib/tags.ts b/packages/aws-cdk-lib/assertions/lib/tags.ts index 305c9f83c0389..82ce9a6146242 100644 --- a/packages/aws-cdk-lib/assertions/lib/tags.ts +++ b/packages/aws-cdk-lib/assertions/lib/tags.ts @@ -53,13 +53,13 @@ export class Tags { } /** - * Assert that the there are no tags associated with - * the synthesized CDK Stack's manifest. - * - * This is a convenience method over `hasValues(Match.exact({}))`, and is present because the more + * Assert that the there are no tags associated with the synthesized CDK + * Stack's manifest. * - * @param tags the expected set of tags. This should be a - * string or Matcher object. + * This is a convenience method over `hasValues(Match.exact({}))`, and is + * present because the more obvious method of detecting no tags + * (`Match.absent()`) will not work. Manifests default the tag set to an empty + * object. */ public hasNone(): void { this.hasValues(Match.exact({}));