middleman-s3_sync 4.6.5 → 4.8.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.
@@ -13,28 +13,31 @@ Gem::Specification.new do |gem|
13
13
  gem.homepage = "http://github.com/fredjean/middleman-s3_sync"
14
14
  gem.license = 'MIT'
15
15
 
16
+ gem.required_ruby_version = '>= 3.0'
17
+
16
18
  gem.files = `git ls-files`.split($/)
17
19
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
20
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
21
  gem.require_paths = ["lib"]
20
22
 
21
- gem.add_runtime_dependency 'middleman-core'
22
- gem.add_runtime_dependency 'middleman-cli'
23
- gem.add_runtime_dependency 'aws-sdk-s3', '>= 1.187.0'
24
- gem.add_runtime_dependency 'aws-sdk-cloudfront'
25
- gem.add_runtime_dependency 'parallel'
26
- gem.add_runtime_dependency 'ruby-progressbar'
27
- gem.add_runtime_dependency 'ansi', '~> 1.5.0'
28
- gem.add_runtime_dependency 'mime-types', '~> 3.1'
29
- gem.add_runtime_dependency 'nokogiri', '>= 1.18.4'
23
+ # Runtime dependencies
24
+ gem.add_runtime_dependency 'middleman-core', '~> 4.4'
25
+ gem.add_runtime_dependency 'middleman-cli', '~> 4.4'
26
+ gem.add_runtime_dependency 'aws-sdk-s3', '~> 1.187', '>= 1.187.0'
27
+ gem.add_runtime_dependency 'aws-sdk-cloudfront', '~> 1.0'
28
+ gem.add_runtime_dependency 'parallel', '~> 1.20'
29
+ gem.add_runtime_dependency 'ruby-progressbar', '~> 1.11'
30
+ gem.add_runtime_dependency 'ansi', '~> 1.5'
31
+ gem.add_runtime_dependency 'mime-types', '~> 3.4'
32
+ gem.add_runtime_dependency 'nokogiri', '~> 1.18', '>= 1.18.4'
30
33
 
31
- gem.add_development_dependency 'rake'
32
- gem.add_development_dependency 'pry'
33
- gem.add_development_dependency 'pry-byebug'
34
- gem.add_development_dependency 'rspec'
35
- gem.add_development_dependency 'rspec-support'
36
- gem.add_development_dependency 'rspec-its'
37
- gem.add_development_dependency 'rspec-mocks'
38
- gem.add_development_dependency 'timerizer'
39
- gem.add_development_dependency 'webrick'
34
+ # Development dependencies
35
+ gem.add_development_dependency 'rake', '~> 13.0'
36
+ gem.add_development_dependency 'pry', '~> 0.14'
37
+ gem.add_development_dependency 'pry-byebug', '~> 3.10'
38
+ gem.add_development_dependency 'rspec', '~> 3.12'
39
+ gem.add_development_dependency 'rspec-support', '~> 3.12'
40
+ gem.add_development_dependency 'rspec-its', '~> 2.0'
41
+ gem.add_development_dependency 'rspec-mocks', '~> 3.12'
42
+ gem.add_development_dependency 'webrick', '~> 1.8'
40
43
  end
@@ -87,6 +87,60 @@ describe 'AWS SDK Parameter Validation' do
87
87
  }.to raise_error('S3 requires `index_document` if `error_document` is specified')
88
88
  end
89
89
  end
