diff --git a/packages/aws-cdk-lib/assertions/README.md b/packages/aws-cdk-lib/assertions/README.md index 7c78cd55d514d..21941354d08d8 100644 --- a/packages/aws-cdk-lib/assertions/README.md +++ b/packages/aws-cdk-lib/assertions/README.md @@ -595,3 +595,57 @@ 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 +const tags = Tags.fromStack(stack); + +// using a default 'objectLike' Matcher +tags.hasValues({ + 'tag-name': 'tag-value', +}); + +// ... with Matchers embedded +tags.hasValues({ + 'tag-name': Match.stringLikeRegexp('value'), +}); + +// or another object Matcher at the top level +tags.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 +// 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(/will never match/i); +``` 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..82ce9a6146242 --- /dev/null +++ b/packages/aws-cdk-lib/assertions/lib/tags.ts @@ -0,0 +1,91 @@ +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 { + // 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()); + if (result.hasFailed()) { + throw new Error( + 'Stack tags did not match as expected:\n' + result.renderMismatch(), + ); + } + } + + /** + * 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 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({})); + } + + /** + * 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..e6125ffec229f --- /dev/null +++ b/packages/aws-cdk-lib/assertions/test/tags.test.ts @@ -0,0 +1,136 @@ +import { App, Stack } from '../../core'; +import { 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(), + }); + }); + + 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); + + // 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( + /^match.absent\(\) will never match Tags/i, + ); + }); + + test('no tags matches empty object successfully', () => { + const stack = new Stack(app, 'stack'); + const tags = Tags.fromStack(stack); + + tags.hasValues(Match.exact({})); + }); + + 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('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', { + 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..2ce0763c64e4d 100644 --- a/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture +++ b/packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture @@ -1,9 +1,10 @@ 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; + toThrow(what?: any): void; } declare function expect(what: any): Expect;