-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstack.test.js
More file actions
361 lines (320 loc) · 15 KB
/
Copy pathstack.test.js
File metadata and controls
361 lines (320 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
// Proves the README's claims against a running fakecloud with the stack deployed:
//
// make up && npm test
//
// Every test asserts an observable outcome of one of the fixes and names the
// branch in its title. Nothing here mocks fakecloud - if it passes, the emulator
// really served the site and really ran the custom resources.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const http = require('node:http');
const { readFileSync } = require('node:fs');
const ENDPOINT = 'http://localhost:4566';
const DOMAIN = 'fakecloud-web.localhost';
const STACK = 'SpaStack';
const ENV = {
...process.env,
AWS_ENDPOINT_URL: ENDPOINT,
AWS_ACCESS_KEY_ID: 'test',
AWS_SECRET_ACCESS_KEY: 'test',
AWS_DEFAULT_REGION: 'us-east-1',
};
const aws = (...args) => {
try {
return JSON.parse(
execFileSync('aws', [...args, '--output', 'json'], {
env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}) || 'null',
);
} catch (e) {
const why = /Could not connect|Connection refused|EndpointConnectionError/.test(e.stderr ?? '')
? `no fakecloud at ${ENDPOINT} - run \`make up\` first`
: `aws ${args.slice(0, 2).join(' ')} failed`;
throw new Error(`${why}\n${e.stderr ?? e.message}`);
}
};
/** Runs an aws command expected to fail; returns its stderr. */
const awsErr = (...args) => {
try {
execFileSync('aws', args, { env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) {
return e.stderr;
}
assert.fail(`aws ${args.join(' ')} unexpectedly succeeded`);
};
// node:http, not fetch: `Host` is a forbidden header name, so fetch silently
// drops it - and the Host header is the only way to route to the distribution.
const get = (path, host) =>
new Promise((resolve, reject) => {
http
.get({ host: '127.0.0.1', port: 4566, path, headers: { Host: host } }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (c) => (body += c));
res.on('end', () => resolve({ status: res.statusCode, body }));
})
.on('error', reject);
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const indexHtml = readFileSync(`${__dirname}/web/index.html`, 'utf8');
/** Polls `probe` until it returns truthy, or fails after `seconds`. */
const waitFor = async (probe, what, seconds = 60) => {
for (let i = 0; i < seconds; i++) {
const v = probe();
if (v) return v;
await sleep(1000);
}
assert.fail(`timed out after ${seconds}s waiting for ${what}`);
};
// Read once, on first use: every test below reads the same deployed stack.
//
// Nothing here waits for the assets. A CREATE_COMPLETE stack has finished its
// custom resources, so `BucketDeployment` has copied `web/` by the time
// `cdk deploy` returns - which is only true since
// `fix/cfn-changeset-custom-resource-outcome`. Before it, the objects landed
// 4.7s after the deploy returned, and a wait here would quietly hide that
// coming back.
let cache;
const deployed = async () => {
if (cache) return cache;
const stack = aws('cloudformation', 'describe-stacks', '--stack-name', STACK).Stacks[0];
assert.match(
stack.StackStatus, /^(CREATE|UPDATE)_COMPLETE$/,
`${STACK} is ${stack.StackStatus}, so it never deployed - fakecloud is unpatched, or \`make up\` failed.`,
);
const resources = aws('cloudformation', 'describe-stack-resources', '--stack-name', STACK).StackResources;
const physical = (type) => resources.find((r) => r.ResourceType === type).PhysicalResourceId;
const bucket = physical('AWS::S3::Bucket');
const distId = physical('AWS::CloudFront::Distribution');
const objects = aws('s3api', 'list-objects-v2', '--bucket', bucket).Contents ?? [];
cache = {
stack,
resources,
bucket,
objects,
distDomain: stack.Outputs.find((o) => o.OutputKey === 'CloudFrontUrl').OutputValue.replace('https://', ''),
distConfig: aws('cloudfront', 'get-distribution-config', '--id', distId).DistributionConfig,
};
return cache;
};
// --- The template actually provisioned something -----------------------------
test('the stack provisioned real resources - fix/s3-sse-kms-internal-readers', async () => {
// The bug: the KMS envelope of the uploaded template survived UTF-8 decoding
// and parsed as a template with no resources, so the stack reached
// CREATE_COMPLETE having provisioned nothing. `deployed()` asserts the
// status; this asserts there is something behind it.
const { stack, resources } = await deployed();
const types = new Set(resources.map((r) => r.ResourceType));
for (const t of [
'AWS::S3::Bucket',
'AWS::CloudFront::Distribution',
'AWS::CertificateManager::Certificate',
'Custom::S3AutoDeleteObjects',
'Custom::CDKBucketDeployment',
]) {
assert.ok(types.has(t), `stack has no ${t}`);
}
for (const r of resources) assert.equal(r.ResourceStatus, `${stack.StackStatus.split('_')[0]}_COMPLETE`, r.LogicalResourceId);
});
// --- Nothing served the site -------------------------------------------------
test('the bucket got a legal S3 name, not the mixed-case logical id - fix/cfn-s3-bucket-physical-name', async () => {
const { resources, bucket } = await deployed();
const logicalId = resources.find((r) => r.ResourceType === 'AWS::S3::Bucket').LogicalResourceId;
assert.match(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/);
assert.notEqual(bucket, logicalId);
assert.match(logicalId, /[A-Z]/); // the id it would otherwise have taken is not a legal bucket name
// The symptom was a 404 here on an object the bucket visibly held.
const res = await get('/index.html', `${bucket}.s3.us-east-1.amazonaws.com`);
assert.equal(res.status, 200);
assert.equal(res.body, indexHtml);
});
test('the distribution serves the bucket, not real AWS - fix/cloudfront-s3-origin-resolution', async () => {
// A REST origin resolves in real DNS, so the proxy used to reach the real
// internet and never touch the local bucket.
const { bucket, distConfig } = await deployed();
const origin = distConfig.Origins.Items[0];
assert.equal(origin.DomainName, `${bucket}.s3.us-east-1.amazonaws.com`);
const res = await get('/', DOMAIN);
assert.equal(res.status, 200);
assert.equal(res.body, indexHtml);
});
test('/ serves index.html, not the bucket listing - fix/cloudfront-default-root-object', async () => {
assert.equal((await deployed()).distConfig.DefaultRootObject, 'index.html');
const res = await get('/', DOMAIN);
assert.doesNotMatch(res.body, /ListBucketResult/);
assert.match(res.body, /<title>Hello World SPA<\/title>/);
});
test('deep links fall back to the app shell - fix/cfn-cloudfront-custom-error-responses', async () => {
// Both rules used to fail to deserialize: CFN types ResponseCode as an
// integer and the CloudFront API as a string, and ErrorCachingMinTTL was
// mis-spelled by rename_all.
const rules = (await deployed()).distConfig.CustomErrorResponses;
assert.equal(rules.Quantity, 2);
assert.deepEqual(
rules.Items.map((r) => [r.ErrorCode, r.ResponseCode, r.ResponsePagePath, r.ErrorCachingMinTTL]),
[
[403, '200', '/index.html', 300],
[404, '200', '/index.html', 300],
],
);
for (const path of ['/about', '/missing.png']) {
const res = await get(path, DOMAIN);
assert.equal(res.status, 200, path);
assert.equal(res.body, indexHtml, path);
}
});
test('the distribution also answers on its own domain', async () => {
const res = await get('/', (await deployed()).distDomain);
assert.equal(res.status, 200);
assert.equal(res.body, indexHtml);
});
// --- Nothing uploaded the assets ---------------------------------------------
test('BucketDeployment copied web/ into the bucket - fix/cfn-lambda-s3-asset-zip, fix/lambda-container-aws-endpoint', async () => {
// Both bugs ended the same way: the deployment reported success having copied
// nothing - one because the handler's own code came back as a KMS envelope
// instead of a zip, the other because the container had no AWS environment
// for its SDK client.
const { objects } = await deployed();
assert.deepEqual(
objects.map((o) => o.Key),
['index.html'],
);
assert.equal(objects[0].Size, Buffer.byteLength(indexHtml));
});
// --- Custom resources could report their outcome ------------------------------
test('both custom resources signalled back over TLS - feat/cfn-custom-resource-response-url, feat/cfn-response-url-tls, feat/lambda-ca-bundle, fix/cfn-custom-resource-property-strings', async () => {
// Reaching CREATE_COMPLETE means each handler PUT to the event's ResponseURL:
// it existed, it was TLS on 443, and the certificate verified. The python3.13
// one is BucketDeployment, whose handler also does
// `props.get('Prune', 'true').lower()` - which raises unless CFN stringified
// the property first.
const runtimes = (await deployed())
.resources.filter((r) => r.ResourceType.startsWith('Custom::'))
.map((r) => {
assert.match(r.ResourceStatus, /_COMPLETE$/, r.LogicalResourceId);
return r.LogicalResourceId;
});
assert.equal(runtimes.length, 2); // the Node handler (autoDeleteObjects) and the Python one (BucketDeployment)
const lambdaRuntimes = aws('lambda', 'list-functions')
.Functions.filter((f) => f.FunctionName.startsWith(`${STACK}-`))
.map((f) => f.Runtime)
.sort();
assert.deepEqual(lambdaRuntimes, ['nodejs24.x', 'python3.13']);
});
test('fakecloud publishes no port 443 on the host - feat/cfn-response-url-internal', () => {
// Function containers join fakecloud's own network and reach its container
// port 443 by DNS, so the host's 443 stays free.
const ports = execFileSync('docker', ['compose', 'ps', '--format', '{{.Ports}}'], {
cwd: __dirname, encoding: 'utf8',
});
assert.doesNotMatch(ports, /:443->/);
});
/// A Lambda-backed custom resource whose handler body is `source`.
const probeTemplate = (source) =>
JSON.stringify({
Resources: {
Role: {
Type: 'AWS::IAM::Role',
Properties: {
AssumeRolePolicyDocument: {
Version: '2012-10-17',
Statement: [
{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' },
],
},
},
},
Fn: {
Type: 'AWS::Lambda::Function',
Properties: {
Runtime: 'nodejs24.x',
Handler: 'index.handler',
// Explicit: on the default 3s a slow handler is killed, and a killed
// handler returns no parseable error body, which reads as success.
Timeout: 30,
Role: { 'Fn::GetAtt': ['Role', 'Arn'] },
Code: { ZipFile: source },
},
},
Cr: {
Type: 'AWS::CloudFormation::CustomResource',
Properties: { ServiceToken: { 'Fn::GetAtt': ['Fn', 'Arn'] } },
},
},
});
/** Poll a probe stack until it leaves `*_IN_PROGRESS`. */
const terminalStack = (name) =>
waitFor(() => {
const stack = aws('cloudformation', 'describe-stacks', '--stack-name', name).Stacks[0];
return stack.StackStatus.endsWith('_IN_PROGRESS') ? null : stack;
}, `${name} to reach a terminal status`);
const deleteStack = (name) =>
execFileSync('aws', ['cloudformation', 'delete-stack', '--stack-name', name], { env: ENV, stdio: 'ignore' });
test('an inline-ZipFile handler runs, and its failure fails the stack - fix/cfn-lambda-inline-zipfile, fix/cfn-custom-resource-failure', async () => {
// Two bugs in one probe. `Code.ZipFile` is source text in CFN but an archive
// in the Lambda API, so the handler only runs at all if the provisioner
// packaged it. And a handler that raises returns HTTP 200 with the error in
// the body - fakecloud used to discard that and report CREATE_COMPLETE.
//
// `create-stack`, so this is the synchronous provisioning arm. The changeset
// arm below is a separate code path and was fixed separately.
const name = 'InlineZipFailProbe';
try {
aws('cloudformation', 'create-stack', '--stack-name', name, '--capabilities', 'CAPABILITY_IAM',
'--template-body', probeTemplate("exports.handler = async () => { throw new Error('inline handler ran'); };"));
const probe = await terminalStack(name);
assert.match(probe.StackStatus, /FAILED|ROLLBACK/);
// The message is the one thrown by the inline source, so the archive the
// provisioner built really was that code.
assert.match(probe.StackStatusReason, /inline handler ran/);
} finally {
deleteStack(name);
}
});
test('a changeset custom resource reports its outcome - fix/cfn-changeset-custom-resource-outcome', async () => {
// `cdk deploy` provisions through CreateChangeSet + ExecuteChangeSet, and
// that path queued custom-resource invokes without ever reading the result:
// the stack reached CREATE_COMPLETE before the handler had run, and a
// handler that raised never failed it. Every earlier outcome fix landed on
// the synchronous arm, which this path does not take.
//
// The handler sleeps before throwing, so both halves are observable in one
// probe: complete-too-early, and complete-despite-the-failure.
const name = 'ChangeSetOutcomeProbe';
try {
// Addressed by Id, not name: a change set name is reused across runs and
// resolves to the spent one from the last.
const changeSet = aws('cloudformation', 'create-change-set', '--stack-name', name,
'--change-set-name', 'cs1', '--change-set-type', 'CREATE', '--capabilities', 'CAPABILITY_IAM',
'--template-body', probeTemplate(
"exports.handler = async () => { await new Promise((r) => setTimeout(r, 3000)); throw new Error('changeset handler ran'); };",
)).Id;
await waitFor(
() => aws('cloudformation', 'describe-change-set', '--change-set-name', changeSet)
.ExecutionStatus === 'AVAILABLE',
'the change set to become AVAILABLE',
);
aws('cloudformation', 'execute-change-set', '--change-set-name', changeSet);
// The handler is still sleeping, so the stack cannot legitimately be done.
const during = aws('cloudformation', 'describe-stacks', '--stack-name', name).Stacks[0];
assert.equal(
during.StackStatus, 'CREATE_IN_PROGRESS',
'the stack must stay in progress while its custom resource runs',
);
const probe = await terminalStack(name);
assert.match(probe.StackStatus, /FAILED|ROLLBACK/);
assert.match(probe.StackStatusReason, /changeset handler ran/);
} finally {
deleteStack(name);
}
});
// --- cdk deploy / bootstrap against an existing stack --------------------------
test('DescribeChangeSet reports a miss instead of fabricating CREATE_COMPLETE - fix/cfn-describe-change-set-not-found', () => {
// The CDK CLI deletes any leftover change set and polls until it 404s. A
// fabricated CREATE_COMPLETE made that poll run forever, hanging every
// `cdk deploy` and `cdk bootstrap` against a stack that already existed.
const err = awsErr('cloudformation', 'describe-change-set', '--stack-name', STACK,
'--change-set-name', 'no-such-change-set');
assert.match(err, /ChangeSetNotFound/);
});