middleman-s3_sync 4.5.0 → 4.6.1

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.
@@ -5,6 +5,7 @@ require 'middleman/s3_sync/options'
5
5
  require 'middleman/s3_sync/caching_policy'
6
6
  require 'middleman/s3_sync/status'
7
7
  require 'middleman/s3_sync/resource'
8
+ require 'middleman/s3_sync/cloudfront'
8
9
  require 'middleman-s3_sync/extension'
9
10
  require 'middleman/redirect'
10
11
  require 'parallel'
@@ -25,13 +26,22 @@ module Middleman
25
26
  attr_reader :app
26
27
 
27
28
  THREADS_COUNT = 8
29
+
30
+ # Track paths that were changed during sync for CloudFront invalidation
31
+ attr_accessor :invalidation_paths
28
32
 
29
33
  def sync()
30
34
  @app ||= ::Middleman::Application.new
35
+ @invalidation_paths = []
31
36
 
32
37
  say_status "Let's see if there's work to be done..."
33
38
  unless work_to_be_done?
34
39
  say_status "All S3 files are up to date."
40
+
41
+ # Still run CloudFront invalidation if requested for all paths
42
+ if s3_sync_options.cloudfront_invalidate && s3_sync_options.cloudfront_invalidate_all
43
+ CloudFront.invalidate([], s3_sync_options)
44
+ end
35
45
  return
36
46
  end
37
47
 
@@ -45,6 +55,11 @@ module Middleman
45
55
  create_resources
46
56
  update_resources
47
57
  delete_resources
58
+
59
+ # Invalidate CloudFront cache if requested
60
+ if s3_sync_options.cloudfront_invalidate
61
+ CloudFront.invalidate(@invalidation_paths, s3_sync_options)
62
+ end
48
63
  end
49
64
 
50
65
  def bucket
@@ -60,6 +75,13 @@ module Middleman
60
75
  def add_local_resource(mm_resource)
61
76
  s3_sync_resources[mm_resource.destination_path] = S3Sync::Resource.new(mm_resource, remote_resource_for_path(mm_resource.destination_path)).tap(&:status)
62
77
  end
78
+
79
+ def add_invalidation_path(path)
80
+ @invalidation_paths ||= []
81
+ # Normalize path for CloudFront (ensure it starts with /)
82
+ normalized_path = path.start_with?('/') ? path : "/#{path}"
83
+ @invalidation_paths << normalized_path unless @invalidation_paths.include?(normalized_path)
84
+ end
63
85
 
64
86
  def remote_only_paths
65
87
  paths - s3_sync_resources.keys
@@ -168,15 +190,24 @@ module Middleman
168
190
  end
169
191
 
170
192
  def create_resources
171
- Parallel.map(files_to_create, in_threads: THREADS_COUNT, &:create!)
193
+ Parallel.map(files_to_create, in_threads: THREADS_COUNT) do |resource|
194
+ resource.create!
195
+ add_invalidation_path(resource.path)
196
+ end
172
197
  end
173
198
 
174
199
  def update_resources
175
- Parallel.map(files_to_update, in_threads: THREADS_COUNT, &:update!)
200
+ Parallel.map(files_to_update, in_threads: THREADS_COUNT) do |resource|
201
+ resource.update!
202
+ add_invalidation_path(resource.path)
203
+ end
176
204
  end
177
205
 
178
206
  def delete_resources
179
- Parallel.map(files_to_delete, in_threads: THREADS_COUNT, &:destroy!)
207
+ Parallel.map(files_to_delete, in_threads: THREADS_COUNT) do |resource|
208
+ resource.destroy!
209
+ add_invalidation_path(resource.path)
210
+ end
180
211
  end
181
212
 
182
213
  def ignore_resources
@@ -65,6 +65,38 @@ module Middleman
65
65
  type: :string,
66
66
  desc: 'Print instrument messages.'
67
67
 
