Skip to content

Commit 072e1b9

Browse files
authored
fix(assertions): incorrect assertions when >1 messages on a resource (#18948)
Previously we relied on the message id as a key to the internal `messages` object we maintain. However, the id is the construct path, and this is not unique if there are multiple messages attached to a particular construct. This would result in erroneous behavior from `Annotations`, as the newer message would overwrite the old one. The fix here is to not depend on the id as the key at all. We don't need it, and it's there just to mold the messages into an object that `matchSection` can handle. Instead, we can index on `index`, and suddenly all our problems are solved. I also redacted the stack trace from the `findXxx APIs; I don't see this as a breaking change because it is unfathomable that anyone is depending on the stack trace output, which is a long list of gibberish. Fixes #18840. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 2755b18 commit 072e1b9

File tree

4 files changed

+106
-25
lines changed

4 files changed

+106
-25
lines changed

packages/@aws-cdk/assertions/lib/annotations.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,10 @@ function constructMessage(type: 'info' | 'warning' | 'error', message: any): {[k
102102
}
103103

104104
function convertArrayToMessagesType(messages: SynthesisMessage[]): Messages {
105-
return messages.reduce((obj, item) => {
105+
return messages.reduce((obj, item, index) => {
106106
return {
107107
...obj,
108-
[item.id]: item,
108+
[index]: item,
109109
};
110110
}, {}) as Messages;
111111
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SynthesisMessage } from '@aws-cdk/cx-api';
22

33
export type Messages = {
4-
[logicalId: string]: SynthesisMessage;
4+
[key: string]: SynthesisMessage;
55
}
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1-
import { MatchResult } from '../matcher';
1+
import { SynthesisMessage } from '@aws-cdk/cx-api';
22
import { Messages } from './message';
3-
import { filterLogicalId, formatFailure, matchSection } from './section';
3+
import { formatFailure, matchSection } from './section';
44

5-
export function findMessage(messages: Messages, logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } {
6-
const section: { [key: string]: {} } = messages;
7-
const result = matchSection(filterLogicalId(section, logicalId), props);
5+
export function findMessage(messages: Messages, constructPath: string, props: any = {}): { [key: string]: { [key: string]: any } } {
6+
const section: { [key: string]: SynthesisMessage } = messages;
7+
const result = matchSection(filterPath(section, constructPath), props);
88

99
if (!result.match) {
1010
return {};
1111
}
1212

13+
Object.values(result.matches).forEach((m) => handleTrace(m));
1314
return result.matches;
1415
}
1516

16-
export function hasMessage(messages: Messages, logicalId: string, props: any): string | void {
17-
const section: { [key: string]: {} } = messages;
18-
const result = matchSection(filterLogicalId(section, logicalId), props);
17+
export function hasMessage(messages: Messages, constructPath: string, props: any): string | void {
18+
const section: { [key: string]: SynthesisMessage } = messages;
19+
const result = matchSection(filterPath(section, constructPath), props);
1920

2021
if (result.match) {
2122
return;
@@ -25,17 +26,26 @@ export function hasMessage(messages: Messages, logicalId: string, props: any): s
2526
return 'No messages found in the stack';
2627
}
2728

29+
handleTrace(result.closestResult.target);
2830
return [
2931
`Stack has ${result.analyzedCount} messages, but none match as expected.`,
30-
formatFailure(formatMessage(result.closestResult)),
32+
formatFailure(result.closestResult),
3133
].join('\n');
3234
}
3335

3436
// We redact the stack trace by default because it is unnecessarily long and unintelligible.
3537
// If there is a use case for rendering the trace, we can add it later.
36-
function formatMessage(match: MatchResult, renderTrace: boolean = false): MatchResult {
37-
if (!renderTrace) {
38-
match.target.entry.trace = 'redacted';
39-
}
40-
return match;
38+
function handleTrace(match: any, redact: boolean = true): void {
39+
if (redact && match.entry?.trace !== undefined) {
40+
match.entry.trace = 'redacted';
41+
};
42+
}
43+
44+
function filterPath(section: { [key: string]: SynthesisMessage }, path: string): { [key: string]: SynthesisMessage } {
45+
// default signal for all paths is '*'
46+
if (path === '*') return section;
47+
48+
return Object.entries(section ?? {})
49+
.filter(([_, v]) => v.id === path)
50+
.reduce((agg, [k, v]) => { return { ...agg, [k]: v }; }, {});
4151
}

packages/@aws-cdk/assertions/test/annotations.test.ts

+79-8
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ describe('Messages', () => {
77
let annotations: _Annotations;
88
beforeAll(() => {
99
stack = new Stack();
10-
new CfnResource(stack, 'Foo', {
10+
const foo = new CfnResource(stack, 'Foo', {
1111
type: 'Foo::Bar',
1212
properties: {
1313
Fred: 'Thud',
1414
},
1515
});
16+
foo.node.setContext('disable-stack-trace', false);
1617

1718
new CfnResource(stack, 'Bar', {
1819
type: 'Foo::Bar',
@@ -53,12 +54,17 @@ describe('Messages', () => {
5354
describe('findError', () => {
5455
test('match', () => {
5556
const result = annotations.findError('*', Match.anyValue());
56-
expect(Object.keys(result).length).toEqual(2);
57+
expect(result.length).toEqual(2);
5758
});
5859

5960
test('no match', () => {
6061
const result = annotations.findError('*', 'no message looks like this');
61-
expect(Object.keys(result).length).toEqual(0);
62+
expect(result.length).toEqual(0);
63+
});
64+
65+
test('trace is redacted', () => {
66+
const result = annotations.findError('/Default/Foo', Match.anyValue());
67+
expect(result[0].entry.trace).toEqual('redacted');
6268
});
6369
});
6470

@@ -75,12 +81,12 @@ describe('Messages', () => {
7581
describe('findWarning', () => {
7682
test('match', () => {
7783
const result = annotations.findWarning('*', Match.anyValue());
78-
expect(Object.keys(result).length).toEqual(1);
84+
expect(result.length).toEqual(1);
7985
});
8086

8187
test('no match', () => {
8288
const result = annotations.findWarning('*', 'no message looks like this');
83-
expect(Object.keys(result).length).toEqual(0);
89+
expect(result.length).toEqual(0);
8490
});
8591
});
8692

@@ -97,19 +103,19 @@ describe('Messages', () => {
97103
describe('findInfo', () => {
98104
test('match', () => {
99105
const result = annotations.findInfo('/Default/Qux', 'this is an info');
100-
expect(Object.keys(result).length).toEqual(1);
106+
expect(result.length).toEqual(1);
101107
});
102108

103109
test('no match', () => {
104110
const result = annotations.findInfo('*', 'no message looks like this');
105-
expect(Object.keys(result).length).toEqual(0);
111+
expect(result.length).toEqual(0);
106112
});
107113
});
108114

109115
describe('with matchers', () => {
110116
test('anyValue', () => {
111117
const result = annotations.findError('*', Match.anyValue());
112-
expect(Object.keys(result).length).toEqual(2);
118+
expect(result.length).toEqual(2);
113119
});
114120

115121
test('not', () => {
@@ -123,6 +129,53 @@ describe('Messages', () => {
123129
});
124130
});
125131

132+
describe('Multiple Messages on the Resource', () => {
133+
let stack: Stack;
134+
let annotations: _Annotations;
135+
beforeAll(() => {
136+
stack = new Stack();
137+
new CfnResource(stack, 'Foo', {
138+
type: 'Foo::Bar',
139+
properties: {
140+
Fred: 'Thud',
141+
},
142+
});
143+
144+
const bar = new CfnResource(stack, 'Bar', {
145+
type: 'Foo::Bar',
146+
properties: {
147+
Baz: 'Qux',
148+
},
149+
});
150+
bar.node.setContext('disable-stack-trace', false);
151+
152+
Aspects.of(stack).add(new MultipleAspectsPerNode());
153+
annotations = _Annotations.fromStack(stack);
154+
});
155+
156+
test('succeeds on hasXxx APIs', () => {
157+
annotations.hasError('/Default/Foo', 'error: this is an error');
158+
annotations.hasError('/Default/Foo', 'error: unsupported type Foo::Bar');
159+
annotations.hasWarning('/Default/Foo', 'warning: Foo::Bar is deprecated');
160+
});
161+
162+
test('succeeds on findXxx APIs', () => {
163+
const result1 = annotations.findError('*', Match.stringLikeRegexp('error:.*'));
164+
expect(result1.length).toEqual(4);
165+
const result2 = annotations.findError('/Default/Bar', Match.stringLikeRegexp('error:.*'));
166+
expect(result2.length).toEqual(2);
167+
const result3 = annotations.findWarning('/Default/Bar', 'warning: Foo::Bar is deprecated');
168+
expect(result3).toEqual([{
169+
level: 'warning',
170+
entry: {
171+
type: 'aws:cdk:warning',
172+
data: 'warning: Foo::Bar is deprecated',
173+
trace: 'redacted',
174+
},
175+
id: '/Default/Bar',
176+
}]);
177+
});
178+
});
126179
class MyAspect implements IAspect {
127180
public visit(node: IConstruct): void {
128181
if (node instanceof CfnResource) {
@@ -147,4 +200,22 @@ class MyAspect implements IAspect {
147200
protected info(node: IConstruct, message: string): void {
148201
Annotations.of(node).addInfo(message);
149202
}
203+
}
204+
205+
class MultipleAspectsPerNode implements IAspect {
206+
public visit(node: IConstruct): void {
207+
if (node instanceof CfnResource) {
208+
this.error(node, 'error: this is an error');
209+
this.error(node, `error: unsupported type ${node.cfnResourceType}`);
210+
this.warn(node, `warning: ${node.cfnResourceType} is deprecated`);
211+
}
212+
}
213+
214+
protected warn(node: IConstruct, message: string): void {
215+
Annotations.of(node).addWarning(message);
216+
}
217+
218+
protected error(node: IConstruct, message: string): void {
219+
Annotations.of(node).addError(message);
220+
}
150221
}

0 commit comments

Comments
 (0)