90
+
91
+ context 'when routing_rules are set' do
92
+ before do
93
+ options.routing_rules = [
94
+ {
95
+ condition: { key_prefix_equals: 'docs/' },
96
+ redirect: { replace_key_prefix_with: 'documents/' }
97
+ },
98
+ {
99
+ condition: { http_error_code_returned_equals: '404' },
100
+ redirect: { host_name: 'example.com', replace_key_with: 'error.html' }
101
+ }
102
+ ]
103
+ end
104
+
105
+ it 'includes routing_rules in website configuration' do
106
+ expect(s3_client).to receive(:put_bucket_website) do |params|
107
+ expect(params[:website_configuration]).to have_key(:routing_rules)
108
+ rules = params[:website_configuration][:routing_rules]
109
+ expect(rules).to be_an(Array)
110
+ expect(rules.length).to eq(2)
111
+
112
+ # First rule
113
+ expect(rules[0][:condition][:key_prefix_equals]).to eq('docs/')
114
+ expect(rules[0][:redirect][:replace_key_prefix_with]).to eq('documents/')
115
+
116
+ # Second rule
117
+ expect(rules[1][:condition][:http_error_code_returned_equals]).to eq('404')
118
+ expect(rules[1][:redirect][:host_name]).to eq('example.com')
119
+ expect(rules[1][:redirect][:replace_key_with]).to eq('error.html')
120
+ end
121
+
122
+ Middleman::S3Sync.send(:update_bucket_website)
123
+ end
124
+ end
125
+
126
+ context 'when routing_rules are set without index_document' do
127
+ before do
128
+ options.index_document = nil
129
+ options.error_document = nil
130
+ options.routing_rules = [
131
+ {
132
+ condition: { key_prefix_equals: 'old/' },
133
+ redirect: { replace_key_prefix_with: 'new/' }
134
+ }
135
+ ]
136
+ end
137
+
138
+ it 'raises an error because S3 requires index_document if routing_rules are specified' do
139
+ expect {
140
+ Middleman::S3Sync.send(:update_bucket_website)
141
+ }.to raise_error('S3 requires `index_document` if `routing_rules` are specified')
142
+ end
143
+ end
90
144
  end
91
145
 
92
146
  describe 'put_bucket_versioning parameters' do
@@ -425,6 +479,48 @@ describe 'AWS SDK Parameter Validation' do
425
479
  end
426
480
  end
427
481
 
482
+ context 'when a caching policy with only max_age is in effect' do
483
+ before do
484
+ policy = Middleman::S3Sync::BrowserCachePolicy.new(max_age: 31536000, public: true, immutable: true)
485
+ allow(Middleman::S3Sync).to receive(:caching_policy_for).and_return(policy)
486
+ end
487
+
488
+ it 'sets cache_control but omits expires' do
489
+ attributes = resource.to_h
490
+
491
+ expect(attributes[:cache_control]).to eq('max-age=31536000, public, immutable')
492
+ expect(attributes).not_to have_key(:expires)
493
+ end
494
+ end
495
+
496
+ context 'when a caching policy with only expires is in effect' do
497
+ before do
498
+ policy = Middleman::S3Sync::BrowserCachePolicy.new(expires: Time.utc(2030, 1, 1))
499
+ allow(Middleman::S3Sync).to receive(:caching_policy_for).and_return(policy)
500
+ end
501
+
502
+ it 'sets expires but omits cache_control' do
503
+ attributes = resource.to_h
504
+
505
+ expect(attributes[:expires]).to eq(CGI.rfc1123_date(Time.utc(2030, 1, 1)))
506
+ expect(attributes).not_to have_key(:cache_control)
507
+ end
508
+ end
509
+
510
+ context 'when a caching policy sets both max_age and expires' do
511
+ before do
512
+ policy = Middleman::S3Sync::BrowserCachePolicy.new(max_age: 300, expires: Time.utc(2030, 1, 1))
513
+ allow(Middleman::S3Sync).to receive(:caching_policy_for).and_return(policy)
514
+ end
515
+
516
+ it 'emits cache_control and suppresses the redundant expires' do
517
+ attributes = resource.to_h
518
+
519
+ expect(attributes[:cache_control]).to eq('max-age=300')
520
+ expect(attributes).not_to have_key(:expires)
521
+ end
522
+ end
523
+
428
524
  context 'when resource has a redirect' do
429
525
  let(:mm_resource) do