68
+ class_option :cloudfront_distribution_id,
69
+ aliases: '-d',
70
+ type: :string,
71
+ desc: 'CloudFront distribution ID for invalidation.'
72
+
73
+ class_option :cloudfront_invalidate,
74
+ aliases: '-c',
75
+ type: :boolean,
76
+ desc: 'Invalidate CloudFront cache after sync.'
77
+
78
+ class_option :cloudfront_invalidate_all,
79
+ aliases: '-a',
80
+ type: :boolean,
81
+ desc: 'Invalidate all paths (/*) instead of only changed files.'
82
+
83
+ class_option :cloudfront_invalidation_batch_size,
84
+ type: :numeric,
85
+ desc: 'Maximum number of paths to invalidate in a single request (default: 1000).'
86
+
87
+ class_option :cloudfront_invalidation_max_retries,
88
+ type: :numeric,
89
+ desc: 'Maximum number of retries for rate-limited invalidation requests (default: 5).'
90
+
91
+ class_option :cloudfront_invalidation_batch_delay,
92
+ type: :numeric,
93
+ desc: 'Delay in seconds between invalidation batches (default: 2).'
94
+
95
+ class_option :cloudfront_wait,
96
+ aliases: '-w',
97
+ type: :boolean,
98
+ desc: 'Wait for CloudFront invalidation to complete before exiting.'
99
+
68
100
  def s3_sync
69
101
  env = options[:environment].to_s.to_sym
70
102
  verbose = options[:verbose] ? 0 : 1
@@ -99,6 +131,13 @@ module Middleman
99
131
  s3_sync_options.prefix = s3_sync_options.prefix.end_with?('/') ? s3_sync_options.prefix : s3_sync_options.prefix + '/'
100
132
  end
101
133
  s3_sync_options.dry_run = options[:dry_run] if options[:dry_run]
134
+ s3_sync_options.cloudfront_distribution_id = options[:cloudfront_distribution_id] if options[:cloudfront_distribution_id]
135
+ s3_sync_options.cloudfront_invalidate = options[:cloudfront_invalidate] if options[:cloudfront_invalidate]
136
+ s3_sync_options.cloudfront_invalidate_all = options[:cloudfront_invalidate_all] if options[:cloudfront_invalidate_all]
137
+ s3_sync_options.cloudfront_invalidation_batch_size = options[:cloudfront_invalidation_batch_size] if options[:cloudfront_invalidation_batch_size]
138
+ s3_sync_options.cloudfront_invalidation_max_retries = options[:cloudfront_invalidation_max_retries] if options[:cloudfront_invalidation_max_retries]
139
+ s3_sync_options.cloudfront_invalidation_batch_delay = options[:cloudfront_invalidation_batch_delay] if options[:cloudfront_invalidation_batch_delay]
140
+ s3_sync_options.cloudfront_wait = options[:cloudfront_wait] if options[:cloudfront_wait]
102
141
 
103
142
  ::Middleman::S3Sync.sync()
104
143
  end
@@ -29,6 +29,13 @@ module Middleman
29
29
  option :error_document, nil, 'S3 custom error document path'
30
30
  option :content_types, {}, 'Custom content types'
31
31
  option :ignore_paths, [], 'Paths that should be ignored during sync, strings or regex are allowed'
32
+ option :cloudfront_distribution_id, nil, 'CloudFront distribution ID for invalidation'
33
+ option :cloudfront_invalidate, false, 'Whether to invalidate CloudFront cache after sync'
34
+ option :cloudfront_invalidate_all, false, 'Whether to invalidate all paths (/*) or only changed files'
35
+ option :cloudfront_invalidation_batch_size, 1000, 'Maximum number of paths to invalidate in a single request'
36
+ option :cloudfront_invalidation_max_retries, 5, 'Maximum number of retries for rate-limited invalidation requests'
37
+ option :cloudfront_invalidation_batch_delay, 2, 'Delay in seconds between invalidation batches'
38
+ option :cloudfront_wait, false, 'Whether to wait for CloudFront invalidation to complete'
32
39
 
