Skip to content

Commit fcb492f

Browse files
authored
Merge branch 'main' into patch-1
2 parents 0d66a23 + 72f189d commit fcb492f

File tree

5 files changed

+285
-2
lines changed

5 files changed

+285
-2
lines changed

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,3 +595,57 @@ Annotations.fromStack(stack).hasError(
595595
Match.stringLikeRegexp('.*Foo::Bar.*'),
596596
);
597597
```
598+
599+
## Asserting Stack tags
600+
601+
Tags applied to a `Stack` are not part of the rendered template: instead, they
602+
are included as properties in the Cloud Assembly Manifest. To test that stacks
603+
are tagged as expected, simple assertions can be written.
604+
605+
Given the following setup:
606+
607+
```ts nofixture
608+
import { App, Stack } from 'aws-cdk-lib';
609+
import { Tags } from 'aws-cdk-lib/assertions';
610+
611+
const app = new App();
612+
const stack = new Stack(app, 'MyStack', {
613+
tags: {
614+
'tag-name': 'tag-value',
615+
},
616+
});
617+
```
618+
619+
It is possible to test against these values:
620+
621+
```ts
622+
const tags = Tags.fromStack(stack);
623+
624+
// using a default 'objectLike' Matcher
625+
tags.hasValues({
626+
'tag-name': 'tag-value',
627+
});
628+
629+
// ... with Matchers embedded
630+
tags.hasValues({
631+
'tag-name': Match.stringLikeRegexp('value'),
632+
});
633+
634+
// or another object Matcher at the top level
635+
tags.hasValues(Match.objectEquals({
636+
'tag-name': Match.anyValue(),
637+
}));
638+
```
639+
640+
When tags are not defined on the stack, it is represented as an empty object
641+
rather than `undefined`. To make this more obvious, there is a `hasNone()`
642+
method that can be used in place of `Match.exactly({})`. If `Match.absent()` is
643+
passed, an error will result.
644+
645+
```ts
646+
// no tags present
647+
Tags.fromStack(stack).hasNone();
648+
649+
// don't use absent() at the top level, it won't work
650+
expect(() => { Tags.fromStack(stack).hasValues(Match.absent()); }).toThrow(/will never match/i);
651+
```

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export * from './capture';
22
export * from './template';
33
export * from './match';
44
export * from './matcher';
5-
export * from './annotations';
5+
export * from './annotations';
6+
export * from './tags';
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Match } from './match';
2+
import { Matcher } from './matcher';
3+
import { Stack, Stage } from '../../core';
4+
5+
type ManifestTags = { [key: string]: string };
6+
7+
/**
8+
* Allows assertions on the tags associated with a synthesized CDK stack's
9+
* manifest. Stack tags are not part of the synthesized template, so can only be
10+
* checked from the manifest in this manner.
11+
*/
12+
export class Tags {
13+
/**
14+
* Find tags associated with a synthesized CDK `Stack`.
15+
*
16+
* @param stack the CDK Stack to find tags on.
17+
*/
18+
public static fromStack(stack: Stack): Tags {
19+
return new Tags(getManifestTags(stack));
20+
}
21+
22+
private readonly _tags: ManifestTags;
23+
24+
private constructor(tags: ManifestTags) {
25+
this._tags = tags;
26+
}
27+
28+
/**
29+
* Assert that the given Matcher or object matches the tags associated with
30+
* the synthesized CDK Stack's manifest.
31+
*
32+
* @param tags the expected set of tags. This should be a
33+
* string or Matcher object.
34+
*/
35+
public hasValues(tags: any): void {
36+
// The Cloud Assembly API defaults tags to {} when undefined. Using
37+
// Match.absent() will not work as the caller expects, so we push them
38+
// towards a working API.
39+
if (Matcher.isMatcher(tags) && tags.name === 'absent') {
40+
throw new Error(
41+
'Match.absent() will never match Tags because "{}" is the default value. Use Tags.hasNone() instead.',
42+
);
43+
}
44+
45+
const matcher = Matcher.isMatcher(tags) ? tags : Match.objectLike(tags);
46+
47+
const result = matcher.test(this.all());
48+
if (result.hasFailed()) {
49+
throw new Error(
50+
'Stack tags did not match as expected:\n' + result.renderMismatch(),
51+
);
52+
}
53+
}
54+
55+
/**
56+
* Assert that the there are no tags associated with the synthesized CDK
57+
* Stack's manifest.
58+
*
59+
* This is a convenience method over `hasValues(Match.exact({}))`, and is
60+
* present because the more obvious method of detecting no tags
61+
* (`Match.absent()`) will not work. Manifests default the tag set to an empty
62+
* object.
63+
*/
64+
public hasNone(): void {
65+
this.hasValues(Match.exact({}));
66+
}
67+
68+
/**
69+
* Get the tags associated with the manifest. This will be an empty object if
70+
* no tags were supplied.
71+
*
72+
* @returns The tags associated with the stack's synthesized manifest.
73+
*/
74+
public all(): ManifestTags {
75+
return this._tags;
76+
}
77+
}
78+
79+
function getManifestTags(stack: Stack): ManifestTags {
80+
const root = stack.node.root;
81+
if (!Stage.isStage(root)) {
82+
throw new Error('unexpected: all stacks must be part of a Stage or an App');
83+
}
84+
85+
// synthesis is not forced: the stack will only be synthesized once regardless
86+
// of the number of times this is called.
87+
const assembly = root.synth();
88+
89+
const artifact = assembly.getStackArtifact(stack.artifactId);
90+
return artifact.tags;
91+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { App, Stack } from '../../core';
2+
import { Match, Tags } from '../lib';
3+
4+
describe('Tags', () => {
5+
let app: App;
6+
7+
beforeEach(() => {
8+
app = new App();
9+
});
10+
11+
describe('hasValues', () => {
12+
test('simple match', () => {
13+
const stack = new Stack(app, 'stack', {
14+
tags: { 'tag-one': 'tag-one-value' },
15+
});
16+
const tags = Tags.fromStack(stack);
17+
tags.hasValues({
18+
'tag-one': 'tag-one-value',
19+
});
20+
});
21+
22+
test('with matchers', () => {
23+
const stack = new Stack(app, 'stack', {
24+
tags: { 'tag-one': 'tag-one-value' },
25+
});
26+
const tags = Tags.fromStack(stack);
27+
tags.hasValues({
28+
'tag-one': Match.anyValue(),
29+
});
30+
});
31+
32+
describe('given multiple tags', () => {
33+
const stack = new Stack(app, 'stack', {
34+
tags: {
35+
'tag-one': 'tag-one-value',
36+
'tag-two': 'tag-2-value',
37+
'tag-three': 'tag-3-value',
38+
'tag-four': 'tag-4-value',
39+
},
40+
});
41+
const tags = Tags.fromStack(stack);
42+
43+
test('partial match succeeds', ()=>{
44+
tags.hasValues({
45+
'tag-one': Match.anyValue(),
46+
});
47+
});
48+
49+
test('complex match succeeds', ()=>{
50+
tags.hasValues(Match.objectEquals({
51+
'tag-one': Match.anyValue(),
52+
'non-existent': Match.absent(),
53+
'tag-three': Match.stringLikeRegexp('-3-'),
54+
'tag-two': 'tag-2-value',
55+
'tag-four': Match.anyValue(),
56+
}));
57+
});
58+
});
59+
60+
test('no tags with absent matcher will fail', () => {
61+
const stack = new Stack(app, 'stack');
62+
const tags = Tags.fromStack(stack);
63+
64+
// Since the tags are defaulted to the empty object, using the `absent()`
65+
// matcher will never work, instead throwing an error.
66+
expect(() => tags.hasValues(Match.absent())).toThrow(
67+
/^match.absent\(\) will never match Tags/i,
68+
);
69+
});
70+
71+
test('no tags matches empty object successfully', () => {
72+
const stack = new Stack(app, 'stack');
73+
const tags = Tags.fromStack(stack);
74+
75+
tags.hasValues(Match.exact({}));
76+
});
77+
78+
test('no match', () => {
79+
const stack = new Stack(app, 'stack', {
80+
tags: { 'tag-one': 'tag-one-value' },
81+
});
82+
const tags = Tags.fromStack(stack);
83+
84+
expect(() =>
85+
tags.hasValues({
86+
'tag-one': 'mismatched value',
87+
}),
88+
).toThrow(/Expected mismatched value but received tag-one-value/);
89+
});
90+
});
91+
92+
describe('hasNone', () => {
93+
test.each([undefined, {}])('matches empty: %s', (v) => {
94+
const stack = new Stack(app, 'stack', { tags: v });
95+
const tags = Tags.fromStack(stack);
96+
97+
tags.hasNone();
98+
});
99+
100+
test.each(<Record<string, string>[]>[
101+
{ ['tagOne']: 'single-tag' },
102+
{ ['tagOne']: 'first-value', ['tag-two']: 'second-value' },
103+
])('does not match with values: %s', (v) => {
104+
const stack = new Stack(app, 'stack', { tags: v });
105+
const tags = Tags.fromStack(stack);
106+
107+
expect(() => tags.hasNone()).toThrow(/unexpected key/i);
108+
});
109+
});
110+
111+
describe('all', () => {
112+
test('simple match', () => {
113+
const stack = new Stack(app, 'stack', {
114+
tags: { 'tag-one': 'tag-one-value' },
115+
});
116+
const tags = Tags.fromStack(stack);
117+
expect(tags.all()).toStrictEqual({
118+
'tag-one': 'tag-one-value',
119+
});
120+
});
121+
122+
test('no tags', () => {
123+
const stack = new Stack(app, 'stack');
124+
const tags = Tags.fromStack(stack);
125+
126+
expect(tags.all()).toStrictEqual({});
127+
});
128+
129+
test('empty tags', () => {
130+
const stack = new Stack(app, 'stack', { tags: {} });
131+
const tags = Tags.fromStack(stack);
132+
133+
expect(tags.all()).toStrictEqual({});
134+
});
135+
});
136+
});

packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Construct } from 'constructs';
22
import { Aspects, CfnResource, Stack } from 'aws-cdk-lib';
3-
import { Annotations, Capture, Match, Template } from 'aws-cdk-lib/assertions';
3+
import { Annotations, Capture, Match, Tags, Template } from 'aws-cdk-lib/assertions';
44

55
interface Expect {
66
toEqual(what: any): void;
7+
toThrow(what?: any): void;
78
}
89

910
declare function expect(what: any): Expect;

0 commit comments

Comments
 (0)