430
526
  double(
@@ -17,6 +17,7 @@ describe Middleman::S3Sync::BrowserCachePolicy do
17
17
  expect(policy.to_s).to_not match /no-store/
18
18
  expect(policy.to_s).to_not match /must-revalidate/
19
19
  expect(policy.to_s).to_not match /proxy-revalidate/
20
+ expect(policy.to_s).to_not match /immutable/
20
21
  expect(policy.expires).to eq nil
21
22
  end
22
23
 
@@ -62,6 +63,19 @@ describe Middleman::S3Sync::BrowserCachePolicy do
62
63
  its(:to_s) { is_expected.to match /proxy-revalidate/ }
63
64
  end
64
65
 
66
+ context "setting the immutable flag" do
67
+ let(:options) { { immutable: true } }
68
+ its(:to_s) { is_expected.to match /immutable/ }
69
+ end
70
+
71
+ context "combining max_age, public, and immutable for hashed assets" do
72
+ let(:options) { { max_age: 31536000, public: true, immutable: true } }
73
+
74
+ it "emits the directives in policy order" do
75
+ expect(policy.to_s).to eq "max-age=31536000, public, immutable"
76
+ end
77
+ end
78
+
65
79
  context "divide caching policiies with a comma and a space" do
66
80
  let(:options) { { :max_age => 300, :public => true } }
67
81
 
@@ -74,9 +88,20 @@ describe Middleman::S3Sync::BrowserCachePolicy do
74
88
  end
75
89
 
76
90
  context "set the expiration date" do
77
- let(:options) { { expires: 1.years.from_now } }
91
+ let(:expires_at) { Time.utc(2030, 1, 1) }
92
+ let(:options) { { expires: expires_at } }
93
+
94
+ its(:expires) { is_expected.to eq CGI.rfc1123_date(expires_at) }
95
+ end
96
+
97
+ context "max_age suppresses the Expires header" do
98
+ let(:options) { { max_age: 300, expires: Time.utc(2030, 1, 1) } }
99
+
100
+ it "still emits max-age in cache_control" do
101
+ expect(policy.to_s).to match /max-age=300/
102
+ end
78
103
 
79
- its(:expires) { is_expected.to eq CGI.rfc1123_date(1.year.from_now )}
104
+ its(:expires) { is_expected.to be_nil }
80
105
  end
81
106
  end
82
107
  end
@@ -182,6 +182,212 @@ describe Middleman::S3Sync::Resource do
182
182
  end
183
183
  end
184
184
 
185
+ context 'content type detection' do
186
+ context 'when mm_resource provides content_type' do
187
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
188
+
189
+ let(:mm_resource) {
190
+ double(
191
+ destination_path: 'path/to/file.html',
192
+ content_type: 'text/html'
193
+ )
194
+ }
195
+
196
+ before do
197
+ allow(File).to receive(:exist?).with('build/path/to/file.html').and_return(true)
198
+ allow(File).to receive(:read).with('build/path/to/file.html').and_return('content')
199
+ end
200
+
201
+ it 'uses the content_type from mm_resource' do
202
+ expect(resource.content_type).to eq 'text/html'
203
+ end
204
+ end
205
+
206
+ context 'when mm_resource is nil (orphan file)' do
207
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
208
+
209
+ let(:remote) { mock_s3_object('path/to/file.webp') }
210
+
211
+ before do
212
+ resource.full_s3_resource = remote
213
+ allow(File).to receive(:exist?).with('build/path/to/file.webp').and_return(true)
214
+ allow(File).to receive(:read).with('build/path/to/file.webp').and_return('content')
215
+ end
216
+
217
+ it 'falls back to mime-types for known extensions' do
218
+ expect(resource.content_type).to eq 'image/webp'
219
+ end
220
+ end
221
+
222
+ context 'when mm_resource has nil content_type' do
223
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
224
+
225
+ let(:mm_resource) {
226
+ double(
227
+ destination_path: 'path/to/image.svg',
228
+ content_type: nil
229
+ )
230
+ }
231
+
232
+ before do
233
+ allow(File).to receive(:exist?).with('build/path/to/image.svg').and_return(true)
234
+ allow(File).to receive(:read).with('build/path/to/image.svg').and_return('content')
235
+ end
236
+
237
+ it 'falls back to mime-types' do
238
+ expect(resource.content_type).to eq 'image/svg+xml'
239
+ end
240
+ end
241
+
242
+ context 'when content_types option is set' do
243
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
244
+
245
+ let(:mm_resource) {
246
+ double(
247
+ destination_path: 'path/to/data.custom',
248
+ content_type: nil
249
+ )
250
+ }
251
+
252
+ before do
253
+ allow(File).to receive(:exist?).with('build/path/to/data.custom').and_return(true)
254
+ allow(File).to receive(:read).with('build/path/to/data.custom').and_return('content')
255
+ options.content_types = { 'path/to/data.custom' => 'application/x-custom' }
256
+ end
257
+
258
+ it 'uses content_types option by path' do
259
+ expect(resource.content_type).to eq 'application/x-custom'
260
+ end
261
+ end
262
+
263
+ context 'when content_types option uses local_path key' do
264
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
265
+
266
+ let(:mm_resource) {
267
+ double(
268
+ destination_path: 'path/to/data.custom',
269
+ content_type: nil
270
+ )
271
+ }
272
+
273
+ before do
274
+ allow(File).to receive(:exist?).with('build/path/to/data.custom').and_return(true)
275
+ allow(File).to receive(:read).with('build/path/to/data.custom').and_return('content')
276
+ options.content_types = { 'build/path/to/data.custom' => 'application/x-custom-local' }
277
+ end
278
+
279
+ it 'uses content_types option by local_path' do
280
+ expect(resource.content_type).to eq 'application/x-custom-local'
281
+ end
282
+ end
283
+
284
+ context 'when extension is unknown' do
285
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
286
+
287
+ let(:mm_resource) {
288
+ double(
289
+ destination_path: 'path/to/file.unknownext123',
290
+ content_type: nil
291
+ )
292
+ }
293
+
294
+ before do
295
+ allow(File).to receive(:exist?).with('build/path/to/file.unknownext123').and_return(true)
296
+ allow(File).to receive(:read).with('build/path/to/file.unknownext123').and_return('content')
297
+ end
298
+
299
+ it 'defaults to application/octet-stream' do
300
+ expect(resource.content_type).to eq 'application/octet-stream'
301
+ end
302
+ end
303
+ end
304
+
305
+ context 'a resource with explicit path (orphan file)' do
306
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, nil, path: 'orphan/file.webp') }
307
+
308
+ before do
309
+ allow(File).to receive(:exist?).with('build/orphan/file.webp').and_return(true)
310
+ allow(File).to receive(:exist?).with('build/orphan/file.webp.gz').and_return(false)
311
+ allow(File).to receive(:read).with('build/orphan/file.webp').and_return('content')
312
+ allow(File).to receive(:directory?).with('build/orphan/file.webp').and_return(false)
313
+
314
+ # Mock the bucket to return NotFound for head request (file doesn't exist on S3)
315
+ mock_object = instance_double(Aws::S3::Object)
316
+ allow(mock_object).to receive(:head).and_raise(Aws::S3::Errors::NotFound.new(nil, 'Not Found'))
317
+ allow(bucket).to receive(:object).with('orphan/file.webp').and_return(mock_object)
318
+ end
319
+
320
+ its(:path) { is_expected.to eq 'orphan/file.webp' }
321
+ its(:local_path) { is_expected.to eq 'build/orphan/file.webp' }
322
+ its(:status) { is_expected.to eq :new }
323
+
324
+ it 'detects content type via mime-types' do
325
+ expect(resource.content_type).to eq 'image/webp'
326
+ end
327
+ end
328
+
329
+ context 'redirect preservation' do
330
+ context 'remote-only redirect (no local file)' do
331
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
332
+
333
+ let(:remote) do
334
+ double(
335
+ key: 'old-page.html',
336
+ metadata: {},
337
+ etag: '"abc123"',
338
+ content_encoding: nil,
339
+ cache_control: nil,
340
+ website_redirect_location: '/new-page.html'
341
+ )
342
+ end
343
+
344
+ before do
345
+ allow(File).to receive(:exist?).with('build/old-page.html').and_return(false)
346
+ resource.full_s3_resource = remote
347
+ end
348
+
349
+ it 'detects as a redirect' do
350
+ expect(resource.redirect?).to be true
351
+ end
352
+
353
+ its(:status) { is_expected.to eq :ignored }
354
+
355
+ it 'is not marked for deletion' do
356
+ expect(resource.to_delete?).to be false
357
+ end
358
+ end
359
+
360
+ context 'remote file without redirect (no local file)' do
361
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
362
+
363
+ let(:remote) do
364
+ double(
365
+ key: 'deleted-page.html',
366
+ metadata: {},
367
+ etag: '"abc123"',
368
+ content_encoding: nil,
369
+ cache_control: nil,
370
+ website_redirect_location: nil
371
+ )
372
+ end
373
+
374
+ before do
375
+ allow(File).to receive(:exist?).with('build/deleted-page.html').and_return(false)
376
+ resource.full_s3_resource = remote
377
+ end
378
+
379
+ it 'is not a redirect' do
380
+ expect(resource.redirect?).to be_falsey
381
+ end
382
+
383
+ its(:status) { is_expected.to eq :deleted }
384
+
385
+ it 'is marked for deletion' do
386
+ expect(resource.to_delete?).to be true
387
+ end
388
+ end
389
+ end
390
+
185
391
  context 'An ignored resource' do
186
392
  context "that is local" do
187
393