33
40
  expose_to_config :s3_sync_options, :default_caching_policy, :caching_policy
34
41
 
@@ -22,6 +22,7 @@ Gem::Specification.new do |gem|
22
22
  gem.add_runtime_dependency 'middleman-cli'
23
23
  gem.add_runtime_dependency 'unf'
24
24
  gem.add_runtime_dependency 'aws-sdk-s3'
25
+ gem.add_runtime_dependency 'aws-sdk-cloudfront'
25
26
  gem.add_runtime_dependency 'map'
26
27
  gem.add_runtime_dependency 'parallel'
27
28
  gem.add_runtime_dependency 'ruby-progressbar'
@@ -0,0 +1,511 @@
1
+ require 'spec_helper'
2
+ require 'middleman/s3_sync/cloudfront'
3
+
4
+ describe Middleman::S3Sync::CloudFront do
5
+ let(:options) do
6
+ double(
7
+ cloudfront_invalidate: true,
8
+ cloudfront_distribution_id: 'E1234567890123',
9
+ cloudfront_invalidate_all: false,
10
+ cloudfront_invalidation_batch_size: 1000,
11
+ cloudfront_invalidation_max_retries: 5,
12
+ cloudfront_invalidation_batch_delay: 2,
13
+ cloudfront_wait: false,
14
+ aws_access_key_id: 'test_key',
15
+ aws_secret_access_key: 'test_secret',
16
+ aws_session_token: nil,
17
+ dry_run: false,
18
+ verbose: false
19
+ )
20
+ end
21
+
22
+ let(:invalidation_response) do
23
+ double(
24
+ invalidation: double(id: 'I1234567890123')
25
+ )
26
+ end
27
+
28
+ before do
29
+ allow(described_class).to receive(:say_status)
30
+ end
31
+
32
+ describe '.invalidate' do
33
+ context 'when CloudFront invalidation is disabled' do
34
+ let(:options) do
35
+ double(cloudfront_invalidate: false)
36
+ end
37
+
38
+ it 'returns early without doing anything' do
39
+ expect(Aws::CloudFront::Client).not_to receive(:new)
40
+ result = described_class.invalidate(['/path1', '/path2'], options)
41
+ expect(result).to be_nil
42
+ end
43
+ end
44
+
45
+ context 'when no distribution ID is provided' do
46
+ let(:options) do
47
+ double(
48
+ cloudfront_invalidate: true,
49
+ cloudfront_distribution_id: nil
50
+ )
51
+ end
52
+
53
+ it 'skips invalidation and shows warning' do
54
+ expect(described_class).to receive(:say_status).with(
55
+ match(/CloudFront invalidation skipped.*no distribution ID/)
56
+ )
57
+ expect(Aws::CloudFront::Client).not_to receive(:new)
58
+ result = described_class.invalidate(['/path1', '/path2'], options)
59
+ expect(result).to be_nil
60
+ end
61
+ end
62
+
63
+ context 'when dry run is enabled' do
64
+ let(:options) do
65
+ double(
66
+ cloudfront_invalidate: true,
67
+ cloudfront_distribution_id: 'E1234567890123',
68
+ cloudfront_invalidate_all: false,
69
+ dry_run: true,
70
+ verbose: true
71
+ )
72
+ end
73
+
74
+ it 'shows what would be invalidated without making API calls' do
75
+ expect(described_class).to receive(:say_status).with(
76
+ 'Invalidating CloudFront distribution E1234567890123'
77
+ )
78
+ expect(described_class).to receive(:say_status).with(
79
+ match(/DRY RUN.*Would invalidate 2 paths/)
80
+ )
81
+ expect(described_class).to receive(:say_status).with(' /path1')
82
+ expect(described_class).to receive(:say_status).with(' /path2')
83
+ expect(Aws::CloudFront::Client).not_to receive(:new)
84
+
85
+ result = described_class.invalidate(['/path1', '/path2'], options)
86
+ expect(result).to be_nil
87
+ end
88
+ end
89
+
90
+ context 'when invalidating all paths' do
91
+ let(:options) do
92
+ double(
93
+ cloudfront_invalidate: true,
94
+ cloudfront_distribution_id: 'E1234567890123',
95
+ cloudfront_invalidate_all: true,
96
+ cloudfront_invalidation_batch_size: 1000,
97
+ cloudfront_invalidation_max_retries: 5,
98
+ cloudfront_invalidation_batch_delay: 2,
99
+ cloudfront_wait: false,
100
+ aws_access_key_id: 'test_key',
101
+ aws_secret_access_key: 'test_secret',
102
+ aws_session_token: nil,
103
+ dry_run: false,
104
+ verbose: false
105
+ )
106
+ end
107
+
108
+ it 'invalidates all paths with /*' do
109
+ client = double('cloudfront_client')
110
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
111
+ expect(client).to receive(:create_invalidation).with({
112
+ distribution_id: 'E1234567890123',
113
+ invalidation_batch: {
114
+ paths: {
115
+ quantity: 1,
116
+ items: ['/*']
117
+ },
118
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
119
+ }
120
+ }).and_return(invalidation_response)
121
+
122
+ result = described_class.invalidate(['/path1', '/path2'], options)
123
+ expect(result).to eq(['I1234567890123'])
124
+ end
125
+ end
126
+
127
+ context 'when invalidating specific paths' do
128
+ it 'creates invalidation for the provided paths' do
129
+ client = double('cloudfront_client')
130
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
131
+ expect(client).to receive(:create_invalidation).with({
132
+ distribution_id: 'E1234567890123',
133
+ invalidation_batch: {
134
+ paths: {
135
+ quantity: 2,
136
+ items: ['/path1', '/path2']
137
+ },
138
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
139
+ }
140
+ }).and_return(invalidation_response)
141
+
142
+ result = described_class.invalidate(['/path1', '/path2'], options)
143
+ expect(result).to eq(['I1234567890123'])
144
+ end
145
+
146
+ it 'normalizes paths to start with /' do
147
+ client = double('cloudfront_client')
148
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
149
+ expect(client).to receive(:create_invalidation).with({
150
+ distribution_id: 'E1234567890123',
151
+ invalidation_batch: {
152
+ paths: {
153
+ quantity: 2,
154
+ items: ['/path1', '/path2']
155
+ },
156
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
157
+ }
158
+ }).and_return(invalidation_response)
159
+
160
+ result = described_class.invalidate(['path1', '/path2'], options)
161
+ expect(result).to eq(['I1234567890123'])
162
+ end
163
+
164
+ it 'removes duplicate paths' do
165
+ client = double('cloudfront_client')
166
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
167
+ expect(client).to receive(:create_invalidation).with({
168
+ distribution_id: 'E1234567890123',
169
+ invalidation_batch: {
170
+ paths: {
171
+ quantity: 2,
172
+ items: ['/path1', '/path2']
173
+ },
174
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
175
+ }
176
+ }).and_return(invalidation_response)
177
+
178
+ result = described_class.invalidate(['/path1', 'path1', '/path2'], options)
179
+ expect(result).to eq(['I1234567890123'])
180
+ end
181
+
182
+ it 'removes double slashes from paths' do
183
+ client = double('cloudfront_client')
184
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
185
+ expect(client).to receive(:create_invalidation).with({
186
+ distribution_id: 'E1234567890123',
187
+ invalidation_batch: {
188
+ paths: {
189
+ quantity: 1,
190
+ items: ['/path/to/file.html']
191
+ },
192
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
193
+ }
194
+ }).and_return(invalidation_response)
195
+
196
+ result = described_class.invalidate(['//path//to//file.html'], options)
197
+ expect(result).to eq(['I1234567890123'])
198
+ end
199
+ end
200
+
201
+ context 'with large batch sizes' do
202
+ let(:options) do
203
+ double(
204
+ cloudfront_invalidate: true,
205
+ cloudfront_distribution_id: 'E1234567890123',
206
+ cloudfront_invalidate_all: false,
207
+ cloudfront_invalidation_batch_size: 2,
208
+ cloudfront_invalidation_max_retries: 5,
209
+ cloudfront_invalidation_batch_delay: 1,
210
+ cloudfront_wait: false,
211
+ aws_access_key_id: 'test_key',
212
+ aws_secret_access_key: 'test_secret',
213
+ aws_session_token: nil,
214
+ dry_run: false,
215
+ verbose: false
216
+ )
217
+ end
218
+
219
+ it 'splits paths into multiple batches' do
220
+ client = double('cloudfront_client')
221
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
222
+ paths = ['/path1', '/path2', '/path3', '/path4']
223
+
224
+ expect(client).to receive(:create_invalidation).twice.and_return(invalidation_response)
225
+ expect(described_class).to receive(:sleep).with(1)
226
+
227
+ result = described_class.invalidate(paths, options)
228
+ expect(result).to eq(['I1234567890123', 'I1234567890123'])
229
+ end
230
+ end
231
+
232
+ context 'when CloudFront API returns an error' do
233
+ let(:error) { Aws::CloudFront::Errors::ServiceError.new(nil, 'Distribution not found') }
234
+
235
+ it 'handles API errors gracefully' do
236
+ client = double('cloudfront_client')
237
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
238
+ expect(client).to receive(:create_invalidation).and_raise(error)
239
+ expect(described_class).to receive(:say_status).with(
240
+ match(/Failed to create CloudFront invalidation.*Distribution not found/)
241
+ )
242
+
243
+ expect {
244
+ described_class.invalidate(['/path1'], options)
245
+ }.to raise_error(Aws::CloudFront::Errors::ServiceError)
246
+ end
247
+
248
+ context 'when rate limit is exceeded' do
249
+ let(:rate_error) { Aws::CloudFront::Errors::ServiceError.new(nil, 'Rate exceeded') }
250
+
251
+ it 'retries with exponential backoff' do
252
+ client = double('cloudfront_client')
253
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
254
+
255
+ # Fail twice with rate limit, then succeed
256
+ call_count = 0
257
+ allow(client).to receive(:create_invalidation) do
258
+ call_count += 1
259
+ if call_count <= 2
260
+ raise rate_error
261
+ else
262
+ invalidation_response
263
+ end
264
+ end
265
+
266
+ # Allow normal status messages but expect retry messages
267
+ allow(described_class).to receive(:say_status)
268
+ expect(described_class).to receive(:say_status).with(
269
+ match(/Rate limit hit, retrying in \d+ seconds.*attempt 1\/5/)
270
+ ).ordered
271
+ expect(described_class).to receive(:say_status).with(
272
+ match(/Rate limit hit, retrying in \d+ seconds.*attempt 2\/5/)
273
+ ).ordered
274
+
275
+ # Expect sleep calls for backoff
276
+ expect(described_class).to receive(:sleep).twice
277
+
278
+ result = described_class.invalidate(['/path1'], options)
279
+ expect(result).to eq(['I1234567890123'])
280
+ end
281
+
282
+ it 'gives up after max retries and raises error' do
283
+ rate_limited_options = double(
284
+ cloudfront_invalidate: true,
285
+ cloudfront_distribution_id: 'E1234567890123',
286
+ cloudfront_invalidate_all: false,
287
+ cloudfront_invalidation_batch_size: 1000,
288
+ cloudfront_invalidation_max_retries: 2,
289
+ cloudfront_invalidation_batch_delay: 2,
290
+ cloudfront_wait: false,
291
+ aws_access_key_id: 'test_key',
292
+ aws_secret_access_key: 'test_secret',
293
+ aws_session_token: nil,
294
+ dry_run: false,
295
+ verbose: false
296
+ )
297
+
298
+ client = double('cloudfront_client')
299
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
300
+
301
+ # Fail max_retries + 1 times
302
+ expect(client).to receive(:create_invalidation).exactly(3).times.and_raise(rate_error)
303
+
304
+ # Allow normal status messages
305
+ allow(described_class).to receive(:say_status)
306
+
307
+ # Expect retry status messages
308
+ expect(described_class).to receive(:say_status).with(
309
+ match(/Rate limit hit, retrying in \d+ seconds.*attempt 1\/2/)
310
+ )
311
+ expect(described_class).to receive(:say_status).with(
312
+ match(/Rate limit hit, retrying in \d+ seconds.*attempt 2\/2/)
313
+ )
314
+ expect(described_class).to receive(:say_status).with(
315
+ match(/Failed to create CloudFront invalidation.*Rate exceeded/)
316
+ )
317
+
318
+ # Expect sleep calls for backoff
319
+ expect(described_class).to receive(:sleep).twice
320
+
321
+ expect {
322
+ described_class.invalidate(['/path1'], rate_limited_options)
323
+ }.to raise_error(Aws::CloudFront::Errors::ServiceError)
324
+ end
325
+
326
+ it 'handles throttling errors the same as rate exceeded' do
327
+ throttling_error = Aws::CloudFront::Errors::ServiceError.new(nil, 'Throttling: Request was throttled')
328
+
329
+ client = double('cloudfront_client')
330
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
331
+
332
+ call_count = 0
333
+ allow(client).to receive(:create_invalidation) do
334
+ call_count += 1
335
+ if call_count == 1
336
+ raise throttling_error
337
+ else
338
+ invalidation_response
339
+ end
340
+ end
341
+
342
+ # Allow normal status messages but expect retry message
343
+ allow(described_class).to receive(:say_status)
344
+ expect(described_class).to receive(:say_status).with(
345
+ match(/Rate limit hit, retrying in \d+ seconds.*attempt 1\/5/)
346
+ ).ordered
347
+ expect(described_class).to receive(:sleep).once
348
+
349
+ result = described_class.invalidate(['/path1'], options)
350
+ expect(result).to eq(['I1234567890123'])
351
+ end
352
+ end
353
+
354
+ context 'when verbose mode is enabled' do
355
+ let(:options) do
356
+ double(
357
+ cloudfront_invalidate: true,
358
+ cloudfront_distribution_id: 'E1234567890123',
359
+ cloudfront_invalidate_all: false,
360
+ cloudfront_invalidation_batch_size: 1000,
361
+ cloudfront_invalidation_max_retries: 5,
362
+ cloudfront_invalidation_batch_delay: 2,
363
+ cloudfront_wait: false,
364
+ aws_access_key_id: 'test_key',
365
+ aws_secret_access_key: 'test_secret',
366
+ aws_session_token: nil,
367
+ dry_run: false,
368
+ verbose: true
369
+ )
370
+ end
371
+
372
+ it 'does not re-raise errors in verbose mode' do
373
+ client = double('cloudfront_client')
374
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
375
+ expect(client).to receive(:create_invalidation).and_raise(error)
376
+ expect(described_class).to receive(:say_status).with(
377
+ match(/Failed to create CloudFront invalidation/)
378
+ )
379
+
380
+ expect {
381
+ described_class.invalidate(['/path1'], options)
382
+ }.not_to raise_error
383
+ end
384
+ end
385
+ end
386
+
387
+ context 'with empty paths and invalidate_all false' do
388
+ it 'returns early without making API calls' do
389
+ expect(Aws::CloudFront::Client).not_to receive(:new)
390
+ result = described_class.invalidate([], options)
391
+ expect(result).to be_nil
392
+ end
393
+ end
394
+
395
+ context 'when cloudfront_wait is enabled' do
396
+ let(:options) do
397
+ double(
398
+ cloudfront_invalidate: true,
399
+ cloudfront_distribution_id: 'E1234567890123',
400
+ cloudfront_invalidate_all: false,
401
+ cloudfront_invalidation_batch_size: 1000,
402
+ cloudfront_invalidation_max_retries: 5,
403
+ cloudfront_invalidation_batch_delay: 2,
404
+ cloudfront_wait: true,
405
+ aws_access_key_id: 'test_key',
406
+ aws_secret_access_key: 'test_secret',
407
+ aws_session_token: nil,
408
+ dry_run: false,
409
+ verbose: false
410
+ )
411
+ end
412
+
413
+ it 'waits for invalidation to complete' do
414
+ client = double('cloudfront_client')
415
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
416
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
417
+ expect(client).to receive(:wait_until).with(:invalidation_completed,
418
+ distribution_id: 'E1234567890123',
419
+ id: 'I1234567890123'
420
+ )
421
+ expect(described_class).to receive(:say_status).with(
422
+ 'Waiting for CloudFront invalidation(s) to complete...'
423
+ )
424
+ expect(described_class).to receive(:say_status).with(
425
+ 'CloudFront invalidation(s) completed successfully'
426
+ )
427
+
428
+ result = described_class.invalidate(['/path1'], options)
429
+ expect(result).to eq(['I1234567890123'])
430
+ end
431
+ end
432
+ end
433
+
434
+ describe 'CloudFront client configuration' do
435
+ it 'creates client with correct credentials' do
436
+ client = double('cloudfront_client')
437
+ expect(Aws::CloudFront::Client).to receive(:new).with({
438
+ region: 'us-east-1',
439
+ access_key_id: 'test_key',
440
+ secret_access_key: 'test_secret'
441
+ }).and_return(client)
442
+
443
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
444
+
445
+ described_class.invalidate(['/test'], options)
446
+ end
447
+
448
+ context 'with session token' do
449
+ let(:options) do
450
+ double(
451
+ cloudfront_invalidate: true,
452
+ cloudfront_distribution_id: 'E1234567890123',
453
+ cloudfront_invalidate_all: false,
454
+ cloudfront_invalidation_batch_size: 1000,
455
+ cloudfront_invalidation_max_retries: 5,
456
+ cloudfront_invalidation_batch_delay: 2,
457
+ cloudfront_wait: false,
458
+ aws_access_key_id: 'test_key',
459
+ aws_secret_access_key: 'test_secret',
460
+ aws_session_token: 'test_token',
461
+ dry_run: false,
462
+ verbose: false
463
+ )
464
+ end
465
+
466
+ it 'includes session token in client configuration' do
467
+ client = double('cloudfront_client')
468
+ expect(Aws::CloudFront::Client).to receive(:new).with({
469
+ region: 'us-east-1',
470
+ access_key_id: 'test_key',
471
+ secret_access_key: 'test_secret',
472
+ session_token: 'test_token'
473
+ }).and_return(client)
474
+
475
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
476
+
477
+ described_class.invalidate(['/test'], options)
478
+ end
479
+ end
480
+
481
+ context 'without explicit credentials' do
482
+ let(:options) do
483
+ double(
484
+ cloudfront_invalidate: true,
485
+ cloudfront_distribution_id: 'E1234567890123',
486
+ cloudfront_invalidate_all: false,
487
+ cloudfront_invalidation_batch_size: 1000,
488
+ cloudfront_invalidation_max_retries: 5,
489
+ cloudfront_invalidation_batch_delay: 2,
490
+ cloudfront_wait: false,
491
+ aws_access_key_id: nil,
492
+ aws_secret_access_key: nil,
493
+ aws_session_token: nil,
494
+ dry_run: false,
495
+ verbose: false
496
+ )
497
+ end
498
+
499
+ it 'creates client without explicit credentials (uses default chain)' do
500
+ client = double('cloudfront_client')
501
+ expect(Aws::CloudFront::Client).to receive(:new).with({
502
+ region: 'us-east-1'
503
+ }).and_return(client)
504
+
505
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
506
+
507
+ described_class.invalidate(['/test'], options)
508
+ end
509
+ end
510
+ end
511
+ end