@adobe/spacecat-shared-tokowaka-client 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4185 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { expect } from 'chai';
14
+ import sinon from 'sinon';
15
+ import esmock from 'esmock';
16
+
17
+ describe('edge-optimize support', () => {
18
+ let stsSendStub;
19
+ let cfSendStub;
20
+ let iamSendStub;
21
+ let lambdaSendStub;
22
+ let edgeOptimize;
23
+
24
+ // esmock ONCE for the whole file (not per-test) — esmock re-instantiates the mocked module
25
+ // graph on every call and accumulates memory, which contributes to the suite's heap pressure.
26
+ // The mocked clients call the `*SendStub` closures, which read the `let` bindings reassigned
27
+ // fresh in beforeEach, so a single esmock works for all tests.
28
+ before(async function setupEsmock() {
29
+ // One-time esmock of the AWS SDK module graph. This is memory-heavy, so under the full CI
30
+ // suite (12k+ tests + nyc coverage + heap pressure) it can take well over the default/30s
31
+ // even though it runs in ~1s locally. Give the hook generous headroom so it can't flake the
32
+ // whole build on suite growth (it still completes in seconds in practice).
33
+ this.timeout(120000);
34
+ // Each command in a mocked module is a constructor FUNCTION (not a class) — eslint forbids
35
+ // multiple class declarations in one file, so we capture the command name + input on `this`.
36
+ const cfCommand = (Name) => function CloudFrontCommand(input) {
37
+ this.input = input;
38
+ this.commandName = Name;
39
+ };
40
+ const iamCommand = (Name) => function IamCommand(input) {
41
+ this.input = input;
42
+ this.commandName = Name;
43
+ };
44
+ const lambdaCommand = (Name) => function LambdaCommand(input) {
45
+ this.input = input;
46
+ this.commandName = Name;
47
+ };
48
+ edgeOptimize = await esmock('../../../src/cdn/cloudfront/index.js', {
49
+ '@aws-sdk/client-sts': {
50
+ STSClient: function STSClient() {
51
+ this.send = (cmd) => stsSendStub(cmd);
52
+ },
53
+ AssumeRoleCommand: function AssumeRoleCommand(input) {
54
+ this.input = input;
55
+ },
56
+ },
57
+ '@aws-sdk/client-cloudfront': {
58
+ CloudFrontClient: function CloudFrontClient(config) {
59
+ this.config = config;
60
+ this.send = (cmd) => cfSendStub(cmd);
61
+ },
62
+ ListDistributionsCommand: cfCommand('ListDistributions'),
63
+ GetDistributionCommand: cfCommand('GetDistribution'),
64
+ GetDistributionConfigCommand: cfCommand('GetDistributionConfig'),
65
+ GetCachePolicyConfigCommand: cfCommand('GetCachePolicyConfig'),
66
+ GetCachePolicyCommand: cfCommand('GetCachePolicy'),
67
+ ListCachePoliciesCommand: cfCommand('ListCachePolicies'),
68
+ CreateCachePolicyCommand: cfCommand('CreateCachePolicy'),
69
+ UpdateCachePolicyCommand: cfCommand('UpdateCachePolicy'),
70
+ CreateFunctionCommand: cfCommand('CreateFunction'),
71
+ UpdateFunctionCommand: cfCommand('UpdateFunction'),
72
+ DescribeFunctionCommand: cfCommand('DescribeFunction'),
73
+ PublishFunctionCommand: cfCommand('PublishFunction'),
74
+ UpdateDistributionCommand: cfCommand('UpdateDistribution'),
75
+ },
76
+ '@aws-sdk/client-iam': {
77
+ IAMClient: function IAMClient(config) {
78
+ this.config = config;
79
+ this.send = (cmd) => iamSendStub(cmd);
80
+ },
81
+ CreateRoleCommand: iamCommand('CreateRole'),
82
+ GetRoleCommand: iamCommand('GetRole'),
83
+ GetRolePolicyCommand: iamCommand('GetRolePolicy'),
84
+ PutRolePolicyCommand: iamCommand('PutRolePolicy'),
85
+ UpdateAssumeRolePolicyCommand: iamCommand('UpdateAssumeRolePolicy'),
86
+ },
87
+ '@aws-sdk/client-lambda': {
88
+ LambdaClient: function LambdaClient(config) {
89
+ this.config = config;
90
+ this.send = (cmd) => lambdaSendStub(cmd);
91
+ },
92
+ CreateFunctionCommand: lambdaCommand('CreateFunction'),
93
+ UpdateFunctionCodeCommand: lambdaCommand('UpdateFunctionCode'),
94
+ GetFunctionConfigurationCommand: lambdaCommand('GetFunctionConfiguration'),
95
+ ListVersionsByFunctionCommand: lambdaCommand('ListVersionsByFunction'),
96
+ PublishVersionCommand: lambdaCommand('PublishVersion'),
97
+ },
98
+ });
99
+ });
100
+
101
+ beforeEach(() => {
102
+ // Fresh stubs per test; the esmocked clients read these `let` bindings at call time.
103
+ stsSendStub = sinon.stub();
104
+ cfSendStub = sinon.stub();
105
+ iamSendStub = sinon.stub();
106
+ lambdaSendStub = sinon.stub();
107
+ });
108
+
109
+ afterEach(() => {
110
+ sinon.restore();
111
+ });
112
+
113
+ describe('assumeConnectorRole', () => {
114
+ it('assumes the role and returns mapped credentials', async () => {
115
+ stsSendStub.resolves({
116
+ Credentials: {
117
+ AccessKeyId: 'AKIA',
118
+ SecretAccessKey: 'secret',
119
+ SessionToken: 'token',
120
+ Expiration: new Date('2030-01-01T00:00:00Z'),
121
+ },
122
+ });
123
+
124
+ const result = await edgeOptimize.assumeConnectorRole({
125
+ accountId: '120569600543',
126
+ externalId: 'ext-123',
127
+ });
128
+
129
+ expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/AdobeLLMOptimizerCloudFrontConnectorRole');
130
+ expect(result.accountId).to.equal('120569600543');
131
+ expect(result.credentials.accessKeyId).to.equal('AKIA');
132
+ expect(result.credentials.secretAccessKey).to.equal('secret');
133
+ expect(result.credentials.sessionToken).to.equal('token');
134
+ expect(stsSendStub.calledOnce).to.equal(true);
135
+ });
136
+
137
+ it('uses a custom role name when provided', async () => {
138
+ stsSendStub.resolves({
139
+ Credentials: { AccessKeyId: 'A', SecretAccessKey: 'S', SessionToken: 'T' },
140
+ });
141
+
142
+ const result = await edgeOptimize.assumeConnectorRole({
143
+ accountId: '120569600543',
144
+ externalId: 'ext',
145
+ roleName: 'CustomRole',
146
+ });
147
+
148
+ expect(result.roleArn).to.equal('arn:aws:iam::120569600543:role/CustomRole');
149
+ });
150
+
151
+ it('throws for an invalid account id', async () => {
152
+ let error;
153
+ try {
154
+ await edgeOptimize.assumeConnectorRole({ accountId: '123', externalId: 'ext' });
155
+ } catch (e) {
156
+ error = e;
157
+ }
158
+ expect(error).to.be.an('error');
159
+ expect(error.message).to.include('12-digit');
160
+ expect(stsSendStub.called).to.equal(false);
161
+ });
162
+
163
+ it('throws when the external id is missing', async () => {
164
+ let error;
165
+ try {
166
+ await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: '' });
167
+ } catch (e) {
168
+ error = e;
169
+ }
170
+ expect(error).to.be.an('error');
171
+ expect(error.message).to.include('externalId');
172
+ });
173
+
174
+ it('throws when STS returns no credentials', async () => {
175
+ stsSendStub.resolves({});
176
+ let error;
177
+ try {
178
+ await edgeOptimize.assumeConnectorRole({ accountId: '120569600543', externalId: 'ext' });
179
+ } catch (e) {
180
+ error = e;
181
+ }
182
+ expect(error).to.be.an('error');
183
+ expect(error.message).to.include('no credentials');
184
+ });
185
+ });
186
+
187
+ describe('listDistributions', () => {
188
+ it('maps the distribution list to the wizard projection', async () => {
189
+ cfSendStub.resolves({
190
+ DistributionList: {
191
+ Items: [
192
+ {
193
+ Id: 'E123',
194
+ DomainName: 'd.cloudfront.net',
195
+ Aliases: { Items: ['www.example.com'] },
196
+ Status: 'Deployed',
197
+ Enabled: true,
198
+ Comment: 'prod',
199
+ },
200
+ ],
201
+ },
202
+ });
203
+
204
+ const result = await edgeOptimize.listDistributions({
205
+ accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T',
206
+ });
207
+
208
+ expect(result).to.have.length(1);
209
+ expect(result[0]).to.deep.equal({
210
+ id: 'E123',
211
+ domainName: 'd.cloudfront.net',
212
+ aliases: ['www.example.com'],
213
+ status: 'Deployed',
214
+ enabled: true,
215
+ comment: 'prod',
216
+ });
217
+ });
218
+
219
+ it('returns an empty array when there are no distributions', async () => {
220
+ cfSendStub.resolves({ DistributionList: {} });
221
+
222
+ const result = await edgeOptimize.listDistributions({});
223
+
224
+ expect(result).to.deep.equal([]);
225
+ });
226
+
227
+ it('defaults aliases and comment when absent and reflects disabled state', async () => {
228
+ cfSendStub.resolves({
229
+ DistributionList: {
230
+ Items: [{
231
+ Id: 'E2', DomainName: 'd2.cloudfront.net', Status: 'InProgress', Enabled: false,
232
+ }],
233
+ },
234
+ });
235
+
236
+ const result = await edgeOptimize.listDistributions({});
237
+
238
+ expect(result[0].aliases).to.deep.equal([]);
239
+ expect(result[0].comment).to.equal('');
240
+ expect(result[0].enabled).to.equal(false);
241
+ });
242
+
243
+ it('paginates through every page when the result is truncated', async () => {
244
+ // First page is truncated (IsTruncated + NextMarker) → a second ListDistributions call must
245
+ // follow the marker; the loop stops once IsTruncated is false. Both pages are aggregated.
246
+ cfSendStub.onFirstCall().resolves({
247
+ DistributionList: {
248
+ IsTruncated: true,
249
+ NextMarker: 'page-2',
250
+ Items: [{
251
+ Id: 'E1', DomainName: 'd1.cloudfront.net', Status: 'Deployed', Enabled: true,
252
+ }],
253
+ },
254
+ });
255
+ cfSendStub.onSecondCall().resolves({
256
+ DistributionList: {
257
+ IsTruncated: false,
258
+ Items: [{
259
+ Id: 'E2', DomainName: 'd2.cloudfront.net', Status: 'Deployed', Enabled: true,
260
+ }],
261
+ },
262
+ });
263
+
264
+ const result = await edgeOptimize.listDistributions({});
265
+
266
+ expect(cfSendStub.callCount).to.equal(2);
267
+ // The first page is fetched with no Marker; the second follows the NextMarker.
268
+ expect(cfSendStub.firstCall.args[0].input).to.deep.equal({});
269
+ expect(cfSendStub.secondCall.args[0].input).to.deep.equal({ Marker: 'page-2' });
270
+ expect(result.map((d) => d.id)).to.deep.equal(['E1', 'E2']);
271
+ });
272
+ });
273
+
274
+ describe('getDistributionConfig', () => {
275
+ it('maps origins, default cache behavior, and ordered cache behaviors', async () => {
276
+ cfSendStub.resolves({
277
+ DistributionConfig: {
278
+ Origins: {
279
+ Items: [
280
+ { Id: 'origin-aem', DomainName: 'origin.example.com', OriginPath: '/content' },
281
+ { Id: 'EdgeOptimizeOrigin', DomainName: 'live.edgeoptimize.net' },
282
+ ],
283
+ },
284
+ DefaultCacheBehavior: { TargetOriginId: 'origin-aem' },
285
+ CacheBehaviors: {
286
+ Items: [
287
+ { PathPattern: '/api/*', TargetOriginId: 'origin-aem' },
288
+ ],
289
+ },
290
+ },
291
+ });
292
+
293
+ const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE');
294
+
295
+ expect(cfSendStub.calledOnce).to.equal(true);
296
+ expect(cfSendStub.firstCall.args[0].input).to.deep.equal({ Id: 'E2EXAMPLE' });
297
+ expect(result.origins).to.deep.equal([
298
+ { id: 'origin-aem', domainName: 'origin.example.com', originPath: '/content' },
299
+ { id: 'EdgeOptimizeOrigin', domainName: 'live.edgeoptimize.net', originPath: '' },
300
+ ]);
301
+ expect(result.defaultCacheBehavior).to.deep.equal({
302
+ pathPattern: 'Default (*)',
303
+ targetOriginId: 'origin-aem',
304
+ });
305
+ expect(result.cacheBehaviors).to.deep.equal([
306
+ { pathPattern: '/api/*', targetOriginId: 'origin-aem' },
307
+ ]);
308
+ });
309
+
310
+ it('defaults to empty collections when the config is sparse', async () => {
311
+ cfSendStub.resolves({ DistributionConfig: {} });
312
+
313
+ const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE');
314
+
315
+ expect(result.origins).to.deep.equal([]);
316
+ expect(result.defaultCacheBehavior).to.equal(null);
317
+ expect(result.cacheBehaviors).to.deep.equal([]);
318
+ });
319
+
320
+ it('falls back to an empty config object when DistributionConfig is absent', async () => {
321
+ cfSendStub.resolves({});
322
+
323
+ const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE');
324
+
325
+ expect(result.origins).to.deep.equal([]);
326
+ expect(result.defaultCacheBehavior).to.equal(null);
327
+ expect(result.cacheBehaviors).to.deep.equal([]);
328
+ });
329
+
330
+ it('falls back to an empty list when the SDK returns nothing', async () => {
331
+ cfSendStub.resolves(undefined);
332
+
333
+ const result = await edgeOptimize.getDistributionConfig({}, 'E2EXAMPLE');
334
+
335
+ expect(result.origins).to.deep.equal([]);
336
+ });
337
+
338
+ it('throws when the distribution id is missing', async () => {
339
+ let error;
340
+ try {
341
+ await edgeOptimize.getDistributionConfig({}, '');
342
+ } catch (e) {
343
+ error = e;
344
+ }
345
+ expect(error).to.be.an('error');
346
+ expect(error.message).to.include('distributionId');
347
+ expect(cfSendStub.called).to.equal(false);
348
+ });
349
+ });
350
+
351
+ describe('listDistributions edge cases', () => {
352
+ it('falls back to an empty list when the SDK returns nothing', async () => {
353
+ cfSendStub.resolves(undefined);
354
+
355
+ const result = await edgeOptimize.listDistributions({});
356
+
357
+ expect(result).to.deep.equal([]);
358
+ });
359
+ });
360
+
361
+ describe('CloudFrontEdgeClient', () => {
362
+ it('requires credentials', () => {
363
+ expect(() => new edgeOptimize.CloudFrontEdgeClient()).to.throw('credentials are required');
364
+ });
365
+
366
+ it('stores credentials once and delegates through generic methods', async () => {
367
+ cfSendStub.resolves({
368
+ DistributionList: {
369
+ Items: [{
370
+ Id: 'E123',
371
+ DomainName: 'd.cloudfront.net',
372
+ Status: 'Deployed',
373
+ Enabled: true,
374
+ }],
375
+ },
376
+ });
377
+ const credentials = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' };
378
+ const client = new edgeOptimize.CloudFrontEdgeClient({
379
+ credentials,
380
+ region: 'us-west-2',
381
+ });
382
+
383
+ const result = await client.listDistributions();
384
+
385
+ expect(client.credentials).to.equal(credentials);
386
+ expect(client.region).to.equal('us-west-2');
387
+ expect(result.map((d) => d.id)).to.deep.equal(['E123']);
388
+ expect(cfSendStub.calledOnce).to.equal(true);
389
+ });
390
+ });
391
+
392
+ describe('CloudFrontEdgeClient method delegation', () => {
393
+ const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' };
394
+ const client = () => new edgeOptimize.CloudFrontEdgeClient({ credentials: creds, region: 'us-east-1' });
395
+
396
+ // Dispatch a *SendStub by command name (robust to call order / poll counts).
397
+ const dispatch = (stub, map) => stub.callsFake((cmd) => {
398
+ const r = map[cmd.commandName];
399
+ if (r === undefined) {
400
+ throw new Error(`unexpected command in test: ${cmd.commandName}`);
401
+ }
402
+ return Promise.resolve(typeof r === 'function' ? r(cmd) : r);
403
+ });
404
+ const throwNamed = (name) => () => {
405
+ const e = new Error(name);
406
+ e.name = name;
407
+ throw e;
408
+ };
409
+ const validTrust = encodeURIComponent(JSON.stringify({
410
+ Version: '2012-10-17',
411
+ Statement: [{
412
+ Effect: 'Allow',
413
+ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] },
414
+ Action: 'sts:AssumeRole',
415
+ }],
416
+ }));
417
+ const okRoleIam = {
418
+ GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: validTrust } },
419
+ GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' },
420
+ UpdateAssumeRolePolicy: {},
421
+ PutRolePolicy: {},
422
+ };
423
+ const deployParams = {
424
+ distributionId: 'E2EXAMPLE123',
425
+ originId: 'origin-aem',
426
+ behavior: 'default',
427
+ originDomain: 'dev.edgeoptimize.net',
428
+ originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' },
429
+ accountId: '120569600543',
430
+ };
431
+
432
+ it('getDistributionConfig delegates to the free function', async () => {
433
+ dispatch(cfSendStub, {
434
+ GetDistributionConfig: { DistributionConfig: { Origins: { Items: [{ Id: 'o1', DomainName: 'x' }] } } },
435
+ });
436
+ const res = await client().getDistributionConfig('E1');
437
+ expect(res.origins).to.deep.equal([{ id: 'o1', domainName: 'x', originPath: '' }]);
438
+ });
439
+
440
+ it('createOrigin delegates to the free function', async () => {
441
+ dispatch(cfSendStub, {
442
+ GetDistributionConfig: { DistributionConfig: { Origins: { Quantity: 0, Items: [] } }, ETag: 'e1' },
443
+ UpdateDistribution: {},
444
+ });
445
+ const res = await client().createOrigin('E1', 'dev.edgeoptimize.net', {
446
+ apiKey: 'k', forwardedHost: 'h',
447
+ });
448
+ expect(res).to.deep.equal({
449
+ created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin',
450
+ });
451
+ });
452
+
453
+ it('createCloudFrontFunction delegates to the free function', async () => {
454
+ dispatch(cfSendStub, {
455
+ DescribeFunction: throwNamed('NoSuchFunctionExists'),
456
+ CreateFunction: { ETag: 'fn-etag' },
457
+ PublishFunction: {},
458
+ });
459
+ const res = await client().createCloudFrontFunction('origin-aem', 'E1');
460
+ expect(res.created).to.equal(true);
461
+ expect(res.stage).to.equal('LIVE');
462
+ });
463
+
464
+ it('updateCacheSettings delegates to the free function', async () => {
465
+ dispatch(cfSendStub, {
466
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } },
467
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
468
+ GetCachePolicyConfig: {
469
+ CachePolicyConfig: {
470
+ Name: 'p',
471
+ MinTTL: 60,
472
+ ParametersInCacheKeyAndForwardedToOrigin: {
473
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 0, Items: [] } },
474
+ },
475
+ },
476
+ ETag: 'cp-etag',
477
+ },
478
+ UpdateCachePolicy: {},
479
+ });
480
+ const res = await client().updateCacheSettings('E1', 'default');
481
+ expect(res.scenario).to.equal('custom');
482
+ expect(res.updated).to.equal(true);
483
+ });
484
+
485
+ it('createLambdaAtEdge delegates to the free function', async () => {
486
+ dispatch(iamSendStub, {
487
+ GetRole: throwNamed('NoSuchEntityException'),
488
+ CreateRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } },
489
+ PutRolePolicy: {},
490
+ });
491
+ dispatch(lambdaSendStub, {
492
+ GetFunctionConfiguration: throwNamed('ResourceNotFoundException'),
493
+ CreateFunction: { FunctionArn: 'arn:fn' },
494
+ });
495
+ const res = await client().createLambdaAtEdge('120569600543', { distributionId: 'E1' });
496
+ expect(res.status).to.equal('provisioning');
497
+ expect(res.roleArn).to.include('edgeoptimize-origin-role');
498
+ });
499
+
500
+ it('getLambdaAtEdgeStatus delegates to the free function', async () => {
501
+ dispatch(iamSendStub, { GetRole: throwNamed('NoSuchEntityException') });
502
+ dispatch(lambdaSendStub, { GetFunctionConfiguration: throwNamed('ResourceNotFoundException') });
503
+ const res = await client().getLambdaAtEdgeStatus('E1');
504
+ expect(res).to.deep.equal({
505
+ roleExists: false, roleOk: false, exists: false, versionArn: null, ready: false,
506
+ });
507
+ });
508
+
509
+ it('applyAssociations delegates to the free function', async () => {
510
+ const lambdaArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1';
511
+ dispatch(cfSendStub, {
512
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
513
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: {} }, ETag: 'dist-etag' },
514
+ UpdateDistribution: {},
515
+ });
516
+ const res = await client().applyAssociations('E1', 'default', lambdaArn);
517
+ expect(res).to.deep.equal({ cloudFrontFunctionArn: 'arn:cf-fn', lambdaArn });
518
+ });
519
+
520
+ it('runDeployStep delegates to the free function', async () => {
521
+ dispatch(cfSendStub, {
522
+ GetDistributionConfig: {
523
+ DistributionConfig: {
524
+ Origins: {
525
+ Items: [{
526
+ Id: 'EdgeOptimize_Origin',
527
+ DomainName: 'dev.edgeoptimize.net',
528
+ CustomHeaders: {
529
+ Items: [
530
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
531
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
532
+ ],
533
+ },
534
+ }],
535
+ },
536
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
537
+ },
538
+ ETag: 'etag',
539
+ },
540
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
541
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
542
+ GetCachePolicyConfig: {
543
+ CachePolicyConfig: {
544
+ Name: 'p',
545
+ MinTTL: 0,
546
+ ParametersInCacheKeyAndForwardedToOrigin: {
547
+ HeadersConfig: {
548
+ HeaderBehavior: 'whitelist',
549
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
550
+ },
551
+ },
552
+ },
553
+ ETag: 'cp-etag',
554
+ },
555
+ });
556
+ dispatch(lambdaSendStub, {
557
+ GetFunctionConfiguration: throwNamed('ResourceNotFoundException'),
558
+ ListVersionsByFunction: { Versions: [] },
559
+ CreateFunction: { FunctionArn: 'arn:lambda', Version: '$LATEST' },
560
+ });
561
+ dispatch(iamSendStub, okRoleIam);
562
+ const res = await client().runDeployStep(deployParams);
563
+ expect(res.steps.find((s) => s.key === 'origin').status).to.equal('done');
564
+ expect(res.steps.find((s) => s.key === 'lambda').status).to.equal('in_progress');
565
+ expect(res.routingDeployed).to.equal(false);
566
+ });
567
+
568
+ it('planDeploy delegates to the free function', async () => {
569
+ dispatch(cfSendStub, {
570
+ GetDistributionConfig: {
571
+ DistributionConfig: {
572
+ Origins: { Items: [] },
573
+ DefaultCacheBehavior: {
574
+ ForwardedValues: { Headers: { Quantity: 0, Items: [] } },
575
+ MinTTL: 60,
576
+ },
577
+ },
578
+ },
579
+ DescribeFunction: throwNamed('NoSuchFunctionExists'),
580
+ });
581
+ dispatch(lambdaSendStub, { GetFunctionConfiguration: throwNamed('ResourceNotFoundException') });
582
+ dispatch(iamSendStub, { GetRole: throwNamed('NoSuchEntityException') });
583
+ const res = await client().planDeploy(deployParams);
584
+ expect(res.canProceed).to.equal(true);
585
+ expect(res.steps.map((s) => s.key)).to.deep.equal(['origin', 'function', 'cache', 'lambda', 'associate']);
586
+ });
587
+ });
588
+
589
+ describe('createOrigin', () => {
590
+ it('adds the Edge Optimize origin when it does not exist', async () => {
591
+ cfSendStub.onFirstCall().resolves({
592
+ DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } },
593
+ ETag: 'etag-1',
594
+ });
595
+ cfSendStub.onSecondCall().resolves({});
596
+
597
+ const result = await edgeOptimize.createOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net');
598
+
599
+ expect(result).to.deep.equal({
600
+ created: true, alreadyExisted: false, updated: false, originId: 'EdgeOptimize_Origin',
601
+ });
602
+ expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution');
603
+ const update = cfSendStub.secondCall.args[0].input;
604
+ expect(update.IfMatch).to.equal('etag-1');
605
+ const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin');
606
+ expect(added.DomainName).to.equal('dev.edgeoptimize.net');
607
+ expect(added.CustomOriginConfig.OriginProtocolPolicy).to.equal('https-only');
608
+ });
609
+
610
+ it('defaults the origin domain to the production EO domain', async () => {
611
+ cfSendStub.onFirstCall().resolves({
612
+ DistributionConfig: { Origins: { Items: [] } },
613
+ ETag: 'etag-1',
614
+ });
615
+ cfSendStub.onSecondCall().resolves({});
616
+
617
+ await edgeOptimize.createOrigin({}, 'E2EXAMPLE');
618
+
619
+ const added = cfSendStub.secondCall.args[0].input
620
+ .DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin');
621
+ expect(added.DomainName).to.equal('live.edgeoptimize.net');
622
+ });
623
+
624
+ it('handles an absent Origins collection on the config', async () => {
625
+ cfSendStub.onFirstCall().resolves({ DistributionConfig: {}, ETag: 'etag-1' });
626
+ cfSendStub.onSecondCall().resolves({});
627
+
628
+ const result = await edgeOptimize.createOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net');
629
+
630
+ expect(result.created).to.equal(true);
631
+ });
632
+
633
+ it('sets the EO custom headers on the new origin', async () => {
634
+ cfSendStub.onFirstCall().resolves({
635
+ DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'origin-aem', DomainName: 'origin.example.com' }] } },
636
+ ETag: 'etag-1',
637
+ });
638
+ cfSendStub.onSecondCall().resolves({});
639
+
640
+ await edgeOptimize.createOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', {
641
+ apiKey: 'eo-key-123', forwardedHost: 'www.example.com', fetcherKey: 'fk-9',
642
+ });
643
+
644
+ const update = cfSendStub.secondCall.args[0].input;
645
+ const added = update.DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin');
646
+ expect(added.CustomHeaders.Quantity).to.equal(3);
647
+ const headerMap = added.CustomHeaders.Items.reduce((acc, h) => {
648
+ acc[h.HeaderName] = h.HeaderValue;
649
+ return acc;
650
+ }, {});
651
+ expect(headerMap).to.deep.equal({
652
+ 'x-edgeoptimize-api-key': 'eo-key-123',
653
+ 'x-forwarded-host': 'www.example.com',
654
+ 'x-edgeoptimize-fetcher-key': 'fk-9',
655
+ });
656
+ });
657
+
658
+ it('is idempotent when the origin already exists by id', async () => {
659
+ cfSendStub.resolves({
660
+ DistributionConfig: { Origins: { Quantity: 1, Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'x' }] } },
661
+ ETag: 'etag-1',
662
+ });
663
+
664
+ const result = await edgeOptimize.createOrigin({}, 'E2EXAMPLE');
665
+
666
+ expect(result).to.deep.equal({
667
+ created: false, alreadyExisted: true, updated: false, originId: 'EdgeOptimize_Origin',
668
+ });
669
+ expect(cfSendStub.calledOnce).to.equal(true); // never updated
670
+ });
671
+
672
+ it('stops when a non-Edge Optimize origin already uses the EO domain', async () => {
673
+ cfSendStub.resolves({
674
+ DistributionConfig: { Origins: { Items: [{ Id: 'custom', DomainName: 'dev.edgeoptimize.net' }] } },
675
+ ETag: 'etag-1',
676
+ });
677
+
678
+ let error;
679
+ try {
680
+ await edgeOptimize.createOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net');
681
+ } catch (e) {
682
+ error = e;
683
+ }
684
+
685
+ expect(error.message).to.include('refusing to reuse a non-Edge Optimize origin');
686
+ expect(cfSendStub.calledOnce).to.equal(true);
687
+ });
688
+
689
+ it('patches the headers when the origin exists without them (self-heal)', async () => {
690
+ cfSendStub.onFirstCall().resolves({
691
+ DistributionConfig: {
692
+ Origins: {
693
+ Quantity: 1,
694
+ Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net', CustomHeaders: { Quantity: 0, Items: [] } }],
695
+ },
696
+ },
697
+ ETag: 'etag-1',
698
+ });
699
+ cfSendStub.onSecondCall().resolves({});
700
+
701
+ const result = await edgeOptimize.createOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', {
702
+ apiKey: 'eo-key-123', forwardedHost: 'www.example.com',
703
+ });
704
+
705
+ expect(result).to.deep.equal({
706
+ created: false, alreadyExisted: true, updated: true, originId: 'EdgeOptimize_Origin',
707
+ });
708
+ expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateDistribution');
709
+ const patched = cfSendStub.secondCall.args[0].input
710
+ .DistributionConfig.Origins.Items.find((o) => o.Id === 'EdgeOptimize_Origin');
711
+ expect(patched.CustomHeaders.Quantity).to.equal(2);
712
+ });
713
+
714
+ it('does not patch when the existing headers already match', async () => {
715
+ cfSendStub.resolves({
716
+ DistributionConfig: {
717
+ Origins: {
718
+ Quantity: 1,
719
+ Items: [{
720
+ Id: 'EdgeOptimize_Origin',
721
+ DomainName: 'dev.edgeoptimize.net',
722
+ CustomHeaders: {
723
+ Quantity: 2,
724
+ Items: [
725
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key-123' },
726
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
727
+ ],
728
+ },
729
+ }],
730
+ },
731
+ },
732
+ ETag: 'etag-1',
733
+ });
734
+
735
+ const result = await edgeOptimize.createOrigin({}, 'E2EXAMPLE', 'dev.edgeoptimize.net', {
736
+ apiKey: 'eo-key-123', forwardedHost: 'www.example.com',
737
+ });
738
+
739
+ expect(result.updated).to.equal(false);
740
+ expect(cfSendStub.calledOnce).to.equal(true); // no UpdateDistribution
741
+ });
742
+
743
+ it('throws when the distribution id is missing', async () => {
744
+ let error;
745
+ try {
746
+ await edgeOptimize.createOrigin({}, '');
747
+ } catch (e) {
748
+ error = e;
749
+ }
750
+ expect(error.message).to.include('distributionId');
751
+ expect(cfSendStub.called).to.equal(false);
752
+ });
753
+ });
754
+
755
+ describe('buildCloudfrontFunctionCode', () => {
756
+ it('embeds the default origin id and null targeted paths', () => {
757
+ const code = edgeOptimize.buildCloudfrontFunctionCode('origin-aem');
758
+ expect(code).to.include('{ "originId": "origin-aem" }');
759
+ expect(code).to.include('var TARGETED_PATHS = null;');
760
+ expect(code).to.include("import cf from 'cloudfront';");
761
+ });
762
+
763
+ it('embeds explicit targeted paths as JSON', () => {
764
+ const code = edgeOptimize.buildCloudfrontFunctionCode('origin-aem', ['/a', '/b']);
765
+ expect(code).to.include('var TARGETED_PATHS = ["/a","/b"];');
766
+ });
767
+
768
+ it('escapes the default origin id as a JavaScript string literal', () => {
769
+ const code = edgeOptimize.buildCloudfrontFunctionCode('origin-aem"; throw new Error("x")//');
770
+
771
+ expect(code).to.include('{ "originId": "origin-aem\\"; throw new Error(\\"x\\")//" }');
772
+ });
773
+ });
774
+
775
+ describe('buildEdgeOptimizeLambdaCode', () => {
776
+ it('bakes the EO origin domain into the routing check (per environment)', () => {
777
+ const dev = edgeOptimize.buildEdgeOptimizeLambdaCode('dev.edgeoptimize.net');
778
+ expect(dev).to.include('originDomain === "dev.edgeoptimize.net"');
779
+ expect(dev).to.not.include('originDomain === "live.edgeoptimize.net"');
780
+
781
+ const prod = edgeOptimize.buildEdgeOptimizeLambdaCode('live.edgeoptimize.net');
782
+ expect(prod).to.include('originDomain === "live.edgeoptimize.net"');
783
+ });
784
+
785
+ it('escapes the EO origin domain as a JavaScript string literal', () => {
786
+ const code = edgeOptimize.buildEdgeOptimizeLambdaCode('live.edgeoptimize.net"; throw new Error("x")//');
787
+
788
+ expect(code).to.include('originDomain === "live.edgeoptimize.net\\"; throw new Error(\\"x\\")//"');
789
+ });
790
+ });
791
+
792
+ describe('createCloudFrontFunction', () => {
793
+ it('creates and publishes a new function when none exists', async () => {
794
+ cfSendStub.onFirstCall().rejects(Object.assign(new Error('not found'), { name: 'NoSuchFunctionExists' }));
795
+ cfSendStub.onSecondCall().resolves({ ETag: 'fn-etag' }); // CreateFunction
796
+ cfSendStub.onThirdCall().resolves({}); // PublishFunction
797
+
798
+ const result = await edgeOptimize.createCloudFrontFunction({}, 'origin-aem', 'E2EXAMPLE');
799
+
800
+ expect(result).to.deep.equal({ name: 'edgeoptimize-routing-adobe-E2EXAMPLE', created: true, stage: 'LIVE' });
801
+ expect(cfSendStub.secondCall.args[0].commandName).to.equal('CreateFunction');
802
+ expect(cfSendStub.thirdCall.args[0].commandName).to.equal('PublishFunction');
803
+ expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('fn-etag');
804
+ });
805
+
806
+ it('updates and publishes when the function already exists', async () => {
807
+ cfSendStub.onFirstCall().resolves({ ETag: 'dev-etag' }); // DescribeFunction DEVELOPMENT
808
+ cfSendStub.onSecondCall().resolves({ ETag: 'updated-etag' }); // UpdateFunction
809
+ cfSendStub.onThirdCall().resolves({}); // PublishFunction
810
+
811
+ const result = await edgeOptimize.createCloudFrontFunction({}, 'origin-aem', 'E2EXAMPLE');
812
+
813
+ expect(result.created).to.equal(false);
814
+ expect(cfSendStub.secondCall.args[0].commandName).to.equal('UpdateFunction');
815
+ expect(cfSendStub.thirdCall.args[0].input.IfMatch).to.equal('updated-etag');
816
+ });
817
+
818
+ it('throws when defaultOriginId is missing', async () => {
819
+ let error;
820
+ try {
821
+ await edgeOptimize.createCloudFrontFunction({}, '');
822
+ } catch (e) {
823
+ error = e;
824
+ }
825
+ expect(error.message).to.include('defaultOriginId');
826
+ expect(cfSendStub.called).to.equal(false);
827
+ });
828
+
829
+ it('throws when distributionId is missing', async () => {
830
+ let error;
831
+ try {
832
+ await edgeOptimize.createCloudFrontFunction({}, 'origin-aem', '');
833
+ } catch (e) {
834
+ error = e;
835
+ }
836
+ expect(error.message).to.include('distributionId');
837
+ expect(cfSendStub.called).to.equal(false);
838
+ });
839
+
840
+ it('rethrows unexpected describe errors', async () => {
841
+ cfSendStub.onFirstCall().rejects(new Error('boom'));
842
+ let error;
843
+ try {
844
+ await edgeOptimize.createCloudFrontFunction({}, 'origin-aem', 'E2EXAMPLE');
845
+ } catch (e) {
846
+ error = e;
847
+ }
848
+ expect(error.message).to.equal('boom');
849
+ });
850
+ });
851
+
852
+ describe('updateCacheSettings', () => {
853
+ // Dispatch cfSendStub by command name so tests are robust to call order.
854
+ const wireCloudFront = (responders) => {
855
+ cfSendStub.callsFake((cmd) => {
856
+ const fn = responders[cmd.commandName];
857
+ if (!fn) {
858
+ throw new Error(`unexpected command in test: ${cmd.commandName}`);
859
+ }
860
+ return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn);
861
+ });
862
+ };
863
+
864
+ const lastCommand = (name) => cfSendStub.getCalls()
865
+ .filter((c) => c.args[0].commandName === name).pop()?.args[0];
866
+
867
+ it('updates a CUSTOM policy to add the EO headers + MinTTL 0', async () => {
868
+ wireCloudFront({
869
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } },
870
+ ListCachePolicies: { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-x' } }] } },
871
+ GetCachePolicyConfig: {
872
+ CachePolicyConfig: {
873
+ Name: 'my-policy',
874
+ MinTTL: 60,
875
+ ParametersInCacheKeyAndForwardedToOrigin: {
876
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 1, Items: ['accept'] } },
877
+ },
878
+ },
879
+ ETag: 'cp-etag',
880
+ },
881
+ UpdateCachePolicy: {},
882
+ });
883
+
884
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
885
+
886
+ expect(result.scenario).to.equal('custom');
887
+ expect(result.policyId).to.equal('cp-1');
888
+ expect(result.updated).to.equal(true);
889
+ const updated = lastCommand('UpdateCachePolicy').input.CachePolicyConfig;
890
+ expect(updated.MinTTL).to.equal(0);
891
+ const items = updated.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items;
892
+ expect(items).to.include('x-edgeoptimize-config');
893
+ expect(items).to.include('x-edgeoptimize-url');
894
+ });
895
+
896
+ it('updates a CUSTOM policy MinTTL only when the headers are already present', async () => {
897
+ wireCloudFront({
898
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } },
899
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
900
+ GetCachePolicyConfig: {
901
+ CachePolicyConfig: {
902
+ Name: 'my-policy',
903
+ MinTTL: 99,
904
+ ParametersInCacheKeyAndForwardedToOrigin: {
905
+ HeadersConfig: {
906
+ HeaderBehavior: 'whitelist',
907
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
908
+ },
909
+ },
910
+ },
911
+ ETag: 'cp-etag',
912
+ },
913
+ UpdateCachePolicy: {},
914
+ });
915
+
916
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
917
+
918
+ expect(result.updated).to.equal(true);
919
+ const updated = lastCommand('UpdateCachePolicy').input.CachePolicyConfig;
920
+ expect(updated.MinTTL).to.equal(0);
921
+ });
922
+
923
+ it('updates a sparse CUSTOM policy (no managed list, no params, no MinTTL)', async () => {
924
+ wireCloudFront({
925
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } },
926
+ // managed list response has no CachePolicyList → `?.Items || []` (managedIds empty).
927
+ ListCachePolicies: {},
928
+ // custom policy with NO ParametersInCacheKeyAndForwardedToOrigin and NO MinTTL.
929
+ GetCachePolicyConfig: { CachePolicyConfig: { Name: 'bare' }, ETag: 'cp-etag' },
930
+ UpdateCachePolicy: {},
931
+ });
932
+
933
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
934
+
935
+ expect(result.scenario).to.equal('custom');
936
+ expect(result.updated).to.equal(true);
937
+ const updated = lastCommand('UpdateCachePolicy').input.CachePolicyConfig;
938
+ const items = updated.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items;
939
+ expect(items).to.include('x-edgeoptimize-config');
940
+ });
941
+
942
+ it('does not add headers to a CUSTOM policy with HeaderBehavior allViewer', async () => {
943
+ wireCloudFront({
944
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } },
945
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
946
+ GetCachePolicyConfig: {
947
+ CachePolicyConfig: {
948
+ Name: 'my-policy',
949
+ MinTTL: 0,
950
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'allViewer' } },
951
+ },
952
+ ETag: 'cp-etag',
953
+ },
954
+ });
955
+
956
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
957
+
958
+ // allViewer already forwards all headers + MinTTL already 0 → nothing to do.
959
+ expect(result.updated).to.equal(false);
960
+ expect(result.alreadyForwarded).to.equal(true);
961
+ expect(lastCommand('UpdateCachePolicy')).to.equal(undefined);
962
+ });
963
+
964
+ it('respects setMinTTLZero:false (keeps a long MinTTL on a custom policy)', async () => {
965
+ wireCloudFront({
966
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } },
967
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
968
+ GetCachePolicyConfig: {
969
+ CachePolicyConfig: {
970
+ Name: 'my-policy',
971
+ MinTTL: 9999,
972
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } },
973
+ },
974
+ ETag: 'cp-etag',
975
+ },
976
+ UpdateCachePolicy: {},
977
+ });
978
+
979
+ await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default', { setMinTTLZero: false });
980
+
981
+ const updated = lastCommand('UpdateCachePolicy').input.CachePolicyConfig;
982
+ expect(updated.MinTTL).to.equal(9999); // untouched
983
+ });
984
+
985
+ it('is a no-op when a custom policy already forwards the headers and MinTTL is 0', async () => {
986
+ wireCloudFront({
987
+ GetDistributionConfig: { DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp-1' } } },
988
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
989
+ GetCachePolicyConfig: {
990
+ CachePolicyConfig: {
991
+ Name: 'my-policy',
992
+ MinTTL: 0,
993
+ ParametersInCacheKeyAndForwardedToOrigin: {
994
+ HeadersConfig: {
995
+ HeaderBehavior: 'whitelist',
996
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
997
+ },
998
+ },
999
+ },
1000
+ ETag: 'cp-etag',
1001
+ },
1002
+ });
1003
+
1004
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1005
+
1006
+ expect(result).to.deep.equal({
1007
+ scenario: 'custom', policyId: 'cp-1', updated: false, alreadyForwarded: true,
1008
+ });
1009
+ expect(lastCommand('UpdateCachePolicy')).to.equal(undefined); // never updated
1010
+ });
1011
+
1012
+ it('CLONES an AWS-managed policy into a per-distribution custom policy and repoints the behavior', async () => {
1013
+ wireCloudFront({
1014
+ GetDistributionConfig: {
1015
+ DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1', ForwardedValues: { x: 1 } } },
1016
+ ETag: 'dist-etag',
1017
+ },
1018
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
1019
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
1020
+ : { CachePolicyList: { Items: [] } }), // no existing custom edgeoptimize-cache
1021
+ GetCachePolicy: {
1022
+ CachePolicy: {
1023
+ CachePolicyConfig: {
1024
+ Name: 'Managed-CachingOptimized',
1025
+ MinTTL: 86400,
1026
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } },
1027
+ },
1028
+ },
1029
+ },
1030
+ CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } },
1031
+ UpdateDistribution: {},
1032
+ });
1033
+
1034
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1035
+
1036
+ expect(result.scenario).to.equal('managed');
1037
+ expect(result.policyId).to.equal('new-eo-policy');
1038
+ expect(result.reused).to.equal(false);
1039
+ const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig;
1040
+ expect(created.Name).to.equal('CachingOptimized-adobe-E2EXAMPLE');
1041
+ expect(created.MinTTL).to.equal(0);
1042
+ const items = created.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items;
1043
+ expect(items).to.include('x-edgeoptimize-config');
1044
+ // behavior repointed to the new policy + ForwardedValues removed
1045
+ const cfg = lastCommand('UpdateDistribution').input.DistributionConfig;
1046
+ expect(cfg.DefaultCacheBehavior.CachePolicyId).to.equal('new-eo-policy');
1047
+ expect(cfg.DefaultCacheBehavior.ForwardedValues).to.equal(undefined);
1048
+ });
1049
+
1050
+ it('keeps a short MinTTL (<=5s) when cloning a managed policy instead of forcing it to 0', async () => {
1051
+ wireCloudFront({
1052
+ GetDistributionConfig: {
1053
+ DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } },
1054
+ ETag: 'dist-etag',
1055
+ },
1056
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
1057
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
1058
+ : { CachePolicyList: { Items: [] } }),
1059
+ GetCachePolicy: {
1060
+ CachePolicy: {
1061
+ CachePolicyConfig: {
1062
+ Name: 'Managed-CachingOptimized',
1063
+ MinTTL: 3,
1064
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } },
1065
+ },
1066
+ },
1067
+ },
1068
+ CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } },
1069
+ UpdateDistribution: {},
1070
+ });
1071
+
1072
+ await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1073
+
1074
+ const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig;
1075
+ expect(created.MinTTL).to.equal(3); // <= 5s kept, not zeroed
1076
+ });
1077
+
1078
+ it('clones a managed policy with sparse reads (no params, no custom list)', async () => {
1079
+ wireCloudFront({
1080
+ GetDistributionConfig: {
1081
+ DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } },
1082
+ ETag: 'dist-etag',
1083
+ },
1084
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
1085
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
1086
+ : {}), // custom list response has no CachePolicyList → `?.Items || []` fallback
1087
+ GetCachePolicy: {
1088
+ // source has NO ParametersInCacheKeyAndForwardedToOrigin → `cloned... || {}` fallback
1089
+ CachePolicy: { CachePolicyConfig: { Name: 'Managed-Basic' } },
1090
+ },
1091
+ CreateCachePolicy: { CachePolicy: { Id: 'new-eo-policy' } },
1092
+ UpdateDistribution: {},
1093
+ });
1094
+
1095
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1096
+
1097
+ expect(result.scenario).to.equal('managed');
1098
+ expect(result.reused).to.equal(false);
1099
+ const created = lastCommand('CreateCachePolicy').input.CachePolicyConfig;
1100
+ const items = created.ParametersInCacheKeyAndForwardedToOrigin.HeadersConfig.Headers.Items;
1101
+ expect(items).to.include('x-edgeoptimize-config');
1102
+ });
1103
+
1104
+ it('reuses an existing edgeoptimize-cache custom policy (idempotent managed path)', async () => {
1105
+ wireCloudFront({
1106
+ GetDistributionConfig: {
1107
+ DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'managed-1' } },
1108
+ ETag: 'dist-etag',
1109
+ },
1110
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
1111
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
1112
+ : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'existing-eo', CachePolicyConfig: { Name: 'X-adobe-E2EXAMPLE' } } }] } }),
1113
+ GetCachePolicy: {
1114
+ CachePolicy: { CachePolicyConfig: { Name: 'Managed-X', ParametersInCacheKeyAndForwardedToOrigin: {} } },
1115
+ },
1116
+ UpdateDistribution: {},
1117
+ });
1118
+
1119
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1120
+
1121
+ expect(result.scenario).to.equal('managed');
1122
+ expect(result.policyId).to.equal('existing-eo');
1123
+ expect(result.reused).to.equal(true);
1124
+ expect(lastCommand('CreateCachePolicy')).to.equal(undefined); // reused, not created
1125
+ });
1126
+
1127
+ it('handles a LEGACY behavior (ForwardedValues, no CachePolicyId)', async () => {
1128
+ wireCloudFront({
1129
+ GetDistributionConfig: {
1130
+ DistributionConfig: {
1131
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Quantity: 1, Items: ['accept'] } }, MinTTL: 60 },
1132
+ },
1133
+ ETag: 'dist-etag',
1134
+ },
1135
+ UpdateDistribution: {},
1136
+ });
1137
+
1138
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1139
+
1140
+ expect(result.scenario).to.equal('legacy');
1141
+ expect(result.updated).to.equal(true);
1142
+ const cfg = lastCommand('UpdateDistribution').input.DistributionConfig;
1143
+ const items = cfg.DefaultCacheBehavior.ForwardedValues.Headers.Items;
1144
+ expect(items).to.include('x-edgeoptimize-config');
1145
+ expect(cfg.DefaultCacheBehavior.MinTTL).to.equal(0);
1146
+ });
1147
+
1148
+ it('handles a sparse LEGACY behavior (no ForwardedValues / Headers / MinTTL)', async () => {
1149
+ wireCloudFront({
1150
+ // legacy (no CachePolicyId), NO ForwardedValues / Headers / MinTTL → `|| {}`,
1151
+ // `?.Items || []`, and `behavior.MinTTL ?? 0` fallbacks.
1152
+ GetDistributionConfig: {
1153
+ DistributionConfig: { DefaultCacheBehavior: {} },
1154
+ ETag: 'dist-etag',
1155
+ },
1156
+ UpdateDistribution: {},
1157
+ });
1158
+
1159
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1160
+
1161
+ expect(result.scenario).to.equal('legacy');
1162
+ expect(result.updated).to.equal(true);
1163
+ const cfg = lastCommand('UpdateDistribution').input.DistributionConfig;
1164
+ expect(cfg.DefaultCacheBehavior.ForwardedValues.Headers.Items).to.include('x-edgeoptimize-config');
1165
+ });
1166
+
1167
+ it('is a no-op LEGACY behavior when headers already forwarded and MinTTL short', async () => {
1168
+ wireCloudFront({
1169
+ GetDistributionConfig: {
1170
+ DistributionConfig: {
1171
+ DefaultCacheBehavior: {
1172
+ ForwardedValues: { Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
1173
+ MinTTL: 0,
1174
+ },
1175
+ },
1176
+ ETag: 'dist-etag',
1177
+ },
1178
+ });
1179
+
1180
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1181
+
1182
+ expect(result).to.deep.equal({
1183
+ scenario: 'legacy', policyId: null, updated: false, alreadyForwarded: true,
1184
+ });
1185
+ });
1186
+
1187
+ it('does not add headers on a LEGACY behavior that already forwards "*"', async () => {
1188
+ wireCloudFront({
1189
+ GetDistributionConfig: {
1190
+ DistributionConfig: {
1191
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Quantity: 1, Items: ['*'] } }, MinTTL: 0 },
1192
+ },
1193
+ ETag: 'dist-etag',
1194
+ },
1195
+ });
1196
+
1197
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', 'default');
1198
+
1199
+ expect(result.scenario).to.equal('legacy');
1200
+ expect(result.updated).to.equal(false);
1201
+ });
1202
+
1203
+ it('targets a named (non-default) custom-policy behavior', async () => {
1204
+ wireCloudFront({
1205
+ GetDistributionConfig: {
1206
+ DistributionConfig: {
1207
+ DefaultCacheBehavior: { CachePolicyId: 'cp-default' },
1208
+ CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-api' }] },
1209
+ },
1210
+ },
1211
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
1212
+ GetCachePolicyConfig: {
1213
+ CachePolicyConfig: {
1214
+ Name: 'api',
1215
+ MinTTL: 0,
1216
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } },
1217
+ },
1218
+ ETag: 'cp-etag',
1219
+ },
1220
+ UpdateCachePolicy: {},
1221
+ });
1222
+
1223
+ const result = await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', '/api/*');
1224
+ expect(result.policyId).to.equal('cp-api');
1225
+ expect(lastCommand('GetCachePolicyConfig').input.Id).to.equal('cp-api');
1226
+ });
1227
+
1228
+ it('throws when a named behavior is not found', async () => {
1229
+ wireCloudFront({
1230
+ GetDistributionConfig: {
1231
+ DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp' }, CacheBehaviors: { Items: [] } },
1232
+ },
1233
+ });
1234
+
1235
+ let error;
1236
+ try {
1237
+ await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', '/missing/*');
1238
+ } catch (e) {
1239
+ error = e;
1240
+ }
1241
+ expect(error.message).to.include('Behavior not found: /missing/*');
1242
+ });
1243
+
1244
+ it('throws for a named behavior when the config has no CacheBehaviors at all', async () => {
1245
+ wireCloudFront({
1246
+ // no CacheBehaviors → `config.CacheBehaviors?.Items || []` fallback in getBehavior.
1247
+ GetDistributionConfig: {
1248
+ DistributionConfig: { DefaultCacheBehavior: { CachePolicyId: 'cp' } },
1249
+ },
1250
+ });
1251
+
1252
+ let error;
1253
+ try {
1254
+ await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', '/api/*');
1255
+ } catch (e) {
1256
+ error = e;
1257
+ }
1258
+ expect(error.message).to.include('Behavior not found: /api/*');
1259
+ });
1260
+
1261
+ it('throws when distributionId is missing', async () => {
1262
+ let error;
1263
+ try {
1264
+ await edgeOptimize.updateCacheSettings({}, '', 'default');
1265
+ } catch (e) {
1266
+ error = e;
1267
+ }
1268
+ expect(error.message).to.include('distributionId');
1269
+ });
1270
+
1271
+ it('throws when pathPattern is missing', async () => {
1272
+ let error;
1273
+ try {
1274
+ await edgeOptimize.updateCacheSettings({}, 'E2EXAMPLE', '');
1275
+ } catch (e) {
1276
+ error = e;
1277
+ }
1278
+ expect(error.message).to.include('pathPattern');
1279
+ });
1280
+ });
1281
+
1282
+ describe('buildEoClonedCachePolicyName', () => {
1283
+ it('strips the Managed- prefix and appends the per-dist suffix', () => {
1284
+ expect(edgeOptimize.buildEoClonedCachePolicyName('Managed-CachingOptimized', 'E1'))
1285
+ .to.equal('CachingOptimized-adobe-E1');
1286
+ });
1287
+
1288
+ it('defaults the base name when the source is empty', () => {
1289
+ expect(edgeOptimize.buildEoClonedCachePolicyName('', 'E1')).to.equal('cache-adobe-E1');
1290
+ });
1291
+ });
1292
+
1293
+ describe('buildLambdaZip', () => {
1294
+ it('produces a zip buffer with the local-file-header signature', () => {
1295
+ const zip = edgeOptimize.buildLambdaZip('index.mjs', 'console.log(1)');
1296
+ expect(Buffer.isBuffer(zip)).to.equal(true);
1297
+ expect(zip.readUInt32LE(0)).to.equal(0x04034b50);
1298
+ });
1299
+
1300
+ it('accepts a Buffer payload directly', () => {
1301
+ const zip = edgeOptimize.buildLambdaZip('index.mjs', Buffer.from('console.log(2)', 'utf-8'));
1302
+ expect(Buffer.isBuffer(zip)).to.equal(true);
1303
+ expect(zip.readUInt32LE(0)).to.equal(0x04034b50);
1304
+ });
1305
+ });
1306
+
1307
+ describe('createLambdaAtEdge', () => {
1308
+ const creds = { accessKeyId: 'A', secretAccessKey: 'S', sessionToken: 'T' };
1309
+
1310
+ // IAM + Lambda stubs dispatch by command name (robust to call order/poll counts).
1311
+ const wireIam = (responders) => {
1312
+ iamSendStub.callsFake((cmd) => {
1313
+ const r = responders[cmd.commandName];
1314
+ return Promise.resolve(typeof r === 'function' ? r(cmd) : (r || {}));
1315
+ });
1316
+ };
1317
+ const wireLambda = (responders) => {
1318
+ lambdaSendStub.callsFake((cmd) => {
1319
+ const r = responders[cmd.commandName];
1320
+ if (r === undefined) {
1321
+ throw new Error(`unexpected lambda command: ${cmd.commandName}`);
1322
+ }
1323
+ return Promise.resolve(typeof r === 'function' ? r(cmd) : r);
1324
+ });
1325
+ };
1326
+ const lastLambda = (name) => lambdaSendStub.getCalls()
1327
+ .filter((c) => c.args[0].commandName === name).pop()?.args[0];
1328
+ const notFound = () => Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' }));
1329
+
1330
+ it('returns provisioning WITHOUT CreateFunction when the role was just created', async () => {
1331
+ // Root-cause fix for the 503 first-byte timeout: a freshly-created IAM role needs time to
1332
+ // propagate, so we must NOT wait + CreateFunction in this same request. Return provisioning
1333
+ // immediately; the next poll (role now exists → roleIsNew false) performs the create.
1334
+ wireIam({
1335
+ GetRole: () => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })),
1336
+ CreateRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } },
1337
+ PutRolePolicy: {},
1338
+ });
1339
+ wireLambda({
1340
+ GetFunctionConfiguration: () => notFound(),
1341
+ CreateFunction: { FunctionArn: 'arn:fn' },
1342
+ });
1343
+
1344
+ const result = await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1345
+
1346
+ expect(result.status).to.equal('provisioning');
1347
+ expect(result.created).to.equal(true);
1348
+ expect(result.functionArn).to.equal(null);
1349
+ expect(result.versionArn).to.equal(null);
1350
+ expect(result.roleArn).to.include('edgeoptimize-origin-role');
1351
+ // The expensive work is deferred: no CreateFunction (and no PublishVersion) this call.
1352
+ expect(lastLambda('CreateFunction')).to.equal(undefined);
1353
+ expect(lastLambda('PublishVersion')).to.equal(undefined);
1354
+ });
1355
+
1356
+ it('creates the function (non-blocking) and returns provisioning when the role already exists', async () => {
1357
+ // Existing role + missing function: proceed to CreateFunction in the SAME call (unchanged).
1358
+ wireIam({
1359
+ GetRole: { Role: { Arn: 'arn:aws:iam::120569600543:role/edgeoptimize-origin-role' } },
1360
+ UpdateAssumeRolePolicy: {},
1361
+ PutRolePolicy: {},
1362
+ });
1363
+ wireLambda({
1364
+ GetFunctionConfiguration: () => notFound(),
1365
+ CreateFunction: { FunctionArn: 'arn:fn' },
1366
+ });
1367
+
1368
+ const result = await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1369
+
1370
+ // Does NOT block on the new function becoming Active — returns provisioning immediately.
1371
+ expect(result.status).to.equal('provisioning');
1372
+ expect(result.created).to.equal(true);
1373
+ expect(result.functionArn).to.equal('arn:fn');
1374
+ expect(result.versionArn).to.equal(null);
1375
+ expect(result.roleArn).to.include('edgeoptimize-origin-role');
1376
+ expect(lastLambda('CreateFunction').input.Role).to.include('edgeoptimize-origin-role');
1377
+ expect(lastLambda('PublishVersion')).to.equal(undefined); // never publishes while Pending
1378
+ });
1379
+
1380
+ it('retries CreateFunction on role-propagation then succeeds', async () => {
1381
+ let createAttempts = 0;
1382
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1383
+ wireLambda({
1384
+ GetFunctionConfiguration: () => notFound(),
1385
+ CreateFunction: () => {
1386
+ createAttempts += 1;
1387
+ if (createAttempts === 1) {
1388
+ return Promise.reject(Object.assign(
1389
+ new Error('The role defined for the function cannot be assumed by Lambda.'),
1390
+ { name: 'InvalidParameterValueException' },
1391
+ ));
1392
+ }
1393
+ return Promise.resolve({ FunctionArn: 'arn:fn' });
1394
+ },
1395
+ });
1396
+
1397
+ const result = await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { retryDelayMs: 1, distributionId: 'E2EXAMPLE' });
1398
+ expect(result.status).to.equal('provisioning');
1399
+ expect(createAttempts).to.equal(2);
1400
+ });
1401
+
1402
+ it('rethrows a non-role-propagation CreateFunction error', async () => {
1403
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1404
+ wireLambda({
1405
+ GetFunctionConfiguration: () => notFound(),
1406
+ CreateFunction: () => Promise.reject(Object.assign(new Error('boom'), { name: 'SomethingElse' })),
1407
+ });
1408
+
1409
+ let error;
1410
+ try {
1411
+ await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1412
+ } catch (e) {
1413
+ error = e;
1414
+ }
1415
+ expect(error.message).to.equal('boom');
1416
+ });
1417
+
1418
+ it('rethrows an InvalidParameterValue error with no message (not role propagation)', async () => {
1419
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1420
+ wireLambda({
1421
+ GetFunctionConfiguration: () => notFound(),
1422
+ // name is InvalidParameterValueException but message is empty → `(message || '')` fallback,
1423
+ // `.includes('role')` is false → not role-propagation → rethrow immediately.
1424
+ CreateFunction: () => {
1425
+ const e = new Error('');
1426
+ e.name = 'InvalidParameterValueException';
1427
+ e.message = '';
1428
+ return Promise.reject(e);
1429
+ },
1430
+ });
1431
+
1432
+ let error;
1433
+ try {
1434
+ await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1435
+ } catch (e) {
1436
+ error = e;
1437
+ }
1438
+ expect(error.name).to.equal('InvalidParameterValueException');
1439
+ });
1440
+
1441
+ it('gives up after the retry budget on persistent role-propagation', async () => {
1442
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1443
+ wireLambda({
1444
+ GetFunctionConfiguration: () => notFound(),
1445
+ CreateFunction: () => Promise.reject(Object.assign(
1446
+ new Error('The role defined for the function cannot be assumed by Lambda.'),
1447
+ { name: 'InvalidParameterValueException' },
1448
+ )),
1449
+ });
1450
+
1451
+ let error;
1452
+ try {
1453
+ await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { retryDelayMs: 1, distributionId: 'E2EXAMPLE' });
1454
+ } catch (e) {
1455
+ error = e;
1456
+ }
1457
+ expect(error.name).to.equal('InvalidParameterValueException');
1458
+ });
1459
+
1460
+ it('rethrows an unexpected GetRole error', async () => {
1461
+ wireIam({ GetRole: () => Promise.reject(Object.assign(new Error('access denied'), { name: 'AccessDenied' })) });
1462
+ lambdaSendStub.callsFake(() => Promise.resolve({}));
1463
+
1464
+ let error;
1465
+ try {
1466
+ await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1467
+ } catch (e) {
1468
+ error = e;
1469
+ }
1470
+ expect(error.message).to.equal('access denied');
1471
+ });
1472
+
1473
+ it('rethrows an unexpected GetFunctionConfiguration error', async () => {
1474
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1475
+ wireLambda({
1476
+ GetFunctionConfiguration: () => Promise.reject(Object.assign(new Error('throttled'), { name: 'TooManyRequestsException' })),
1477
+ });
1478
+
1479
+ let error;
1480
+ try {
1481
+ await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1482
+ } catch (e) {
1483
+ error = e;
1484
+ }
1485
+ expect(error.message).to.equal('throttled');
1486
+ });
1487
+
1488
+ it('returns provisioning (no mutation) while the function is still finalizing', async () => {
1489
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1490
+ wireLambda({
1491
+ GetFunctionConfiguration: {
1492
+ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'InProgress',
1493
+ },
1494
+ });
1495
+
1496
+ const result = await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1497
+
1498
+ expect(result.status).to.equal('provisioning');
1499
+ expect(result.versionArn).to.equal(null);
1500
+ expect(lastLambda('PublishVersion')).to.equal(undefined); // never touched while InProgress
1501
+ });
1502
+
1503
+ it('is idempotent: reuses the existing version when the function is idle', async () => {
1504
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1505
+ wireLambda({
1506
+ GetFunctionConfiguration: {
1507
+ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful',
1508
+ },
1509
+ ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }, { Version: '3', FunctionArn: 'arn:fn:3' }] },
1510
+ });
1511
+
1512
+ const result = await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1513
+
1514
+ expect(result.status).to.equal('ready');
1515
+ expect(result.alreadyExisted).to.equal(true);
1516
+ expect(result.versionArn).to.equal('arn:fn:3');
1517
+ expect(lastLambda('PublishVersion')).to.equal(undefined); // reused, not re-published
1518
+ });
1519
+
1520
+ it('publishes a version when the function is idle but unpublished', async () => {
1521
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1522
+ wireLambda({
1523
+ GetFunctionConfiguration: {
1524
+ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful',
1525
+ },
1526
+ ListVersionsByFunction: { Versions: [{ Version: '$LATEST' }] },
1527
+ PublishVersion: { FunctionArn: 'arn:fn:1', Version: '1' },
1528
+ });
1529
+
1530
+ const result = await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1531
+
1532
+ expect(result.status).to.equal('ready');
1533
+ expect(result.versionArn).to.equal('arn:fn:1');
1534
+ expect(lastLambda('PublishVersion')).to.not.equal(undefined);
1535
+ });
1536
+
1537
+ it('treats a concurrent-create conflict as provisioning', async () => {
1538
+ wireIam({ GetRole: { Role: { Arn: 'arn:role' } }, UpdateAssumeRolePolicy: {}, PutRolePolicy: {} });
1539
+ wireLambda({
1540
+ GetFunctionConfiguration: () => notFound(),
1541
+ CreateFunction: () => Promise.reject(Object.assign(new Error('exists'), { name: 'ResourceConflictException' })),
1542
+ });
1543
+
1544
+ const result = await edgeOptimize.createLambdaAtEdge(creds, '120569600543', { distributionId: 'E2EXAMPLE' });
1545
+
1546
+ expect(result.status).to.equal('provisioning');
1547
+ });
1548
+
1549
+ it('throws for an invalid account id', async () => {
1550
+ let error;
1551
+ try {
1552
+ await edgeOptimize.createLambdaAtEdge(creds, '123');
1553
+ } catch (e) {
1554
+ error = e;
1555
+ }
1556
+ expect(error.message).to.include('12-digit');
1557
+ expect(iamSendStub.called).to.equal(false);
1558
+ });
1559
+
1560
+ it('throws when distributionId is missing', async () => {
1561
+ let error;
1562
+ try {
1563
+ await edgeOptimize.createLambdaAtEdge(creds, '120569600543', {});
1564
+ } catch (e) {
1565
+ error = e;
1566
+ }
1567
+ expect(error.message).to.include('distributionId');
1568
+ expect(iamSendStub.called).to.equal(false);
1569
+ });
1570
+ });
1571
+
1572
+ describe('getLambdaAtEdgeStatus', () => {
1573
+ it('reports roleExists:false + exists:false when nothing is provisioned', async () => {
1574
+ iamSendStub.callsFake(() => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })));
1575
+ lambdaSendStub.callsFake((cmd) => {
1576
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1577
+ return Promise.reject(Object.assign(new Error('nf'), { name: 'ResourceNotFoundException' }));
1578
+ }
1579
+ throw new Error(`unexpected: ${cmd.commandName}`);
1580
+ });
1581
+
1582
+ const result = await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1583
+
1584
+ expect(result).to.deep.equal({
1585
+ roleExists: false, roleOk: false, exists: false, versionArn: null, ready: false,
1586
+ });
1587
+ });
1588
+
1589
+ it('reports the role (roleOk) + published version and ready:true when fully provisioned', async () => {
1590
+ const trust = encodeURIComponent(JSON.stringify({
1591
+ Version: '2012-10-17',
1592
+ Statement: [{
1593
+ Effect: 'Allow',
1594
+ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] },
1595
+ Action: 'sts:AssumeRole',
1596
+ }],
1597
+ }));
1598
+ iamSendStub.callsFake((cmd) => {
1599
+ if (cmd.commandName === 'GetRole') {
1600
+ return Promise.resolve({ Role: { Arn: 'arn:role', AssumeRolePolicyDocument: trust } });
1601
+ }
1602
+ if (cmd.commandName === 'GetRolePolicy') {
1603
+ return Promise.resolve({ PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' });
1604
+ }
1605
+ throw new Error(`unexpected iam: ${cmd.commandName}`);
1606
+ });
1607
+ lambdaSendStub.callsFake((cmd) => {
1608
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1609
+ return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' });
1610
+ }
1611
+ if (cmd.commandName === 'ListVersionsByFunction') {
1612
+ return Promise.resolve({ Versions: [{ Version: '$LATEST' }, { Version: '2', FunctionArn: 'arn:fn:2' }] });
1613
+ }
1614
+ throw new Error(`unexpected: ${cmd.commandName}`);
1615
+ });
1616
+
1617
+ const result = await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1618
+
1619
+ expect(result.roleExists).to.equal(true);
1620
+ expect(result.roleOk).to.equal(true);
1621
+ expect(result.exists).to.equal(true);
1622
+ expect(result.state).to.equal('Active');
1623
+ expect(result.versionArn).to.equal('arn:fn:2');
1624
+ expect(result.version).to.equal('2');
1625
+ expect(result.ready).to.equal(true);
1626
+ });
1627
+
1628
+ it('marks roleOk:false when the trust document does not parse', async () => {
1629
+ iamSendStub.callsFake((cmd) => {
1630
+ if (cmd.commandName === 'GetRole') {
1631
+ return Promise.resolve({ Role: { Arn: 'arn:role', AssumeRolePolicyDocument: '%ZZ-not-json' } });
1632
+ }
1633
+ if (cmd.commandName === 'GetRolePolicy') {
1634
+ return Promise.resolve({ PolicyName: 'EdgeOptimizeLambdaLogging' });
1635
+ }
1636
+ throw new Error(`unexpected iam: ${cmd.commandName}`);
1637
+ });
1638
+ lambdaSendStub.callsFake((cmd) => {
1639
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1640
+ return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' });
1641
+ }
1642
+ if (cmd.commandName === 'ListVersionsByFunction') {
1643
+ return Promise.resolve({ Versions: [{ Version: '1', FunctionArn: 'arn:fn:1' }] });
1644
+ }
1645
+ throw new Error(`unexpected: ${cmd.commandName}`);
1646
+ });
1647
+
1648
+ const result = await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1649
+
1650
+ expect(result.roleExists).to.equal(true);
1651
+ expect(result.roleOk).to.equal(false); // trust unparsable → trustOk false
1652
+ });
1653
+
1654
+ it('marks roleOk:false when the logs policy is absent', async () => {
1655
+ const trust = encodeURIComponent(JSON.stringify({
1656
+ Statement: [{ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] } }],
1657
+ }));
1658
+ iamSendStub.callsFake((cmd) => {
1659
+ if (cmd.commandName === 'GetRole') {
1660
+ return Promise.resolve({ Role: { Arn: 'arn:role', AssumeRolePolicyDocument: trust } });
1661
+ }
1662
+ if (cmd.commandName === 'GetRolePolicy') {
1663
+ return Promise.reject(Object.assign(new Error('no policy'), { name: 'NoSuchEntityException' }));
1664
+ }
1665
+ throw new Error(`unexpected iam: ${cmd.commandName}`);
1666
+ });
1667
+ lambdaSendStub.callsFake((cmd) => {
1668
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1669
+ return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' });
1670
+ }
1671
+ if (cmd.commandName === 'ListVersionsByFunction') {
1672
+ return Promise.resolve({ Versions: [{ Version: '1', FunctionArn: 'arn:fn:1' }] });
1673
+ }
1674
+ throw new Error(`unexpected: ${cmd.commandName}`);
1675
+ });
1676
+
1677
+ const result = await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1678
+
1679
+ expect(result.roleOk).to.equal(false);
1680
+ });
1681
+
1682
+ it('rethrows a non-NoSuchEntity GetRole error', async () => {
1683
+ iamSendStub.callsFake(() => Promise.reject(Object.assign(new Error('denied'), { name: 'AccessDenied' })));
1684
+ lambdaSendStub.callsFake(() => Promise.resolve({}));
1685
+
1686
+ let error;
1687
+ try {
1688
+ await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1689
+ } catch (e) {
1690
+ error = e;
1691
+ }
1692
+ expect(error.message).to.equal('denied');
1693
+ });
1694
+
1695
+ it('rethrows a non-NoSuchEntity GetRolePolicy error', async () => {
1696
+ const trust = encodeURIComponent(JSON.stringify({
1697
+ Statement: [{ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] } }],
1698
+ }));
1699
+ iamSendStub.callsFake((cmd) => {
1700
+ if (cmd.commandName === 'GetRole') {
1701
+ return Promise.resolve({ Role: { Arn: 'arn:role', AssumeRolePolicyDocument: trust } });
1702
+ }
1703
+ return Promise.reject(Object.assign(new Error('denied'), { name: 'AccessDenied' }));
1704
+ });
1705
+ lambdaSendStub.callsFake(() => Promise.resolve({}));
1706
+
1707
+ let error;
1708
+ try {
1709
+ await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1710
+ } catch (e) {
1711
+ error = e;
1712
+ }
1713
+ expect(error.message).to.equal('denied');
1714
+ });
1715
+
1716
+ it('rethrows a non-ResourceNotFound GetFunctionConfiguration error', async () => {
1717
+ iamSendStub.callsFake(() => Promise.reject(Object.assign(new Error('no role'), { name: 'NoSuchEntityException' })));
1718
+ lambdaSendStub.callsFake((cmd) => {
1719
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1720
+ return Promise.reject(Object.assign(new Error('throttled'), { name: 'TooManyRequestsException' }));
1721
+ }
1722
+ throw new Error(`unexpected: ${cmd.commandName}`);
1723
+ });
1724
+
1725
+ let error;
1726
+ try {
1727
+ await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1728
+ } catch (e) {
1729
+ error = e;
1730
+ }
1731
+ expect(error.message).to.equal('throttled');
1732
+ });
1733
+
1734
+ it('handles a role with no trust document at all', async () => {
1735
+ iamSendStub.callsFake((cmd) => {
1736
+ if (cmd.commandName === 'GetRole') {
1737
+ return Promise.resolve({ Role: { Arn: 'arn:role' } });
1738
+ }
1739
+ if (cmd.commandName === 'GetRolePolicy') {
1740
+ return Promise.resolve({ PolicyName: 'EdgeOptimizeLambdaLogging' });
1741
+ }
1742
+ throw new Error(`unexpected iam: ${cmd.commandName}`);
1743
+ });
1744
+ lambdaSendStub.callsFake((cmd) => {
1745
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1746
+ return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' });
1747
+ }
1748
+ return Promise.resolve({ Versions: [{ Version: '1', FunctionArn: 'arn:fn:1' }] });
1749
+ });
1750
+
1751
+ const result = await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1752
+ expect(result.roleOk).to.equal(false); // no trust doc → trustOk false
1753
+ });
1754
+
1755
+ it('treats a missing Versions list as no published version (ready:false)', async () => {
1756
+ const trust = encodeURIComponent(JSON.stringify({
1757
+ Statement: [{ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] } }],
1758
+ }));
1759
+ iamSendStub.callsFake((cmd) => {
1760
+ if (cmd.commandName === 'GetRole') {
1761
+ return Promise.resolve({ Role: { Arn: 'arn:role', AssumeRolePolicyDocument: trust } });
1762
+ }
1763
+ return Promise.resolve({ PolicyName: 'EdgeOptimizeLambdaLogging' });
1764
+ });
1765
+ lambdaSendStub.callsFake((cmd) => {
1766
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1767
+ return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Active', LastUpdateStatus: 'Successful' });
1768
+ }
1769
+ // ListVersionsByFunction returns NO Versions field → `resp.Versions || []` fallback.
1770
+ return Promise.resolve({});
1771
+ });
1772
+
1773
+ const result = await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1774
+
1775
+ expect(result.exists).to.equal(true);
1776
+ expect(result.versionArn).to.equal(null);
1777
+ expect(result.ready).to.equal(false);
1778
+ });
1779
+
1780
+ it('reports ready:false (role created, still provisioning) when not yet published', async () => {
1781
+ iamSendStub.callsFake(() => Promise.resolve({ Role: { Arn: 'arn:role' } }));
1782
+ lambdaSendStub.callsFake((cmd) => {
1783
+ if (cmd.commandName === 'GetFunctionConfiguration') {
1784
+ return Promise.resolve({ FunctionArn: 'arn:fn', State: 'Pending', LastUpdateStatus: 'InProgress' });
1785
+ }
1786
+ if (cmd.commandName === 'ListVersionsByFunction') {
1787
+ return Promise.resolve({ Versions: [{ Version: '$LATEST' }] });
1788
+ }
1789
+ throw new Error(`unexpected: ${cmd.commandName}`);
1790
+ });
1791
+
1792
+ const result = await edgeOptimize.getLambdaAtEdgeStatus({}, 'E2EXAMPLE');
1793
+
1794
+ expect(result.roleExists).to.equal(true);
1795
+ expect(result.exists).to.equal(true);
1796
+ expect(result.versionArn).to.equal(null);
1797
+ expect(result.ready).to.equal(false);
1798
+ });
1799
+
1800
+ it('throws when distributionId is missing', async () => {
1801
+ let error;
1802
+ try {
1803
+ await edgeOptimize.getLambdaAtEdgeStatus({}, '');
1804
+ } catch (e) {
1805
+ error = e;
1806
+ }
1807
+ expect(error.message).to.include('distributionId');
1808
+ });
1809
+ });
1810
+
1811
+ describe('applyAssociations', () => {
1812
+ const lambdaArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1';
1813
+
1814
+ it('wires the CF function (viewer-request) and Lambda (origin req/res) onto the behavior', async () => {
1815
+ cfSendStub.onFirstCall().resolves({
1816
+ FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } },
1817
+ });
1818
+ cfSendStub.onSecondCall().resolves({
1819
+ DistributionConfig: { DefaultCacheBehavior: {} },
1820
+ ETag: 'dist-etag',
1821
+ });
1822
+ cfSendStub.onThirdCall().resolves({});
1823
+
1824
+ const result = await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', lambdaArn);
1825
+
1826
+ expect(result).to.deep.equal({ cloudFrontFunctionArn: 'arn:cf-fn', lambdaArn });
1827
+ const update = cfSendStub.thirdCall.args[0];
1828
+ expect(update.commandName).to.equal('UpdateDistribution');
1829
+ const behavior = update.input.DistributionConfig.DefaultCacheBehavior;
1830
+ expect(behavior.FunctionAssociations.Items[0]).to.deep.equal({ FunctionARN: 'arn:cf-fn', EventType: 'viewer-request' });
1831
+ expect(behavior.LambdaFunctionAssociations.Quantity).to.equal(2);
1832
+ expect(behavior.LambdaFunctionAssociations.Items.map((i) => i.EventType)).to.deep.equal(['origin-request', 'origin-response']);
1833
+ });
1834
+
1835
+ it('throws when the CF function is not published to LIVE', async () => {
1836
+ cfSendStub.onFirstCall().resolves({ FunctionSummary: {} });
1837
+ let error;
1838
+ try {
1839
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', lambdaArn);
1840
+ } catch (e) {
1841
+ error = e;
1842
+ }
1843
+ expect(error.message).to.include('not found or not published');
1844
+ });
1845
+
1846
+ it('surfaces a conflicting viewer-request association', async () => {
1847
+ cfSendStub.onFirstCall().resolves({
1848
+ FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } },
1849
+ });
1850
+ cfSendStub.onSecondCall().resolves({
1851
+ DistributionConfig: {
1852
+ DefaultCacheBehavior: {
1853
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:other-fn' }] },
1854
+ },
1855
+ },
1856
+ ETag: 'dist-etag',
1857
+ });
1858
+ let error;
1859
+ try {
1860
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', lambdaArn);
1861
+ } catch (e) {
1862
+ error = e;
1863
+ }
1864
+ expect(error.message).to.include('already has a different viewer-request function');
1865
+ });
1866
+
1867
+ it('surfaces a conflicting viewer-request Lambda@Edge association', async () => {
1868
+ cfSendStub.onFirstCall().resolves({
1869
+ FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } },
1870
+ });
1871
+ cfSendStub.onSecondCall().resolves({
1872
+ DistributionConfig: {
1873
+ DefaultCacheBehavior: {
1874
+ LambdaFunctionAssociations: { Items: [{ EventType: 'viewer-request', LambdaFunctionARN: 'arn:cust-viewer-lambda' }] },
1875
+ },
1876
+ },
1877
+ ETag: 'dist-etag',
1878
+ });
1879
+ let error;
1880
+ try {
1881
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', lambdaArn);
1882
+ } catch (e) {
1883
+ error = e;
1884
+ }
1885
+ expect(error.message).to.include('viewer-request Lambda@Edge');
1886
+ });
1887
+
1888
+ it('preserves the customer\'s other-slot associations (merge, not wholesale replace)', async () => {
1889
+ cfSendStub.onFirstCall().resolves({
1890
+ FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } },
1891
+ });
1892
+ cfSendStub.onSecondCall().resolves({
1893
+ DistributionConfig: {
1894
+ DefaultCacheBehavior: {
1895
+ FunctionAssociations: {
1896
+ Quantity: 1,
1897
+ Items: [{ EventType: 'viewer-response', FunctionARN: 'arn:cust-fn' }],
1898
+ },
1899
+ LambdaFunctionAssociations: {
1900
+ Quantity: 1,
1901
+ Items: [{ EventType: 'viewer-response', LambdaFunctionARN: 'arn:cust-lambda', IncludeBody: false }],
1902
+ },
1903
+ },
1904
+ },
1905
+ ETag: 'dist-etag',
1906
+ });
1907
+ cfSendStub.onThirdCall().resolves({});
1908
+
1909
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', lambdaArn);
1910
+ const behavior = cfSendStub.thirdCall.args[0].input.DistributionConfig.DefaultCacheBehavior;
1911
+ // Customer's viewer-response function is preserved; EO's viewer-request function is added.
1912
+ expect(behavior.FunctionAssociations.Items)
1913
+ .to.deep.include({ EventType: 'viewer-response', FunctionARN: 'arn:cust-fn' });
1914
+ expect(behavior.FunctionAssociations.Items)
1915
+ .to.deep.include({ FunctionARN: 'arn:cf-fn', EventType: 'viewer-request' });
1916
+ // Customer's viewer-response lambda is preserved; EO's origin-request/response are added.
1917
+ const lambdaEvents = behavior.LambdaFunctionAssociations.Items.map((i) => i.EventType);
1918
+ expect(lambdaEvents).to.include.members(['viewer-response', 'origin-request', 'origin-response']);
1919
+ expect(behavior.LambdaFunctionAssociations.Items
1920
+ .find((i) => i.EventType === 'viewer-response').LambdaFunctionARN).to.equal('arn:cust-lambda');
1921
+ });
1922
+
1923
+ it('targets a named (non-default) behavior', async () => {
1924
+ cfSendStub.onFirstCall().resolves({
1925
+ FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } },
1926
+ });
1927
+ cfSendStub.onSecondCall().resolves({
1928
+ DistributionConfig: { CacheBehaviors: { Items: [{ PathPattern: '/api/*' }] } },
1929
+ ETag: 'dist-etag',
1930
+ });
1931
+ cfSendStub.onThirdCall().resolves({});
1932
+
1933
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', '/api/*', lambdaArn);
1934
+ const { DistributionConfig } = cfSendStub.thirdCall.args[0].input;
1935
+ const behavior = DistributionConfig.CacheBehaviors.Items[0];
1936
+ expect(behavior.FunctionAssociations.Items.map((i) => i.EventType)).to.include('viewer-request');
1937
+ });
1938
+
1939
+ it('refuses an origin-request Lambda@Edge association that carries no ARN', async () => {
1940
+ cfSendStub.onFirstCall().resolves({
1941
+ FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } },
1942
+ });
1943
+ cfSendStub.onSecondCall().resolves({
1944
+ DistributionConfig: {
1945
+ DefaultCacheBehavior: {
1946
+ // origin-request lambda with NO LambdaFunctionARN → isEdgeOptimizeLambdaArn(undefined)
1947
+ // exercises the `arn || ''` fallback → not EO → conflict.
1948
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request' }] },
1949
+ },
1950
+ },
1951
+ ETag: 'dist-etag',
1952
+ });
1953
+ let error;
1954
+ try {
1955
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', lambdaArn);
1956
+ } catch (e) {
1957
+ error = e;
1958
+ }
1959
+ expect(error.message).to.include('different origin-request');
1960
+ });
1961
+
1962
+ it('refuses to overwrite a customer origin-request Lambda@Edge', async () => {
1963
+ cfSendStub.onFirstCall().resolves({
1964
+ FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } },
1965
+ });
1966
+ cfSendStub.onSecondCall().resolves({
1967
+ DistributionConfig: {
1968
+ DefaultCacheBehavior: {
1969
+ LambdaFunctionAssociations: {
1970
+ Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:cust-origin-lambda' }],
1971
+ },
1972
+ },
1973
+ },
1974
+ ETag: 'dist-etag',
1975
+ });
1976
+ let error;
1977
+ try {
1978
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', lambdaArn);
1979
+ } catch (e) {
1980
+ error = e;
1981
+ }
1982
+ expect(error.message).to.include('different origin-request');
1983
+ expect(cfSendStub.thirdCall).to.equal(null); // never issued an UpdateDistribution
1984
+ });
1985
+
1986
+ it('throws when distributionId is missing', async () => {
1987
+ let error;
1988
+ try {
1989
+ await edgeOptimize.applyAssociations({}, '', 'default', lambdaArn);
1990
+ } catch (e) {
1991
+ error = e;
1992
+ }
1993
+ expect(error.message).to.include('distributionId');
1994
+ expect(cfSendStub.called).to.equal(false);
1995
+ });
1996
+
1997
+ it('throws when pathPattern is missing', async () => {
1998
+ let error;
1999
+ try {
2000
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', '', lambdaArn);
2001
+ } catch (e) {
2002
+ error = e;
2003
+ }
2004
+ expect(error.message).to.include('pathPattern');
2005
+ expect(cfSendStub.called).to.equal(false);
2006
+ });
2007
+
2008
+ it('throws when lambdaVersionArn is missing', async () => {
2009
+ let error;
2010
+ try {
2011
+ await edgeOptimize.applyAssociations({}, 'E2EXAMPLE', 'default', '');
2012
+ } catch (e) {
2013
+ error = e;
2014
+ }
2015
+ expect(error.message).to.include('lambdaVersionArn');
2016
+ expect(cfSendStub.called).to.equal(false);
2017
+ });
2018
+ });
2019
+
2020
+ describe('verifyRouting', () => {
2021
+ let fetchStub;
2022
+
2023
+ const makeResponse = (status, headerMap) => ({
2024
+ status,
2025
+ headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) },
2026
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
2027
+ });
2028
+
2029
+ afterEach(() => {
2030
+ if (fetchStub) {
2031
+ fetchStub.restore();
2032
+ }
2033
+ fetchStub = undefined;
2034
+ });
2035
+
2036
+ it('passes when the bot response carries x-edgeoptimize-request-id and the human does not', async () => {
2037
+ fetchStub = sinon.stub(global, 'fetch');
2038
+ fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' }));
2039
+ fetchStub.onSecondCall().resolves(makeResponse(200, {}));
2040
+
2041
+ const result = await edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2042
+
2043
+ expect(result.passed).to.equal(true);
2044
+ expect(result.requestId).to.equal('req-123');
2045
+ expect(result.details.bot.status).to.equal(200);
2046
+ });
2047
+
2048
+ it('ignores non-edgeoptimize response headers', async () => {
2049
+ fetchStub = sinon.stub(global, 'fetch');
2050
+ fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-1', 'content-type': 'text/html' }));
2051
+ fetchStub.onSecondCall().resolves(makeResponse(200, { 'cache-control': 'no-store' }));
2052
+
2053
+ const result = await edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2054
+
2055
+ expect(result.details.bot.headers['content-type']).to.equal(undefined);
2056
+ expect(result.passed).to.equal(true);
2057
+ });
2058
+
2059
+ it('does NOT pass when only failover (x-edgeoptimize-fo) is present', async () => {
2060
+ fetchStub = sinon.stub(global, 'fetch');
2061
+ fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-fo': '1' }));
2062
+ fetchStub.onSecondCall().resolves(makeResponse(200, {}));
2063
+
2064
+ const result = await edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2065
+
2066
+ expect(result.passed).to.equal(false);
2067
+ expect(result.requestId).to.equal(null);
2068
+ });
2069
+
2070
+ it('does NOT pass when the human response is also optimized', async () => {
2071
+ fetchStub = sinon.stub(global, 'fetch');
2072
+ fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' }));
2073
+ fetchStub.onSecondCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-999' }));
2074
+
2075
+ const result = await edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2076
+
2077
+ expect(result.passed).to.equal(false);
2078
+ });
2079
+
2080
+ it('does NOT pass when the human response carries the proxy marker', async () => {
2081
+ fetchStub = sinon.stub(global, 'fetch');
2082
+ fetchStub.onFirstCall().resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'req-123' }));
2083
+ fetchStub.onSecondCall().resolves(makeResponse(200, { 'x-edgeoptimize-proxy': '1' }));
2084
+
2085
+ const result = await edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2086
+
2087
+ expect(result.passed).to.equal(false);
2088
+ });
2089
+
2090
+ it('passes a bounded AbortSignal to each probe', async () => {
2091
+ fetchStub = sinon.stub(global, 'fetch');
2092
+ fetchStub.resolves(makeResponse(200, { 'x-edgeoptimize-request-id': 'r' }));
2093
+
2094
+ await edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2095
+
2096
+ // Each probe carries an AbortSignal so it can be cancelled at the bounded timeout.
2097
+ expect(fetchStub.firstCall.args[1].signal).to.be.instanceOf(AbortSignal);
2098
+ expect(edgeOptimize.EDGE_OPTIMIZE_VERIFY_PROBE_TIMEOUT_MS).to.equal(20000);
2099
+ });
2100
+
2101
+ it('resolves passed:false on a network error instead of throwing', async () => {
2102
+ fetchStub = sinon.stub(global, 'fetch');
2103
+ // Bot probe errors at the network layer; it must resolve to a non-passing { status: 0 }
2104
+ // result (NOT throw) so the FE poll loop simply retries. The human probe still runs.
2105
+ fetchStub.onFirstCall().rejects(new Error('ECONNREFUSED'));
2106
+ fetchStub.onSecondCall().resolves(makeResponse(200, {}));
2107
+
2108
+ const result = await edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2109
+
2110
+ expect(result.passed).to.equal(false);
2111
+ expect(result.requestId).to.equal(null);
2112
+ expect(result.details.bot.status).to.equal(0);
2113
+ expect(result.details.bot.headers).to.deep.equal({});
2114
+ // A plain network error is not flagged as a timeout.
2115
+ expect(result.details.bot.timedOut).to.equal(undefined);
2116
+ expect(result.details.human.status).to.equal(200);
2117
+ });
2118
+
2119
+ it('aborts a probe that exceeds the bounded timeout and resolves passed:false', async () => {
2120
+ const clock = sinon.useFakeTimers();
2121
+ fetchStub = sinon.stub(global, 'fetch');
2122
+ // Bot probe hangs until its abort signal fires (the bounded-timeout abort); the human
2123
+ // probe resolves immediately. Driving the fake clock past the timeout triggers the abort.
2124
+ fetchStub.onFirstCall().callsFake((url, opts) => new Promise((resolve, reject) => {
2125
+ opts.signal.addEventListener('abort', () => {
2126
+ const err = new Error('aborted');
2127
+ err.name = 'AbortError';
2128
+ reject(err);
2129
+ });
2130
+ }));
2131
+ fetchStub.onSecondCall().resolves(makeResponse(200, {}));
2132
+
2133
+ const promise = edgeOptimize.verifyRouting('https://d.cloudfront.net/');
2134
+ await clock.tickAsync(edgeOptimize.EDGE_OPTIMIZE_VERIFY_PROBE_TIMEOUT_MS);
2135
+ const result = await promise;
2136
+
2137
+ expect(result.passed).to.equal(false);
2138
+ expect(result.requestId).to.equal(null);
2139
+ expect(result.details.bot.status).to.equal(0);
2140
+ // An abort (timeout) is flagged so callers can tell "still warming up" from a hard failure.
2141
+ expect(result.details.bot.timedOut).to.equal(true);
2142
+ clock.restore();
2143
+ });
2144
+
2145
+ it('throws when url is missing', async () => {
2146
+ let error;
2147
+ try {
2148
+ await edgeOptimize.verifyRouting('');
2149
+ } catch (e) {
2150
+ error = e;
2151
+ }
2152
+ expect(error.message).to.include('url');
2153
+ });
2154
+ });
2155
+
2156
+ describe('EDGE_OPTIMIZE_DEPLOY_STEPS', () => {
2157
+ it('exposes the ordered step contract', () => {
2158
+ expect(edgeOptimize.EDGE_OPTIMIZE_DEPLOY_STEPS.map((s) => s.key)).to.deep.equal([
2159
+ 'origin', 'function', 'cache', 'lambda', 'associate', 'propagation', 'verify',
2160
+ ]);
2161
+ });
2162
+ });
2163
+
2164
+ describe('runDeployStep', () => {
2165
+ let fetchStub;
2166
+ const deployParams = {
2167
+ distributionId: 'E2EXAMPLE123',
2168
+ originId: 'origin-aem',
2169
+ behavior: 'default',
2170
+ originDomain: 'dev.edgeoptimize.net',
2171
+ originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' },
2172
+ accountId: '120569600543',
2173
+ };
2174
+
2175
+ // Dispatch each client's send() by command name; per-test overrides via the `r` map.
2176
+ const wire = (cf = {}, lambda = {}, iam = {}) => {
2177
+ cfSendStub.callsFake((cmd) => {
2178
+ const fn = cf[cmd.commandName];
2179
+ if (fn === undefined) {
2180
+ throw new Error(`unexpected cf command: ${cmd.commandName}`);
2181
+ }
2182
+ return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn);
2183
+ });
2184
+ lambdaSendStub.callsFake((cmd) => {
2185
+ const fn = lambda[cmd.commandName];
2186
+ if (fn === undefined) {
2187
+ throw new Error(`unexpected lambda command: ${cmd.commandName}`);
2188
+ }
2189
+ return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn);
2190
+ });
2191
+ iamSendStub.callsFake((cmd) => {
2192
+ const fn = iam[cmd.commandName];
2193
+ if (fn === undefined) {
2194
+ throw new Error(`unexpected iam command: ${cmd.commandName}`);
2195
+ }
2196
+ return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn);
2197
+ });
2198
+ };
2199
+
2200
+ const statusOf = (steps, key) => steps.find((s) => s.key === key).status;
2201
+ const cfCalls = (name) => cfSendStub.getCalls().filter((c) => c.args[0].commandName === name);
2202
+
2203
+ // Returns a responder that throws an AWS-style named error (so the SDK error path triggers).
2204
+ const throwNamed = (name, message) => () => {
2205
+ const e = new Error(message);
2206
+ e.name = name;
2207
+ throw e;
2208
+ };
2209
+
2210
+ const iamCalls = (name) => iamSendStub.getCalls().filter((c) => c.args[0].commandName === name);
2211
+
2212
+ // Encoded trust doc allowing both Lambda@Edge principals — what inspectRole treats as valid.
2213
+ const validTrust = encodeURIComponent(JSON.stringify({
2214
+ Version: '2012-10-17',
2215
+ Statement: [{
2216
+ Effect: 'Allow',
2217
+ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] },
2218
+ Action: 'sts:AssumeRole',
2219
+ }],
2220
+ }));
2221
+ // IAM mock for an existing, correctly-configured role (roleExists + roleOk = true).
2222
+ const okRoleIam = (extra = {}) => ({
2223
+ GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: validTrust } },
2224
+ GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' },
2225
+ UpdateAssumeRolePolicy: {},
2226
+ PutRolePolicy: {},
2227
+ ...extra,
2228
+ });
2229
+
2230
+ // CF + Lambda wiring for "function already published, behavior not yet associated, propagation
2231
+ // still in progress" — the role-heal gate tests reuse this and only vary the IAM/role mock.
2232
+ const readyDeployCf = () => ({
2233
+ GetDistributionConfig: () => ({
2234
+ DistributionConfig: {
2235
+ Origins: {
2236
+ Items: [{
2237
+ Id: 'EdgeOptimize_Origin',
2238
+ DomainName: 'dev.edgeoptimize.net',
2239
+ CustomHeaders: {
2240
+ Items: [
2241
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
2242
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
2243
+ ],
2244
+ },
2245
+ }],
2246
+ },
2247
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
2248
+ },
2249
+ ETag: 'etag',
2250
+ }),
2251
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2252
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2253
+ GetCachePolicyConfig: {
2254
+ CachePolicyConfig: {
2255
+ Name: 'p',
2256
+ MinTTL: 0,
2257
+ ParametersInCacheKeyAndForwardedToOrigin: {
2258
+ HeadersConfig: {
2259
+ HeaderBehavior: 'whitelist',
2260
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
2261
+ },
2262
+ },
2263
+ },
2264
+ ETag: 'cp-etag',
2265
+ },
2266
+ UpdateDistribution: {},
2267
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'InProgress' } },
2268
+ });
2269
+ const readyLambda = () => ({
2270
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2271
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: 'arn:lambda:3', CodeSha256: 'sha' }] },
2272
+ });
2273
+
2274
+ const makeFetchResponse = (status, headerMap) => ({
2275
+ status,
2276
+ headers: { forEach: (cb) => Object.entries(headerMap).forEach(([k, v]) => cb(v, k)) },
2277
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
2278
+ });
2279
+
2280
+ afterEach(() => {
2281
+ if (fetchStub) {
2282
+ fetchStub.restore();
2283
+ }
2284
+ fetchStub = undefined;
2285
+ });
2286
+
2287
+ it('first call advances origin+function+cache and returns lambda in_progress (others pending)', async () => {
2288
+ wire(
2289
+ {
2290
+ // origin: existing with matching headers → idempotent no-op (no UpdateDistribution).
2291
+ GetDistributionConfig: {
2292
+ DistributionConfig: {
2293
+ Origins: {
2294
+ Items: [{
2295
+ Id: 'EdgeOptimize_Origin',
2296
+ DomainName: 'dev.edgeoptimize.net',
2297
+ CustomHeaders: {
2298
+ Items: [
2299
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
2300
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
2301
+ ],
2302
+ },
2303
+ }],
2304
+ },
2305
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
2306
+ },
2307
+ ETag: 'etag',
2308
+ },
2309
+ // function gate: already published to LIVE → skip create+publish.
2310
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2311
+ // cache: custom policy already forwards EO headers + MinTTL 0 → no-op.
2312
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2313
+ GetCachePolicyConfig: {
2314
+ CachePolicyConfig: {
2315
+ Name: 'p',
2316
+ MinTTL: 0,
2317
+ ParametersInCacheKeyAndForwardedToOrigin: {
2318
+ HeadersConfig: {
2319
+ HeaderBehavior: 'whitelist',
2320
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
2321
+ },
2322
+ },
2323
+ },
2324
+ ETag: 'cp-etag',
2325
+ },
2326
+ },
2327
+ {
2328
+ // lambda: does not exist yet → kick off create → in_progress.
2329
+ GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'),
2330
+ ListVersionsByFunction: { Versions: [] },
2331
+ CreateFunction: { FunctionArn: 'arn:lambda', Version: '$LATEST' },
2332
+ },
2333
+ // Role already exists + correctly configured → no role-propagation wait.
2334
+ okRoleIam(),
2335
+ );
2336
+
2337
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
2338
+
2339
+ expect(statusOf(out.steps, 'origin')).to.equal('done');
2340
+ expect(statusOf(out.steps, 'function')).to.equal('done');
2341
+ expect(statusOf(out.steps, 'cache')).to.equal('done');
2342
+ expect(statusOf(out.steps, 'lambda')).to.equal('in_progress');
2343
+ expect(statusOf(out.steps, 'associate')).to.equal('pending');
2344
+ expect(statusOf(out.steps, 'verify')).to.equal('pending');
2345
+ expect(out.routingDeployed).to.equal(false);
2346
+ expect(out.verified).to.equal(false);
2347
+ // function already LIVE → never created/published.
2348
+ expect(cfCalls('CreateFunction')).to.have.length(0);
2349
+ expect(cfCalls('PublishFunction')).to.have.length(0);
2350
+ });
2351
+
2352
+ it('creates the routing function when not yet LIVE', async () => {
2353
+ wire(
2354
+ {
2355
+ GetDistributionConfig: {
2356
+ DistributionConfig: {
2357
+ Origins: {
2358
+ Items: [{
2359
+ Id: 'EdgeOptimize_Origin',
2360
+ DomainName: 'dev.edgeoptimize.net',
2361
+ CustomHeaders: {
2362
+ Items: [
2363
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
2364
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
2365
+ ],
2366
+ },
2367
+ }],
2368
+ },
2369
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
2370
+ },
2371
+ ETag: 'etag',
2372
+ },
2373
+ // function gate: not LIVE → create+publish path runs.
2374
+ DescribeFunction: (cmd) => {
2375
+ if (cmd.input.Stage === 'LIVE') {
2376
+ return Promise.reject(Object.assign(new Error('no live'), { name: 'NoSuchFunctionExists' }));
2377
+ }
2378
+ // DEVELOPMENT lookup inside createCloudFrontFunction → also missing.
2379
+ return Promise.reject(Object.assign(new Error('no dev'), { name: 'NoSuchFunctionExists' }));
2380
+ },
2381
+ CreateFunction: { ETag: 'fn-etag' },
2382
+ PublishFunction: {},
2383
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2384
+ GetCachePolicyConfig: {
2385
+ CachePolicyConfig: {
2386
+ Name: 'p',
2387
+ MinTTL: 0,
2388
+ ParametersInCacheKeyAndForwardedToOrigin: {
2389
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2390
+ },
2391
+ },
2392
+ ETag: 'cp-etag',
2393
+ },
2394
+ },
2395
+ {
2396
+ GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'),
2397
+ ListVersionsByFunction: { Versions: [] },
2398
+ CreateFunction: { FunctionArn: 'arn:lambda' },
2399
+ },
2400
+ okRoleIam(),
2401
+ );
2402
+
2403
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
2404
+
2405
+ expect(statusOf(out.steps, 'function')).to.equal('done');
2406
+ expect(cfCalls('CreateFunction')).to.have.length(1);
2407
+ expect(cfCalls('PublishFunction')).to.have.length(1);
2408
+ });
2409
+
2410
+ it('with lambda ready proceeds to associate then verify (in_progress until propagation)', async () => {
2411
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2412
+ wire(
2413
+ {
2414
+ GetDistributionConfig: () => ({
2415
+ // origin exists (idempotent), default behavior NOT yet associated (associate must run).
2416
+ DistributionConfig: {
2417
+ Origins: {
2418
+ Items: [{
2419
+ Id: 'EdgeOptimize_Origin',
2420
+ DomainName: 'dev.edgeoptimize.net',
2421
+ CustomHeaders: {
2422
+ Items: [
2423
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
2424
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
2425
+ ],
2426
+ },
2427
+ }],
2428
+ },
2429
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
2430
+ },
2431
+ ETag: 'etag',
2432
+ }),
2433
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2434
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2435
+ GetCachePolicyConfig: {
2436
+ CachePolicyConfig: {
2437
+ Name: 'p',
2438
+ MinTTL: 0,
2439
+ ParametersInCacheKeyAndForwardedToOrigin: {
2440
+ HeadersConfig: {
2441
+ HeaderBehavior: 'whitelist',
2442
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
2443
+ },
2444
+ },
2445
+ },
2446
+ ETag: 'cp-etag',
2447
+ },
2448
+ UpdateDistribution: {},
2449
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'Deployed' } },
2450
+ },
2451
+ {
2452
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2453
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2454
+ },
2455
+ okRoleIam(),
2456
+ );
2457
+ // verify probe: bot lacks request-id → not passed yet (propagation).
2458
+ fetchStub = sinon.stub(global, 'fetch');
2459
+ fetchStub.onFirstCall().resolves(makeFetchResponse(200, {}));
2460
+ fetchStub.onSecondCall().resolves(makeFetchResponse(200, {}));
2461
+
2462
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
2463
+
2464
+ expect(statusOf(out.steps, 'lambda')).to.equal('done');
2465
+ expect(statusOf(out.steps, 'associate')).to.equal('done');
2466
+ expect(statusOf(out.steps, 'verify')).to.equal('in_progress');
2467
+ expect(out.routingDeployed).to.equal(true);
2468
+ expect(out.verified).to.equal(false);
2469
+ // associate ran exactly one UpdateDistribution (behavior was not associated).
2470
+ expect(cfCalls('UpdateDistribution')).to.have.length(1);
2471
+ // verify probes the customer's REAL host (forwardedHost), not the dist domain.
2472
+ expect(out.steps.find((s) => s.key === 'verify').detail).to.equal('waiting for propagation');
2473
+ expect(fetchStub.firstCall.args[0]).to.equal('https://www.example.com/');
2474
+ });
2475
+
2476
+ it('verify passes → verified true and verify done', async () => {
2477
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2478
+ wire(
2479
+ {
2480
+ GetDistributionConfig: () => ({
2481
+ DistributionConfig: {
2482
+ Origins: {
2483
+ Items: [{
2484
+ Id: 'EdgeOptimize_Origin',
2485
+ DomainName: 'dev.edgeoptimize.net',
2486
+ CustomHeaders: {
2487
+ Items: [
2488
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
2489
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
2490
+ ],
2491
+ },
2492
+ }],
2493
+ },
2494
+ // already associated → associate gate skips UpdateDistribution.
2495
+ DefaultCacheBehavior: {
2496
+ CachePolicyId: 'cp-1',
2497
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2498
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2499
+ },
2500
+ },
2501
+ ETag: 'etag',
2502
+ }),
2503
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2504
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2505
+ GetCachePolicyConfig: {
2506
+ CachePolicyConfig: {
2507
+ Name: 'p',
2508
+ MinTTL: 0,
2509
+ ParametersInCacheKeyAndForwardedToOrigin: {
2510
+ HeadersConfig: {
2511
+ HeaderBehavior: 'whitelist',
2512
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
2513
+ },
2514
+ },
2515
+ },
2516
+ ETag: 'cp-etag',
2517
+ },
2518
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'Deployed' } },
2519
+ },
2520
+ {
2521
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2522
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2523
+ },
2524
+ okRoleIam(),
2525
+ );
2526
+ fetchStub = sinon.stub(global, 'fetch');
2527
+ fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' }));
2528
+ fetchStub.onSecondCall().resolves(makeFetchResponse(200, {}));
2529
+
2530
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
2531
+
2532
+ expect(statusOf(out.steps, 'associate')).to.equal('done');
2533
+ expect(statusOf(out.steps, 'propagation')).to.equal('done');
2534
+ expect(statusOf(out.steps, 'verify')).to.equal('done');
2535
+ expect(out.routingDeployed).to.equal(true);
2536
+ expect(out.verified).to.equal(true);
2537
+ // verify probe surfaces the per-UA result the wizard renders.
2538
+ const verifyProbe = out.steps.find((s) => s.key === 'verify').probe;
2539
+ expect(verifyProbe.domain).to.equal('www.example.com'); // real host, not dist domain
2540
+ expect(verifyProbe.bot).to.deep.include({ ua: 'chatgpt-user', requestId: 'req-1', failover: false });
2541
+ expect(verifyProbe.human.requestId).to.equal(null);
2542
+ // idempotent gate: behavior already associated → no UpdateDistribution at all.
2543
+ expect(cfCalls('UpdateDistribution')).to.have.length(0);
2544
+ });
2545
+
2546
+ it('verify falls back to the distribution domain when no forwardedHost is supplied', async () => {
2547
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2548
+ wire(
2549
+ {
2550
+ GetDistributionConfig: () => ({
2551
+ DistributionConfig: {
2552
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2553
+ DefaultCacheBehavior: {
2554
+ CachePolicyId: 'cp-1',
2555
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2556
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2557
+ },
2558
+ },
2559
+ ETag: 'etag',
2560
+ }),
2561
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2562
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2563
+ GetCachePolicyConfig: {
2564
+ CachePolicyConfig: {
2565
+ Name: 'p',
2566
+ MinTTL: 0,
2567
+ ParametersInCacheKeyAndForwardedToOrigin: {
2568
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2569
+ },
2570
+ },
2571
+ ETag: 'cp-etag',
2572
+ },
2573
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'Deployed' } },
2574
+ },
2575
+ {
2576
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2577
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2578
+ },
2579
+ okRoleIam(),
2580
+ );
2581
+ fetchStub = sinon.stub(global, 'fetch');
2582
+ fetchStub.onFirstCall().resolves(makeFetchResponse(200, {}));
2583
+ fetchStub.onSecondCall().resolves(makeFetchResponse(200, {}));
2584
+
2585
+ // No originHeaders → forwardedHost empty → verify falls back to the dist domain.
2586
+ const noHostParams = { ...deployParams, originHeaders: undefined };
2587
+ const out = await edgeOptimize.runDeployStep({}, noHostParams);
2588
+
2589
+ expect(out.steps.find((s) => s.key === 'verify').probe.domain).to.equal('d123.cloudfront.net');
2590
+ expect(fetchStub.firstCall.args[0]).to.equal('https://d123.cloudfront.net/');
2591
+ });
2592
+
2593
+ it('holds verify "waiting for domain" when neither forwardedHost nor a dist domain is known', async () => {
2594
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2595
+ wire(
2596
+ {
2597
+ GetDistributionConfig: () => ({
2598
+ DistributionConfig: {
2599
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2600
+ DefaultCacheBehavior: {
2601
+ CachePolicyId: 'cp-1',
2602
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2603
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2604
+ },
2605
+ },
2606
+ ETag: 'etag',
2607
+ }),
2608
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2609
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2610
+ GetCachePolicyConfig: {
2611
+ CachePolicyConfig: {
2612
+ Name: 'p',
2613
+ MinTTL: 0,
2614
+ ParametersInCacheKeyAndForwardedToOrigin: {
2615
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2616
+ },
2617
+ },
2618
+ ETag: 'cp-etag',
2619
+ },
2620
+ // distribution deployed but reports no domain name → no host to verify.
2621
+ GetDistribution: { Distribution: { DomainName: '', Status: 'Deployed' } },
2622
+ UpdateDistribution: {}, // origin self-heal write (api-key header added)
2623
+ },
2624
+ {
2625
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2626
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2627
+ },
2628
+ okRoleIam(),
2629
+ );
2630
+
2631
+ // originHeaders carries an apiKey but NO forwardedHost → verify domain falls back to the
2632
+ // (empty) dist domain → "waiting for domain".
2633
+ const out = await edgeOptimize.runDeployStep({}, { ...deployParams, originHeaders: { apiKey: 'k' } });
2634
+
2635
+ expect(statusOf(out.steps, 'verify')).to.equal('in_progress');
2636
+ expect(out.steps.find((s) => s.key === 'verify').detail).to.equal('waiting for domain');
2637
+ });
2638
+
2639
+ it('surfaces verify failover (x-edgeoptimize-fo) as in_progress', async () => {
2640
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2641
+ wire(
2642
+ {
2643
+ GetDistributionConfig: () => ({
2644
+ DistributionConfig: {
2645
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2646
+ DefaultCacheBehavior: {
2647
+ CachePolicyId: 'cp-1',
2648
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2649
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2650
+ },
2651
+ },
2652
+ ETag: 'etag',
2653
+ }),
2654
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2655
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2656
+ GetCachePolicyConfig: {
2657
+ CachePolicyConfig: {
2658
+ Name: 'p',
2659
+ MinTTL: 0,
2660
+ ParametersInCacheKeyAndForwardedToOrigin: {
2661
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2662
+ },
2663
+ },
2664
+ ETag: 'cp-etag',
2665
+ },
2666
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'Deployed' } },
2667
+ },
2668
+ {
2669
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2670
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2671
+ },
2672
+ okRoleIam(),
2673
+ );
2674
+ fetchStub = sinon.stub(global, 'fetch');
2675
+ fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-fo': '1' }));
2676
+ fetchStub.onSecondCall().resolves(makeFetchResponse(200, {}));
2677
+
2678
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
2679
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
2680
+
2681
+ expect(statusOf(out.steps, 'verify')).to.equal('in_progress');
2682
+ expect(out.steps.find((s) => s.key === 'verify').detail).to.include('failover');
2683
+ });
2684
+
2685
+ it('keeps verify in_progress when a probe network error resolves to a non-passing result', async () => {
2686
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2687
+ wire(
2688
+ {
2689
+ GetDistributionConfig: () => ({
2690
+ DistributionConfig: {
2691
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2692
+ DefaultCacheBehavior: {
2693
+ CachePolicyId: 'cp-1',
2694
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2695
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2696
+ },
2697
+ },
2698
+ ETag: 'etag',
2699
+ }),
2700
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2701
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2702
+ GetCachePolicyConfig: {
2703
+ CachePolicyConfig: {
2704
+ Name: 'p',
2705
+ MinTTL: 0,
2706
+ ParametersInCacheKeyAndForwardedToOrigin: {
2707
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2708
+ },
2709
+ },
2710
+ ETag: 'cp-etag',
2711
+ },
2712
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'Deployed' } },
2713
+ },
2714
+ {
2715
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2716
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2717
+ },
2718
+ okRoleIam(),
2719
+ );
2720
+ fetchStub = sinon.stub(global, 'fetch');
2721
+ // Both probes hit a network error. After the bounded-timeout fix they resolve to a
2722
+ // non-passing result ({ status: 0 }) instead of throwing, so the deploy never fails — verify
2723
+ // simply stays in_progress and the FE poll loop retries on the next poll.
2724
+ fetchStub.rejects(new Error('network down'));
2725
+
2726
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
2727
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
2728
+
2729
+ expect(statusOf(out.steps, 'verify')).to.equal('in_progress');
2730
+ expect(out.steps.find((s) => s.key === 'verify').detail).to.equal('waiting for propagation');
2731
+ });
2732
+
2733
+ it('holds at propagation (verify pending) while the distribution is still Deploying', async () => {
2734
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2735
+ wire(
2736
+ {
2737
+ GetDistributionConfig: () => ({
2738
+ DistributionConfig: {
2739
+ Origins: {
2740
+ Items: [{
2741
+ Id: 'EdgeOptimize_Origin',
2742
+ DomainName: 'dev.edgeoptimize.net',
2743
+ CustomHeaders: {
2744
+ Items: [
2745
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
2746
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
2747
+ ],
2748
+ },
2749
+ }],
2750
+ },
2751
+ DefaultCacheBehavior: {
2752
+ CachePolicyId: 'cp-1',
2753
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2754
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2755
+ },
2756
+ },
2757
+ ETag: 'etag',
2758
+ }),
2759
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2760
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2761
+ GetCachePolicyConfig: {
2762
+ CachePolicyConfig: {
2763
+ Name: 'p',
2764
+ MinTTL: 0,
2765
+ ParametersInCacheKeyAndForwardedToOrigin: {
2766
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2767
+ },
2768
+ },
2769
+ ETag: 'cp-etag',
2770
+ },
2771
+ // distribution still deploying → propagation gate holds, verify never runs.
2772
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'InProgress' } },
2773
+ },
2774
+ {
2775
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2776
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2777
+ },
2778
+ okRoleIam(),
2779
+ );
2780
+
2781
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
2782
+
2783
+ expect(statusOf(out.steps, 'associate')).to.equal('done');
2784
+ expect(statusOf(out.steps, 'propagation')).to.equal('in_progress');
2785
+ expect(statusOf(out.steps, 'verify')).to.equal('pending');
2786
+ expect(out.steps.find((s) => s.key === 'propagation').detail).to.include('Deploying');
2787
+ expect(out.verified).to.equal(false);
2788
+ });
2789
+
2790
+ it('holds at propagation when the distribution is not yet returned', async () => {
2791
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2792
+ wire(
2793
+ {
2794
+ GetDistributionConfig: () => ({
2795
+ DistributionConfig: {
2796
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2797
+ DefaultCacheBehavior: {
2798
+ CachePolicyId: 'cp-1',
2799
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2800
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2801
+ },
2802
+ },
2803
+ ETag: 'etag',
2804
+ }),
2805
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2806
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2807
+ GetCachePolicyConfig: {
2808
+ CachePolicyConfig: {
2809
+ Name: 'p',
2810
+ MinTTL: 0,
2811
+ ParametersInCacheKeyAndForwardedToOrigin: {
2812
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2813
+ },
2814
+ },
2815
+ ETag: 'cp-etag',
2816
+ },
2817
+ // distribution not returned yet → "waiting for the distribution to appear".
2818
+ GetDistribution: {},
2819
+ },
2820
+ {
2821
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2822
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2823
+ },
2824
+ okRoleIam(),
2825
+ );
2826
+
2827
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
2828
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
2829
+
2830
+ expect(statusOf(out.steps, 'propagation')).to.equal('in_progress');
2831
+ expect(out.steps.find((s) => s.key === 'propagation').detail).to.include('waiting for the distribution');
2832
+ });
2833
+
2834
+ it('marks propagation in_progress when getting the distribution fails', async () => {
2835
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2836
+ wire(
2837
+ {
2838
+ GetDistributionConfig: () => ({
2839
+ DistributionConfig: {
2840
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2841
+ DefaultCacheBehavior: {
2842
+ CachePolicyId: 'cp-1',
2843
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
2844
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
2845
+ },
2846
+ },
2847
+ ETag: 'etag',
2848
+ }),
2849
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2850
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2851
+ GetCachePolicyConfig: {
2852
+ CachePolicyConfig: {
2853
+ Name: 'p',
2854
+ MinTTL: 0,
2855
+ ParametersInCacheKeyAndForwardedToOrigin: {
2856
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
2857
+ },
2858
+ },
2859
+ ETag: 'cp-etag',
2860
+ },
2861
+ GetDistribution: () => { throw new Error('get failed'); },
2862
+ },
2863
+ {
2864
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
2865
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
2866
+ },
2867
+ okRoleIam(),
2868
+ );
2869
+
2870
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
2871
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
2872
+
2873
+ expect(statusOf(out.steps, 'propagation')).to.equal('in_progress');
2874
+ expect(out.steps.find((s) => s.key === 'propagation').detail).to.equal('get failed');
2875
+ });
2876
+
2877
+ it('marks the step error (earlier done, later pending) and does not throw when a step fails', async () => {
2878
+ wire(
2879
+ {
2880
+ GetDistributionConfig: {
2881
+ DistributionConfig: {
2882
+ Origins: {
2883
+ Items: [{
2884
+ Id: 'EdgeOptimize_Origin',
2885
+ DomainName: 'dev.edgeoptimize.net',
2886
+ CustomHeaders: {
2887
+ Items: [
2888
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
2889
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
2890
+ ],
2891
+ },
2892
+ }],
2893
+ },
2894
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
2895
+ },
2896
+ ETag: 'etag',
2897
+ },
2898
+ // function gate DescribeFunction throws a non-NoSuchFunction error → step error.
2899
+ DescribeFunction: () => { throw new Error('AccessDenied on DescribeFunction'); },
2900
+ },
2901
+ );
2902
+
2903
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
2904
+
2905
+ expect(statusOf(out.steps, 'origin')).to.equal('done');
2906
+ expect(statusOf(out.steps, 'function')).to.equal('error');
2907
+ expect(out.steps.find((s) => s.key === 'function').detail).to.include('AccessDenied');
2908
+ // later steps remain pending.
2909
+ expect(statusOf(out.steps, 'cache')).to.equal('pending');
2910
+ expect(statusOf(out.steps, 'lambda')).to.equal('pending');
2911
+ expect(out.routingDeployed).to.equal(false);
2912
+ });
2913
+
2914
+ it('marks the origin step error (raw err.message) and stops the sequence', async () => {
2915
+ wire(
2916
+ {
2917
+ GetDistributionConfig: () => { throw new Error('GetDistributionConfig denied'); },
2918
+ },
2919
+ );
2920
+
2921
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
2922
+
2923
+ expect(statusOf(out.steps, 'origin')).to.equal('error');
2924
+ expect(out.steps.find((s) => s.key === 'origin').detail).to.equal('GetDistributionConfig denied');
2925
+ expect(statusOf(out.steps, 'function')).to.equal('pending');
2926
+ });
2927
+
2928
+ it('marks the cache step error (raw err.message) and stops the sequence', async () => {
2929
+ wire(
2930
+ {
2931
+ GetDistributionConfig: () => ({
2932
+ DistributionConfig: {
2933
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2934
+ // No CachePolicyId and a behavior lookup mismatch: target a missing named behavior.
2935
+ CacheBehaviors: { Items: [] },
2936
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
2937
+ },
2938
+ ETag: 'etag',
2939
+ }),
2940
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2941
+ ListCachePolicies: () => { throw new Error('ListCachePolicies denied'); },
2942
+ },
2943
+ );
2944
+
2945
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
2946
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
2947
+
2948
+ expect(statusOf(out.steps, 'cache')).to.equal('error');
2949
+ expect(out.steps.find((s) => s.key === 'cache').detail).to.equal('ListCachePolicies denied');
2950
+ expect(statusOf(out.steps, 'lambda')).to.equal('pending');
2951
+ });
2952
+
2953
+ it('marks the lambda step error (raw err.message) and stops the sequence', async () => {
2954
+ wire(
2955
+ {
2956
+ GetDistributionConfig: {
2957
+ DistributionConfig: {
2958
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
2959
+ DefaultCacheBehavior: {
2960
+ CachePolicyId: 'cp-1',
2961
+ ForwardedValues: undefined,
2962
+ },
2963
+ },
2964
+ ETag: 'etag',
2965
+ },
2966
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
2967
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
2968
+ GetCachePolicyConfig: {
2969
+ CachePolicyConfig: {
2970
+ Name: 'p',
2971
+ MinTTL: 0,
2972
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } } },
2973
+ },
2974
+ ETag: 'cp-etag',
2975
+ },
2976
+ },
2977
+ {
2978
+ GetFunctionConfiguration: () => { throw Object.assign(new Error('lambda denied'), { name: 'AccessDenied' }); },
2979
+ },
2980
+ okRoleIam(),
2981
+ );
2982
+
2983
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
2984
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
2985
+
2986
+ expect(statusOf(out.steps, 'lambda')).to.equal('error');
2987
+ expect(out.steps.find((s) => s.key === 'lambda').detail).to.equal('lambda denied');
2988
+ expect(statusOf(out.steps, 'associate')).to.equal('pending');
2989
+ });
2990
+
2991
+ it('marks the associate step error (raw err.message) and stops the sequence', async () => {
2992
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
2993
+ let getCfgCount = 0;
2994
+ wire(
2995
+ {
2996
+ GetDistributionConfig: () => {
2997
+ getCfgCount += 1;
2998
+ // 1st: origin (idempotent). 2nd: associate gate (not associated). 3rd: associate write.
2999
+ if (getCfgCount === 3) {
3000
+ throw new Error('associate read denied');
3001
+ }
3002
+ return {
3003
+ DistributionConfig: {
3004
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
3005
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
3006
+ },
3007
+ ETag: 'etag',
3008
+ };
3009
+ },
3010
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3011
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3012
+ GetCachePolicyConfig: {
3013
+ CachePolicyConfig: {
3014
+ Name: 'p',
3015
+ MinTTL: 0,
3016
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } } },
3017
+ },
3018
+ ETag: 'cp-etag',
3019
+ },
3020
+ },
3021
+ {
3022
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
3023
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
3024
+ },
3025
+ okRoleIam(),
3026
+ );
3027
+
3028
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
3029
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
3030
+
3031
+ expect(statusOf(out.steps, 'associate')).to.equal('error');
3032
+ expect(out.steps.find((s) => s.key === 'associate').detail).to.equal('associate read denied');
3033
+ expect(statusOf(out.steps, 'propagation')).to.equal('pending');
3034
+ });
3035
+
3036
+ it('holds the sequence when lambda exists but is not yet ready (no re-create)', async () => {
3037
+ wire(
3038
+ {
3039
+ GetDistributionConfig: {
3040
+ DistributionConfig: {
3041
+ Origins: {
3042
+ Items: [{
3043
+ Id: 'EdgeOptimize_Origin',
3044
+ DomainName: 'dev.edgeoptimize.net',
3045
+ CustomHeaders: {
3046
+ Items: [
3047
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
3048
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
3049
+ ],
3050
+ },
3051
+ }],
3052
+ },
3053
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
3054
+ },
3055
+ ETag: 'etag',
3056
+ },
3057
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3058
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3059
+ GetCachePolicyConfig: {
3060
+ CachePolicyConfig: {
3061
+ Name: 'p',
3062
+ MinTTL: 0,
3063
+ ParametersInCacheKeyAndForwardedToOrigin: {
3064
+ HeadersConfig: {
3065
+ HeaderBehavior: 'whitelist',
3066
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
3067
+ },
3068
+ },
3069
+ },
3070
+ ETag: 'cp-etag',
3071
+ },
3072
+ },
3073
+ {
3074
+ // exists but still finalizing (Pending) → createLambdaAtEdge is called to drive the
3075
+ // state machine, but it must NOT CreateFunction or PublishVersion while still Pending.
3076
+ GetFunctionConfiguration: { State: 'Pending', LastUpdateStatus: 'InProgress', FunctionArn: 'arn:lambda' },
3077
+ ListVersionsByFunction: { Versions: [] },
3078
+ },
3079
+ okRoleIam(),
3080
+ );
3081
+
3082
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
3083
+
3084
+ expect(statusOf(out.steps, 'lambda')).to.equal('in_progress');
3085
+ expect(statusOf(out.steps, 'associate')).to.equal('pending');
3086
+ // Pending → neither CreateFunction nor PublishVersion (no re-create, no premature publish).
3087
+ expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'CreateFunction')).to.have.length(0);
3088
+ expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(0);
3089
+ });
3090
+
3091
+ it('reports lambda create started when the function does not exist yet', async () => {
3092
+ wire(
3093
+ {
3094
+ GetDistributionConfig: {
3095
+ DistributionConfig: {
3096
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
3097
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
3098
+ },
3099
+ ETag: 'etag',
3100
+ },
3101
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3102
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3103
+ GetCachePolicyConfig: {
3104
+ CachePolicyConfig: {
3105
+ Name: 'p',
3106
+ MinTTL: 0,
3107
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } } },
3108
+ },
3109
+ ETag: 'cp-etag',
3110
+ },
3111
+ },
3112
+ {
3113
+ GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'),
3114
+ ListVersionsByFunction: { Versions: [] },
3115
+ CreateFunction: { FunctionArn: 'arn:lambda' },
3116
+ },
3117
+ okRoleIam(),
3118
+ );
3119
+
3120
+ const noHeaderParams = { ...deployParams, originHeaders: undefined };
3121
+ const out = await edgeOptimize.runDeployStep({}, noHeaderParams);
3122
+
3123
+ expect(statusOf(out.steps, 'lambda')).to.equal('in_progress');
3124
+ expect(out.steps.find((s) => s.key === 'lambda').detail).to.equal('Lambda@Edge create started');
3125
+ });
3126
+
3127
+ it('publishes the version once the Lambda is Active, then proceeds to associate + verify', async () => {
3128
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:1';
3129
+ wire(
3130
+ {
3131
+ GetDistributionConfig: () => ({
3132
+ DistributionConfig: {
3133
+ Origins: {
3134
+ Items: [{
3135
+ Id: 'EdgeOptimize_Origin',
3136
+ DomainName: 'dev.edgeoptimize.net',
3137
+ CustomHeaders: {
3138
+ Items: [
3139
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
3140
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
3141
+ ],
3142
+ },
3143
+ }],
3144
+ },
3145
+ // already associated → associate gate skips; the focus is the lambda publish path.
3146
+ DefaultCacheBehavior: {
3147
+ CachePolicyId: 'cp-1',
3148
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
3149
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:1' }] },
3150
+ },
3151
+ },
3152
+ ETag: 'etag',
3153
+ }),
3154
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3155
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3156
+ GetCachePolicyConfig: {
3157
+ CachePolicyConfig: {
3158
+ Name: 'p',
3159
+ MinTTL: 0,
3160
+ ParametersInCacheKeyAndForwardedToOrigin: {
3161
+ HeadersConfig: {
3162
+ HeaderBehavior: 'whitelist',
3163
+ Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
3164
+ },
3165
+ },
3166
+ },
3167
+ ETag: 'cp-etag',
3168
+ },
3169
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'Deployed' } },
3170
+ },
3171
+ {
3172
+ // Active + idle, NO published version yet → createLambdaAtEdge must publish one.
3173
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
3174
+ ListVersionsByFunction: { Versions: [] },
3175
+ PublishVersion: { Version: '1', FunctionArn: lambdaVersionArn },
3176
+ },
3177
+ okRoleIam(),
3178
+ );
3179
+ fetchStub = sinon.stub(global, 'fetch');
3180
+ fetchStub.onFirstCall().resolves(makeFetchResponse(200, { 'x-edgeoptimize-request-id': 'req-1' }));
3181
+ fetchStub.onSecondCall().resolves(makeFetchResponse(200, {}));
3182
+
3183
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
3184
+
3185
+ // the fix: Active-without-version gets published → lambda flips to done (not stuck).
3186
+ expect(statusOf(out.steps, 'lambda')).to.equal('done');
3187
+ expect(lambdaSendStub.getCalls().filter((c) => c.args[0].commandName === 'PublishVersion')).to.have.length(1);
3188
+ expect(statusOf(out.steps, 'associate')).to.equal('done');
3189
+ expect(statusOf(out.steps, 'verify')).to.equal('done');
3190
+ expect(out.routingDeployed).to.equal(true);
3191
+ expect(out.verified).to.equal(true);
3192
+ });
3193
+
3194
+ // Role-heal gate: the lambda step is "done" only when the function is ready AND the role is
3195
+ // present + correctly configured. The next three cover each role state on a ready function.
3196
+ it('lambda ready + role MISSING → recreates the role, then completes the lambda step', async () => {
3197
+ wire(readyDeployCf(), readyLambda(), {
3198
+ GetRole: throwNamed('NoSuchEntityException', 'no role'),
3199
+ CreateRole: { Role: { Arn: 'arn:role' } },
3200
+ PutRolePolicy: {},
3201
+ });
3202
+
3203
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
3204
+
3205
+ expect(statusOf(out.steps, 'lambda')).to.equal('done');
3206
+ expect(iamCalls('CreateRole')).to.have.length(1); // role recreated despite a ready function
3207
+ expect(iamCalls('PutRolePolicy')).to.have.length(1); // logs policy re-attached
3208
+ expect(out.routingDeployed).to.equal(true);
3209
+ expect(statusOf(out.steps, 'propagation')).to.equal('in_progress');
3210
+ });
3211
+
3212
+ it('lambda ready + role MIS-CONFIGURED → heals trust + logs, then completes', async () => {
3213
+ const badTrust = encodeURIComponent(JSON.stringify({
3214
+ Version: '2012-10-17',
3215
+ // missing edgelambda.amazonaws.com → roleOk is false
3216
+ Statement: [{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' }],
3217
+ }));
3218
+ wire(readyDeployCf(), readyLambda(), {
3219
+ GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: badTrust } },
3220
+ GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' },
3221
+ UpdateAssumeRolePolicy: {},
3222
+ PutRolePolicy: {},
3223
+ });
3224
+
3225
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
3226
+
3227
+ expect(statusOf(out.steps, 'lambda')).to.equal('done');
3228
+ expect(iamCalls('UpdateAssumeRolePolicy')).to.have.length(1); // trust corrected
3229
+ expect(iamCalls('PutRolePolicy')).to.have.length(1); // logs policy re-attached
3230
+ expect(iamCalls('CreateRole')).to.have.length(0); // role exists → not recreated
3231
+ expect(out.routingDeployed).to.equal(true);
3232
+ });
3233
+
3234
+ it('lambda ready + role OK → completes WITHOUT touching the role (no churn)', async () => {
3235
+ wire(readyDeployCf(), readyLambda(), okRoleIam());
3236
+
3237
+ const out = await edgeOptimize.runDeployStep({}, deployParams);
3238
+
3239
+ expect(statusOf(out.steps, 'lambda')).to.equal('done');
3240
+ expect(iamCalls('CreateRole')).to.have.length(0);
3241
+ expect(iamCalls('UpdateAssumeRolePolicy')).to.have.length(0);
3242
+ expect(iamCalls('PutRolePolicy')).to.have.length(0); // gate passed → createLambda skipped
3243
+ expect(out.routingDeployed).to.equal(true);
3244
+ });
3245
+
3246
+ it('targets a NAMED behavior already associated (associate gate looks it up by name)', async () => {
3247
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
3248
+ const namedBehavior = {
3249
+ PathPattern: '/api/*',
3250
+ CachePolicyId: 'cp-1',
3251
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:fn/edgeoptimize-routing' }] },
3252
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:edgeoptimize-origin:3' }] },
3253
+ };
3254
+ wire(
3255
+ {
3256
+ GetDistributionConfig: () => ({
3257
+ DistributionConfig: {
3258
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
3259
+ CacheBehaviors: { Items: [namedBehavior] },
3260
+ },
3261
+ ETag: 'etag',
3262
+ }),
3263
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3264
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3265
+ GetCachePolicyConfig: {
3266
+ CachePolicyConfig: {
3267
+ Name: 'p',
3268
+ MinTTL: 0,
3269
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } } },
3270
+ },
3271
+ ETag: 'cp-etag',
3272
+ },
3273
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'Deployed' } },
3274
+ },
3275
+ {
3276
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
3277
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
3278
+ },
3279
+ okRoleIam(),
3280
+ );
3281
+
3282
+ const namedParams = { ...deployParams, behavior: '/api/*', originHeaders: undefined };
3283
+ const out = await edgeOptimize.runDeployStep({}, namedParams);
3284
+
3285
+ // associate gate's isBehaviorAlreadyAssociated resolves the named behavior → already wired.
3286
+ expect(statusOf(out.steps, 'associate')).to.equal('done');
3287
+ expect(cfCalls('UpdateDistribution')).to.have.length(0); // already associated → no write
3288
+ });
3289
+
3290
+ it('writes the association for a NAMED behavior the gate cannot find as associated', async () => {
3291
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
3292
+ // Named behavior exists (so cache + applyAssociations succeed) but has no EO associations,
3293
+ // so isBehaviorAlreadyAssociated returns false via the named-lookup path.
3294
+ const namedBehavior = { PathPattern: '/api/*', CachePolicyId: 'cp-1' };
3295
+ wire(
3296
+ {
3297
+ GetDistributionConfig: () => ({
3298
+ DistributionConfig: {
3299
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
3300
+ CacheBehaviors: { Items: [{ ...namedBehavior }] },
3301
+ },
3302
+ ETag: 'etag',
3303
+ }),
3304
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3305
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3306
+ GetCachePolicyConfig: {
3307
+ CachePolicyConfig: {
3308
+ Name: 'p',
3309
+ MinTTL: 0,
3310
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } } },
3311
+ },
3312
+ ETag: 'cp-etag',
3313
+ },
3314
+ UpdateDistribution: {},
3315
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'InProgress' } },
3316
+ },
3317
+ {
3318
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
3319
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
3320
+ },
3321
+ okRoleIam(),
3322
+ );
3323
+
3324
+ const namedParams = { ...deployParams, behavior: '/api/*', originHeaders: undefined };
3325
+ const out = await edgeOptimize.runDeployStep({}, namedParams);
3326
+
3327
+ // gate (named lookup) returns false → associate writes the association.
3328
+ expect(statusOf(out.steps, 'associate')).to.equal('done');
3329
+ expect(cfCalls('UpdateDistribution')).to.have.length(1);
3330
+ });
3331
+
3332
+ it('associate gate returns false when EO slots are held by non-EO ARNs', async () => {
3333
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
3334
+ // The gate's .some() predicates evaluate the ARN regex (right side of &&) on matching event
3335
+ // types whose ARNs are NOT edgeoptimize → returns false → associate runs (and then refuses).
3336
+ wire(
3337
+ {
3338
+ GetDistributionConfig: () => ({
3339
+ DistributionConfig: {
3340
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
3341
+ DefaultCacheBehavior: {
3342
+ CachePolicyId: 'cp-1',
3343
+ // EventType matches the EO slot but the ARN fields are absent → the gate evaluates
3344
+ // the `a.FunctionARN || ''` / `a.LambdaFunctionARN || ''` fallbacks → regex false.
3345
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request' }, { EventType: 'viewer-request', FunctionARN: 'arn:other-fn' }] },
3346
+ LambdaFunctionAssociations: { Items: [{ EventType: 'origin-request' }] },
3347
+ },
3348
+ },
3349
+ ETag: 'etag',
3350
+ }),
3351
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3352
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3353
+ GetCachePolicyConfig: {
3354
+ CachePolicyConfig: {
3355
+ Name: 'p',
3356
+ MinTTL: 0,
3357
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } } },
3358
+ },
3359
+ ETag: 'cp-etag',
3360
+ },
3361
+ },
3362
+ {
3363
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
3364
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
3365
+ },
3366
+ okRoleIam(),
3367
+ );
3368
+
3369
+ const namedParams = { ...deployParams, originHeaders: undefined };
3370
+ const out = await edgeOptimize.runDeployStep({}, namedParams);
3371
+
3372
+ // gate → false (ARNs not EO); applyAssociations then refuses the non-EO slot.
3373
+ expect(statusOf(out.steps, 'associate')).to.equal('error');
3374
+ expect(out.steps.find((s) => s.key === 'associate').detail).to.include('different viewer-request function');
3375
+ });
3376
+
3377
+ it('returns associate gate false when the named behavior is absent from the gate config', async () => {
3378
+ const lambdaVersionArn = 'arn:aws:lambda:us-east-1:120569600543:function:edgeoptimize-origin:3';
3379
+ let getCfgCount = 0;
3380
+ wire(
3381
+ {
3382
+ GetDistributionConfig: () => {
3383
+ getCfgCount += 1;
3384
+ // 1st: origin. 2nd: cache. 3rd: associate-gate (named behavior ABSENT → !behavior).
3385
+ // 4th: applyAssociations read (named behavior present again).
3386
+ const hasNamed = getCfgCount !== 3;
3387
+ return {
3388
+ DistributionConfig: {
3389
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'dev.edgeoptimize.net' }] },
3390
+ CacheBehaviors: { Items: hasNamed ? [{ PathPattern: '/api/*', CachePolicyId: 'cp-1' }] : [] },
3391
+ },
3392
+ ETag: 'etag',
3393
+ };
3394
+ },
3395
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3396
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3397
+ GetCachePolicyConfig: {
3398
+ CachePolicyConfig: {
3399
+ Name: 'p',
3400
+ MinTTL: 0,
3401
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Quantity: 2, Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } } },
3402
+ },
3403
+ ETag: 'cp-etag',
3404
+ },
3405
+ UpdateDistribution: {},
3406
+ GetDistribution: { Distribution: { DomainName: 'd123.cloudfront.net', Status: 'InProgress' } },
3407
+ },
3408
+ {
3409
+ GetFunctionConfiguration: { State: 'Active', LastUpdateStatus: 'Successful', FunctionArn: 'arn:lambda' },
3410
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: lambdaVersionArn, CodeSha256: 'sha' }] },
3411
+ },
3412
+ okRoleIam(),
3413
+ );
3414
+
3415
+ const namedParams = { ...deployParams, behavior: '/api/*', originHeaders: undefined };
3416
+ const out = await edgeOptimize.runDeployStep({}, namedParams);
3417
+
3418
+ // gate config lacks the named behavior (!behavior → false) → associate writes it.
3419
+ expect(statusOf(out.steps, 'associate')).to.equal('done');
3420
+ expect(cfCalls('UpdateDistribution')).to.have.length(1);
3421
+ });
3422
+ });
3423
+
3424
+ describe('planDeploy', () => {
3425
+ const planParams = {
3426
+ distributionId: 'E2EXAMPLE123',
3427
+ originId: 'origin-aem',
3428
+ behavior: 'default',
3429
+ originDomain: 'live.edgeoptimize.net',
3430
+ originHeaders: { apiKey: 'eo-key', forwardedHost: 'www.example.com' },
3431
+ accountId: '120569600543',
3432
+ };
3433
+
3434
+ // Dispatch each client's send() by command name; per-test overrides via the maps.
3435
+ const wire = (cf = {}, lambda = {}, iam = {}) => {
3436
+ cfSendStub.callsFake((cmd) => {
3437
+ const fn = cf[cmd.commandName];
3438
+ if (fn === undefined) {
3439
+ throw new Error(`unexpected cf command: ${cmd.commandName}`);
3440
+ }
3441
+ return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn);
3442
+ });
3443
+ lambdaSendStub.callsFake((cmd) => {
3444
+ const fn = lambda[cmd.commandName];
3445
+ if (fn === undefined) {
3446
+ throw new Error(`unexpected lambda command: ${cmd.commandName}`);
3447
+ }
3448
+ return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn);
3449
+ });
3450
+ iamSendStub.callsFake((cmd) => {
3451
+ const fn = iam[cmd.commandName];
3452
+ if (fn === undefined) {
3453
+ throw new Error(`unexpected iam command: ${cmd.commandName}`);
3454
+ }
3455
+ return Promise.resolve(typeof fn === 'function' ? fn(cmd) : fn);
3456
+ });
3457
+ };
3458
+
3459
+ const throwNamed = (name, message) => () => {
3460
+ const e = new Error(message);
3461
+ e.name = name;
3462
+ throw e;
3463
+ };
3464
+
3465
+ const stepOf = (steps, key) => steps.find((s) => s.key === key);
3466
+
3467
+ it('plans an all-create deploy (nothing exists yet, legacy cache)', async () => {
3468
+ wire(
3469
+ {
3470
+ GetDistributionConfig: {
3471
+ DistributionConfig: {
3472
+ Origins: { Items: [] },
3473
+ DefaultCacheBehavior: {
3474
+ ForwardedValues: { Headers: { Quantity: 0, Items: [] } },
3475
+ MinTTL: 60,
3476
+ },
3477
+ },
3478
+ },
3479
+ // function gate: not published to LIVE.
3480
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3481
+ },
3482
+ {
3483
+ // lambda: does not exist.
3484
+ GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope'),
3485
+ },
3486
+ {
3487
+ GetRole: throwNamed('NoSuchEntityException', 'no role'),
3488
+ },
3489
+ );
3490
+
3491
+ const result = await edgeOptimize.planDeploy({}, planParams);
3492
+
3493
+ expect(result.canProceed).to.equal(true);
3494
+ expect(result.blocker).to.equal(null);
3495
+ expect(result.steps.map((s) => s.key)).to.deep.equal(['origin', 'function', 'cache', 'lambda', 'associate']);
3496
+ expect(stepOf(result.steps, 'origin').action).to.equal('create');
3497
+ expect(stepOf(result.steps, 'function').action).to.equal('create');
3498
+ expect(stepOf(result.steps, 'cache').action).to.equal('update');
3499
+ expect(stepOf(result.steps, 'cache').detail).to.include('Add the Edge Optimize headers');
3500
+ expect(stepOf(result.steps, 'lambda').action).to.equal('create');
3501
+ expect(stepOf(result.steps, 'associate').action).to.equal('create');
3502
+ // role-will-be-created note surfaced on the lambda row
3503
+ expect(stepOf(result.steps, 'lambda').detail).to.include('will be created');
3504
+ // no `verify` row in the plan
3505
+ expect(result.steps.some((s) => s.key === 'verify')).to.equal(false);
3506
+ });
3507
+
3508
+ it('surfaces a config read failure on the origin + cache rows without blocking', async () => {
3509
+ wire(
3510
+ {
3511
+ GetDistributionConfig: () => { throw new Error('config read denied'); },
3512
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3513
+ },
3514
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3515
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3516
+ );
3517
+
3518
+ const result = await edgeOptimize.planDeploy({}, planParams);
3519
+
3520
+ expect(stepOf(result.steps, 'origin').detail).to.include('could not read distribution config');
3521
+ expect(stepOf(result.steps, 'cache').detail).to.include('could not determine cache scenario');
3522
+ expect(result.canProceed).to.equal(true);
3523
+ });
3524
+
3525
+ it('plans an origin-headers patch when the EO origin exists header-less', async () => {
3526
+ wire(
3527
+ {
3528
+ GetDistributionConfig: {
3529
+ DistributionConfig: {
3530
+ Origins: { Items: [{ Id: 'EdgeOptimize_Origin', DomainName: 'live.edgeoptimize.net' }] },
3531
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } }, MinTTL: 0 },
3532
+ },
3533
+ },
3534
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3535
+ },
3536
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3537
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3538
+ );
3539
+
3540
+ const result = await edgeOptimize.planDeploy({}, planParams);
3541
+
3542
+ expect(stepOf(result.steps, 'origin').action).to.equal('create');
3543
+ expect(stepOf(result.steps, 'origin').detail).to.include('patch Edge Optimize origin headers');
3544
+ });
3545
+
3546
+ it('handles a config that has no Origins collection at all', async () => {
3547
+ wire(
3548
+ {
3549
+ GetDistributionConfig: {
3550
+ // config present but NO Origins → `config.Origins?.Items || []` fallback.
3551
+ DistributionConfig: {
3552
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } } },
3553
+ },
3554
+ },
3555
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3556
+ },
3557
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3558
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3559
+ );
3560
+
3561
+ const result = await edgeOptimize.planDeploy({}, planParams);
3562
+
3563
+ expect(stepOf(result.steps, 'origin').action).to.equal('create');
3564
+ expect(stepOf(result.steps, 'origin').detail).to.include('add Edge Optimize origin');
3565
+ });
3566
+
3567
+ it('blocks when a non-Edge Optimize origin uses the EO domain', async () => {
3568
+ wire(
3569
+ {
3570
+ GetDistributionConfig: {
3571
+ DistributionConfig: {
3572
+ // origin id is NOT EdgeOptimize_Origin, but the DomainName matches originDomain.
3573
+ Origins: { Items: [{ Id: 'some-other-id', DomainName: 'live.edgeoptimize.net' }] },
3574
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } }, MinTTL: 0 },
3575
+ },
3576
+ },
3577
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3578
+ },
3579
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3580
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3581
+ );
3582
+
3583
+ const result = await edgeOptimize.planDeploy(
3584
+ {},
3585
+ { ...planParams, originHeaders: undefined },
3586
+ );
3587
+
3588
+ expect(result.canProceed).to.equal(false);
3589
+ expect(result.blocker).to.include('refusing to reuse a non-Edge Optimize origin');
3590
+ expect(stepOf(result.steps, 'origin').action).to.equal('blocked');
3591
+ expect(stepOf(result.steps, 'origin').detail).to.include('some-other-id');
3592
+ });
3593
+
3594
+ it('marks the legacy cache step "exists" when the headers are already forwarded', async () => {
3595
+ wire(
3596
+ {
3597
+ GetDistributionConfig: {
3598
+ DistributionConfig: {
3599
+ Origins: { Items: [] },
3600
+ DefaultCacheBehavior: {
3601
+ ForwardedValues: { Headers: { Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
3602
+ MinTTL: 0,
3603
+ },
3604
+ },
3605
+ },
3606
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3607
+ },
3608
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3609
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3610
+ );
3611
+
3612
+ const result = await edgeOptimize.planDeploy({}, planParams);
3613
+
3614
+ expect(stepOf(result.steps, 'cache').action).to.equal('exists');
3615
+ expect(stepOf(result.steps, 'cache').detail).to.include('already forwards');
3616
+ });
3617
+
3618
+ it('blocks when the behavior is already associated (canProceed:false + exact blocker)', async () => {
3619
+ const associatedBehavior = {
3620
+ ForwardedValues: { Headers: { Items: [] } },
3621
+ FunctionAssociations: {
3622
+ Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:aws:cloudfront::1:function/edgeoptimize-routing-adobe-E2EXAMPLE123' }],
3623
+ },
3624
+ LambdaFunctionAssociations: {
3625
+ Items: [{ EventType: 'origin-request', LambdaFunctionARN: 'arn:aws:lambda:us-east-1:1:function:edgeoptimize-origin-adobe-E2EXAMPLE123:1' }],
3626
+ },
3627
+ };
3628
+ wire(
3629
+ {
3630
+ GetDistributionConfig: {
3631
+ DistributionConfig: {
3632
+ Origins: { Items: [] },
3633
+ DefaultCacheBehavior: associatedBehavior,
3634
+ },
3635
+ },
3636
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3637
+ },
3638
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3639
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3640
+ );
3641
+
3642
+ const result = await edgeOptimize.planDeploy({}, planParams);
3643
+
3644
+ expect(result.canProceed).to.equal(false);
3645
+ expect(result.blocker).to.equal(
3646
+ "This behaviour is already associated with routes, please recheck — can't proceed with this automation.",
3647
+ );
3648
+ expect(stepOf(result.steps, 'associate').action).to.equal('blocked');
3649
+ });
3650
+
3651
+ it('blocks when the customer owns a conflicting slot (assocConflict)', async () => {
3652
+ wire(
3653
+ {
3654
+ GetDistributionConfig: {
3655
+ DistributionConfig: {
3656
+ Origins: { Items: [] },
3657
+ DefaultCacheBehavior: {
3658
+ ForwardedValues: { Headers: { Items: [] } },
3659
+ FunctionAssociations: { Items: [{ EventType: 'viewer-request', FunctionARN: 'arn:other-fn' }] },
3660
+ },
3661
+ },
3662
+ },
3663
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3664
+ },
3665
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3666
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3667
+ );
3668
+
3669
+ const result = await edgeOptimize.planDeploy({}, planParams);
3670
+
3671
+ expect(result.canProceed).to.equal(false);
3672
+ expect(result.blocker).to.include('already has a different viewer-request function');
3673
+ expect(stepOf(result.steps, 'associate').action).to.equal('blocked');
3674
+ });
3675
+
3676
+ it('surfaces a function-status read failure on the function row', async () => {
3677
+ wire(
3678
+ {
3679
+ GetDistributionConfig: {
3680
+ DistributionConfig: {
3681
+ Origins: { Items: [] },
3682
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } } },
3683
+ },
3684
+ },
3685
+ DescribeFunction: () => { throw new Error('describe denied'); },
3686
+ },
3687
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3688
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3689
+ );
3690
+
3691
+ const result = await edgeOptimize.planDeploy({}, planParams);
3692
+
3693
+ expect(stepOf(result.steps, 'function').detail).to.include('could not read routing function status');
3694
+ });
3695
+
3696
+ it('describes a managed-policy clone in the cache step', async () => {
3697
+ wire(
3698
+ {
3699
+ GetDistributionConfig: {
3700
+ DistributionConfig: {
3701
+ Origins: { Items: [] },
3702
+ DefaultCacheBehavior: { CachePolicyId: 'managed-1' },
3703
+ },
3704
+ },
3705
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3706
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
3707
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
3708
+ : { CachePolicyList: { Items: [] } }), // no existing clone
3709
+ GetCachePolicy: {
3710
+ CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized' } },
3711
+ },
3712
+ },
3713
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3714
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3715
+ );
3716
+
3717
+ const result = await edgeOptimize.planDeploy({}, planParams);
3718
+
3719
+ expect(stepOf(result.steps, 'cache').action).to.equal('create');
3720
+ expect(stepOf(result.steps, 'cache').detail).to.include('CachingOptimized-adobe-E2EXAMPLE123');
3721
+ expect(result.canProceed).to.equal(true);
3722
+ });
3723
+
3724
+ it('mentions the MinTTL change when cloning a managed policy with a long MinTTL', async () => {
3725
+ wire(
3726
+ {
3727
+ GetDistributionConfig: {
3728
+ DistributionConfig: {
3729
+ Origins: { Items: [] },
3730
+ DefaultCacheBehavior: { CachePolicyId: 'managed-1' },
3731
+ },
3732
+ },
3733
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3734
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
3735
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
3736
+ : { CachePolicyList: { Items: [] } }),
3737
+ GetCachePolicy: {
3738
+ CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized', MinTTL: 9999 } },
3739
+ },
3740
+ },
3741
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3742
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3743
+ );
3744
+
3745
+ const result = await edgeOptimize.planDeploy({}, planParams);
3746
+
3747
+ expect(stepOf(result.steps, 'cache').detail).to.include('Minimum TTL will be set to 0');
3748
+ });
3749
+
3750
+ it('marks the managed cache step "update" when the clone exists but the behavior is not associated with it', async () => {
3751
+ wire(
3752
+ {
3753
+ GetDistributionConfig: {
3754
+ DistributionConfig: {
3755
+ Origins: { Items: [] },
3756
+ DefaultCacheBehavior: { CachePolicyId: 'managed-1' },
3757
+ },
3758
+ },
3759
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3760
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
3761
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
3762
+ : { CachePolicyList: { Items: [{ CachePolicy: { Id: 'eo-clone', CachePolicyConfig: { Name: 'CachingOptimized-adobe-E2EXAMPLE123' } } }] } }),
3763
+ GetCachePolicy: {
3764
+ CachePolicy: { CachePolicyConfig: { Name: 'Managed-CachingOptimized' } },
3765
+ },
3766
+ },
3767
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3768
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3769
+ );
3770
+
3771
+ const result = await edgeOptimize.planDeploy({}, planParams);
3772
+ // The clone exists but the behavior is still on the managed policy → the deploy will switch
3773
+ // the behavior to the existing copy, so this is an 'update' with a clear created-but-not-
3774
+ // associated message that names both the current policy and the copy.
3775
+ expect(stepOf(result.steps, 'cache').action).to.equal('update');
3776
+ expect(stepOf(result.steps, 'cache').detail).to.include('not associated');
3777
+ expect(stepOf(result.steps, 'cache').detail).to.include('CachingOptimized-adobe-E2EXAMPLE123');
3778
+ });
3779
+
3780
+ it('marks function + lambda + origin "exists" when already present', async () => {
3781
+ wire(
3782
+ {
3783
+ GetDistributionConfig: {
3784
+ DistributionConfig: {
3785
+ Origins: {
3786
+ Items: [{
3787
+ Id: 'EdgeOptimize_Origin',
3788
+ DomainName: 'live.edgeoptimize.net',
3789
+ CustomHeaders: {
3790
+ Items: [
3791
+ { HeaderName: 'x-edgeoptimize-api-key', HeaderValue: 'eo-key' },
3792
+ { HeaderName: 'x-forwarded-host', HeaderValue: 'www.example.com' },
3793
+ ],
3794
+ },
3795
+ }],
3796
+ },
3797
+ DefaultCacheBehavior: {
3798
+ CachePolicyId: 'cp-custom',
3799
+ },
3800
+ },
3801
+ },
3802
+ // function gate: already published to LIVE.
3803
+ DescribeFunction: { FunctionSummary: { FunctionMetadata: { FunctionARN: 'arn:cf-fn' } } },
3804
+ // cache: custom (not managed), without our headers → update in place.
3805
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3806
+ GetCachePolicyConfig: {
3807
+ CachePolicyConfig: {
3808
+ Name: 'my-custom-policy',
3809
+ MinTTL: 0,
3810
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'none' } },
3811
+ },
3812
+ },
3813
+ },
3814
+ {
3815
+ // lambda: exists + has a published version → ready.
3816
+ GetFunctionConfiguration: { FunctionArn: 'arn:lambda', State: 'Active', LastUpdateStatus: 'Successful' },
3817
+ ListVersionsByFunction: { Versions: [{ Version: '3', FunctionArn: 'arn:lambda:3', CodeSha256: 'sha' }] },
3818
+ },
3819
+ {
3820
+ GetRole: {
3821
+ Role: {
3822
+ Arn: 'arn:role',
3823
+ AssumeRolePolicyDocument: encodeURIComponent(JSON.stringify({
3824
+ Version: '2012-10-17',
3825
+ Statement: [{
3826
+ Effect: 'Allow',
3827
+ Principal: { Service: ['lambda.amazonaws.com', 'edgelambda.amazonaws.com'] },
3828
+ Action: 'sts:AssumeRole',
3829
+ }],
3830
+ })),
3831
+ },
3832
+ },
3833
+ GetRolePolicy: { PolicyName: 'EdgeOptimizeLambdaLogging', PolicyDocument: '{}' },
3834
+ },
3835
+ );
3836
+
3837
+ const result = await edgeOptimize.planDeploy({}, planParams);
3838
+
3839
+ expect(stepOf(result.steps, 'origin').action).to.equal('exists');
3840
+ expect(stepOf(result.steps, 'function').action).to.equal('exists');
3841
+ expect(stepOf(result.steps, 'cache').action).to.equal('update');
3842
+ expect(stepOf(result.steps, 'cache').detail).to.include('my-custom-policy');
3843
+ expect(stepOf(result.steps, 'lambda').action).to.equal('exists');
3844
+ // Role visibility: an existing, correctly-configured execution role is surfaced + reused.
3845
+ expect(stepOf(result.steps, 'lambda').detail).to.include('Execution role');
3846
+ expect(stepOf(result.steps, 'lambda').detail).to.include('correctly configured');
3847
+ expect(stepOf(result.steps, 'associate').action).to.equal('create');
3848
+ expect(result.canProceed).to.equal(true);
3849
+ });
3850
+
3851
+ it('notes a provisioning lambda + a mis-configured existing role', async () => {
3852
+ wire(
3853
+ {
3854
+ GetDistributionConfig: {
3855
+ DistributionConfig: {
3856
+ Origins: { Items: [] },
3857
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } } },
3858
+ },
3859
+ },
3860
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3861
+ },
3862
+ {
3863
+ // function exists but not yet published → provisioning.
3864
+ GetFunctionConfiguration: { FunctionArn: 'arn:lambda', State: 'Pending', LastUpdateStatus: 'InProgress' },
3865
+ ListVersionsByFunction: { Versions: [] },
3866
+ },
3867
+ {
3868
+ GetRole: { Role: { Arn: 'arn:role', AssumeRolePolicyDocument: encodeURIComponent('{"Statement":[]}') } },
3869
+ GetRolePolicy: throwNamed('NoSuchEntityException', 'no policy'),
3870
+ },
3871
+ );
3872
+
3873
+ const result = await edgeOptimize.planDeploy({}, planParams);
3874
+
3875
+ expect(stepOf(result.steps, 'lambda').action).to.equal('exists');
3876
+ expect(stepOf(result.steps, 'lambda').detail).to.include('still provisioning');
3877
+ expect(stepOf(result.steps, 'lambda').detail).to.include('not correctly configured');
3878
+ });
3879
+
3880
+ it('surfaces a Lambda@Edge status read failure on the lambda row', async () => {
3881
+ wire(
3882
+ {
3883
+ GetDistributionConfig: {
3884
+ DistributionConfig: {
3885
+ Origins: { Items: [] },
3886
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } } },
3887
+ },
3888
+ },
3889
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3890
+ },
3891
+ { GetFunctionConfiguration: () => { throw Object.assign(new Error('lambda denied'), { name: 'AccessDenied' }); } },
3892
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3893
+ );
3894
+
3895
+ const result = await edgeOptimize.planDeploy({}, planParams);
3896
+
3897
+ expect(stepOf(result.steps, 'lambda').detail).to.include('could not read Lambda@Edge status');
3898
+ });
3899
+
3900
+ it('marks the custom cache step "exists" when our headers are already present (idempotent re-deploy)', async () => {
3901
+ wire(
3902
+ {
3903
+ GetDistributionConfig: {
3904
+ DistributionConfig: {
3905
+ Origins: { Items: [] },
3906
+ DefaultCacheBehavior: { CachePolicyId: 'eo-clone' },
3907
+ },
3908
+ },
3909
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3910
+ ListCachePolicies: { CachePolicyList: { Items: [] } }, // eo-clone not managed → custom
3911
+ GetCachePolicyConfig: {
3912
+ CachePolicyConfig: {
3913
+ Name: 'CachingOptimized-adobe-E2EXAMPLE123',
3914
+ MinTTL: 0,
3915
+ ParametersInCacheKeyAndForwardedToOrigin: {
3916
+ HeadersConfig: {
3917
+ HeaderBehavior: 'whitelist',
3918
+ Headers: { Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] },
3919
+ },
3920
+ },
3921
+ },
3922
+ },
3923
+ },
3924
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3925
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3926
+ );
3927
+
3928
+ const result = await edgeOptimize.planDeploy({}, planParams);
3929
+ expect(stepOf(result.steps, 'cache').action).to.equal('exists');
3930
+ expect(stepOf(result.steps, 'cache').detail).to.include('Already has the Edge Optimize headers');
3931
+ });
3932
+
3933
+ it('marks a custom cache policy with allViewer headers "exists"', async () => {
3934
+ wire(
3935
+ {
3936
+ GetDistributionConfig: {
3937
+ DistributionConfig: {
3938
+ Origins: { Items: [] },
3939
+ DefaultCacheBehavior: { CachePolicyId: 'cp-av' },
3940
+ },
3941
+ },
3942
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3943
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
3944
+ GetCachePolicyConfig: {
3945
+ CachePolicyConfig: {
3946
+ Name: 'all-viewer-policy',
3947
+ MinTTL: 0,
3948
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'allViewer' } },
3949
+ },
3950
+ },
3951
+ },
3952
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3953
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3954
+ );
3955
+
3956
+ const result = await edgeOptimize.planDeploy({}, planParams);
3957
+ expect(stepOf(result.steps, 'cache').action).to.equal('exists');
3958
+ });
3959
+
3960
+ it('surfaces a behavior read failure on the associate row', async () => {
3961
+ // config available for origin/cache, but the named behavior is missing → assoc read fails.
3962
+ wire(
3963
+ {
3964
+ GetDistributionConfig: {
3965
+ DistributionConfig: {
3966
+ Origins: { Items: [] },
3967
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } } },
3968
+ CacheBehaviors: { Items: [] },
3969
+ },
3970
+ },
3971
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3972
+ },
3973
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3974
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3975
+ );
3976
+
3977
+ const result = await edgeOptimize.planDeploy({}, { ...planParams, behavior: '/missing/*' });
3978
+
3979
+ expect(stepOf(result.steps, 'associate').detail).to.include('could not read behavior associations');
3980
+ });
3981
+
3982
+ it('uses the default origin domain when none is supplied', async () => {
3983
+ wire(
3984
+ {
3985
+ GetDistributionConfig: {
3986
+ DistributionConfig: {
3987
+ Origins: { Items: [] },
3988
+ DefaultCacheBehavior: { ForwardedValues: { Headers: { Items: [] } } },
3989
+ },
3990
+ },
3991
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
3992
+ },
3993
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
3994
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
3995
+ );
3996
+
3997
+ const noDomainParams = { ...planParams, originDomain: undefined, originHeaders: undefined };
3998
+ const result = await edgeOptimize.planDeploy({}, noDomainParams);
3999
+
4000
+ expect(stepOf(result.steps, 'origin').detail).to.include('live.edgeoptimize.net');
4001
+ });
4002
+
4003
+ it('handles a legacy behavior with no ForwardedValues / Headers at all', async () => {
4004
+ wire(
4005
+ {
4006
+ GetDistributionConfig: {
4007
+ DistributionConfig: {
4008
+ Origins: { Items: [] },
4009
+ // legacy (no CachePolicyId) and NO ForwardedValues → both `|| {}` fallbacks.
4010
+ DefaultCacheBehavior: {},
4011
+ },
4012
+ },
4013
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
4014
+ },
4015
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
4016
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
4017
+ );
4018
+
4019
+ const result = await edgeOptimize.planDeploy({}, planParams);
4020
+
4021
+ expect(stepOf(result.steps, 'cache').action).to.equal('update');
4022
+ expect(stepOf(result.steps, 'cache').detail).to.include('Add the Edge Optimize headers');
4023
+ });
4024
+
4025
+ it('handles sparse custom-policy reads (no CachePolicyList / CachePolicyConfig)', async () => {
4026
+ wire(
4027
+ {
4028
+ GetDistributionConfig: {
4029
+ DistributionConfig: {
4030
+ Origins: { Items: [] },
4031
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
4032
+ },
4033
+ },
4034
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
4035
+ // no CachePolicyList → managedIds empty (`?.Items || []`) → custom branch.
4036
+ ListCachePolicies: {},
4037
+ // no CachePolicyConfig → `pc = {}`, no headers → update + `pc.Name || 'custom'`.
4038
+ GetCachePolicyConfig: {},
4039
+ },
4040
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
4041
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
4042
+ );
4043
+
4044
+ const result = await edgeOptimize.planDeploy({}, planParams);
4045
+
4046
+ expect(stepOf(result.steps, 'cache').action).to.equal('update');
4047
+ expect(stepOf(result.steps, 'cache').detail).to.include('Current policy: custom');
4048
+ });
4049
+
4050
+ it('marks a sparse custom policy "exists" using the default policy name', async () => {
4051
+ wire(
4052
+ {
4053
+ GetDistributionConfig: {
4054
+ DistributionConfig: {
4055
+ Origins: { Items: [] },
4056
+ DefaultCacheBehavior: { CachePolicyId: 'cp-1' },
4057
+ },
4058
+ },
4059
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
4060
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
4061
+ // headers present (allViewer) + MinTTL 0 + NO Name → exists + `pc.Name || 'custom'`.
4062
+ GetCachePolicyConfig: {
4063
+ CachePolicyConfig: {
4064
+ MinTTL: 0,
4065
+ ParametersInCacheKeyAndForwardedToOrigin: { HeadersConfig: { HeaderBehavior: 'allViewer' } },
4066
+ },
4067
+ },
4068
+ },
4069
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
4070
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
4071
+ );
4072
+
4073
+ const result = await edgeOptimize.planDeploy({}, planParams);
4074
+
4075
+ expect(stepOf(result.steps, 'cache').action).to.equal('exists');
4076
+ expect(stepOf(result.steps, 'cache').detail).to.include('Current policy: custom');
4077
+ });
4078
+
4079
+ it('handles sparse managed-policy reads (no CachePolicy / custom list)', async () => {
4080
+ wire(
4081
+ {
4082
+ GetDistributionConfig: {
4083
+ DistributionConfig: {
4084
+ Origins: { Items: [] },
4085
+ DefaultCacheBehavior: { CachePolicyId: 'managed-1' },
4086
+ },
4087
+ },
4088
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
4089
+ ListCachePolicies: (cmd) => (cmd.input.Type === 'managed'
4090
+ ? { CachePolicyList: { Items: [{ CachePolicy: { Id: 'managed-1' } }] } }
4091
+ : {}), // custom list absent → `?.Items || []`
4092
+ GetCachePolicy: {}, // no CachePolicy → `srcConfig = {}`, `srcConfig.Name || 'cache'`
4093
+ },
4094
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
4095
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
4096
+ );
4097
+
4098
+ const result = await edgeOptimize.planDeploy({}, planParams);
4099
+
4100
+ expect(stepOf(result.steps, 'cache').action).to.equal('create');
4101
+ expect(stepOf(result.steps, 'cache').detail).to.include('cache-adobe-E2EXAMPLE123');
4102
+ });
4103
+
4104
+ it('associate gate (plan) returns false when the named behavior is absent from its own read', async () => {
4105
+ let getCfgCount = 0;
4106
+ wire(
4107
+ {
4108
+ GetDistributionConfig: () => {
4109
+ getCfgCount += 1;
4110
+ // 1st (top read): named behavior present so origin/cache/conflict succeed.
4111
+ // 2nd (isBehaviorAlreadyAssociated): config has NO CacheBehaviors at all → the
4112
+ // `config.CacheBehaviors?.Items || []` fallback fires → !behavior → false.
4113
+ if (getCfgCount === 1) {
4114
+ return {
4115
+ DistributionConfig: {
4116
+ Origins: { Items: [] },
4117
+ CacheBehaviors: { Items: [{ PathPattern: '/api/*', CachePolicyId: 'cp-1' }] },
4118
+ },
4119
+ };
4120
+ }
4121
+ return { DistributionConfig: { Origins: { Items: [] } } };
4122
+ },
4123
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
4124
+ ListCachePolicies: { CachePolicyList: { Items: [] } },
4125
+ GetCachePolicyConfig: {
4126
+ CachePolicyConfig: {
4127
+ Name: 'p',
4128
+ MinTTL: 0,
4129
+ ParametersInCacheKeyAndForwardedToOrigin: {
4130
+ HeadersConfig: { HeaderBehavior: 'whitelist', Headers: { Items: ['x-edgeoptimize-config', 'x-edgeoptimize-url'] } },
4131
+ },
4132
+ },
4133
+ },
4134
+ },
4135
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
4136
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
4137
+ );
4138
+
4139
+ const result = await edgeOptimize.planDeploy({}, { ...planParams, behavior: '/api/*' });
4140
+
4141
+ // gate (plan path) hits the !behavior → false branch → associate is a normal 'create'.
4142
+ expect(stepOf(result.steps, 'associate').action).to.equal('create');
4143
+ expect(result.canProceed).to.equal(true);
4144
+ });
4145
+
4146
+ it('falls back to the add-origin note when the config is empty (no throw, null config)', async () => {
4147
+ // GetDistributionConfig resolves WITHOUT a DistributionConfig → config is null but no error
4148
+ // was caught, so the origin row falls through to the `else if (!detail)` add-origin branch.
4149
+ wire(
4150
+ {
4151
+ GetDistributionConfig: {},
4152
+ DescribeFunction: throwNamed('NoSuchFunctionExists', 'no fn'),
4153
+ },
4154
+ { GetFunctionConfiguration: throwNamed('ResourceNotFoundException', 'nope') },
4155
+ { GetRole: throwNamed('NoSuchEntityException', 'no role') },
4156
+ );
4157
+
4158
+ const result = await edgeOptimize.planDeploy({}, planParams);
4159
+
4160
+ expect(stepOf(result.steps, 'origin').action).to.equal('create');
4161
+ expect(stepOf(result.steps, 'origin').detail).to.equal('add Edge Optimize origin (live.edgeoptimize.net)');
4162
+ expect(stepOf(result.steps, 'cache').detail).to.include('could not determine cache scenario');
4163
+ });
4164
+
4165
+ it('throws when distributionId is missing', async () => {
4166
+ let error;
4167
+ try {
4168
+ await edgeOptimize.planDeploy({}, { ...planParams, distributionId: '' });
4169
+ } catch (e) {
4170
+ error = e;
4171
+ }
4172
+ expect(error.message).to.include('distributionId');
4173
+ });
4174
+
4175
+ it('throws when behavior is missing', async () => {
4176
+ let error;
4177
+ try {
4178
+ await edgeOptimize.planDeploy({}, { ...planParams, behavior: '' });
4179
+ } catch (e) {
4180
+ error = e;
4181
+ }
4182
+ expect(error.message).to.include('behavior');
4183
+ });
4184
+ });
4185
+ });