Skip to content

Commit

Permalink
Merge branch 'main' into patch-1
Browse files Browse the repository at this point in the history
  • Loading branch information
mergify[bot] authored Apr 10, 2024
2 parents 0d66a23 + 72f189d commit fcb492f
Show file tree
Hide file tree
Showing 5 changed files with 285 additions and 2 deletions.
54 changes: 54 additions & 0 deletions packages/aws-cdk-lib/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
3 changes: 2 additions & 1 deletion packages/aws-cdk-lib/assertions/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './capture';
export * from './template';
export * from './match';
export * from './matcher';
export * from './annotations';
export * from './annotations';
export * from './tags';
91 changes: 91 additions & 0 deletions packages/aws-cdk-lib/assertions/lib/tags.ts
Original file line number Diff line number Diff line change
@@ -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;
}
136 changes: 136 additions & 0 deletions packages/aws-cdk-lib/assertions/test/tags.test.ts
Original file line number Diff line number Diff line change
@@ -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(<Record<string, string>[]>[
{ ['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({});
});
});
});
3 changes: 2 additions & 1 deletion packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture
Original file line number Diff line number Diff line change
@@ -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;
Expand Down

0 comments on commit fcb492f

Please sign in to comment.