active_storage_encryption 0.2.2 → 0.3.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.
@@ -10,6 +10,7 @@ module ActiveStorageEncryption
10
10
  autoload :EncryptedDiskService, __dir__ + "/active_storage_encryption/encrypted_disk_service.rb"
11
11
  autoload :EncryptedMirrorService, __dir__ + "/active_storage_encryption/encrypted_mirror_service.rb"
12
12
  autoload :EncryptedS3Service, __dir__ + "/active_storage_encryption/encrypted_s3_service.rb"
13
+ autoload :EncryptedGCSService, __dir__ + "/active_storage_encryption/encrypted_gcs_service.rb"
13
14
  autoload :Overrides, __dir__ + "/active_storage_encryption/overrides.rb"
14
15
 
15
16
  class IncorrectEncryptionKey < ArgumentError
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User < ApplicationRecord
4
+ has_one_attached :file, service: :encrypted_disk
5
+ end
@@ -42,6 +42,8 @@ Rails.application.configure do
42
42
  # Tell Active Support which deprecation messages to disallow.
43
43
  config.active_support.disallowed_deprecation_warnings = []
44
44
 
45
+ config.active_storage.service = :test
46
+
45
47
  # Raises error for missing translations.
46
48
  # config.i18n.raise_on_missing_translations = true
47
49
 
@@ -19,3 +19,6 @@ encrypted_mirror:
19
19
  - encrypted_disk
20
20
  mirrors:
21
21
  - test
22
+
23
+ encrypted_gcs_service:
24
+ service: EncryptedGCS
@@ -0,0 +1,7 @@
1
+ class CreateUsers < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :users do |t|
4
+ t.timestamps
5
+ end
6
+ end
7
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  # This file is auto-generated from the current state of the database. Instead
4
2
  # of editing this file, please use the migrations feature of Active Record to
5
3
  # incrementally modify your database, and then regenerate this schema definition.
@@ -12,7 +10,7 @@
12
10
  #
13
11
  # It's strongly recommended that you check this file into your version control system.
14
12
 
15
- ActiveRecord::Schema[7.2].define(version: 2025_03_04_023853) do
13
+ ActiveRecord::Schema[7.2].define(version: 2025_04_28_093315) do
16
14
  create_table "active_storage_attachments", force: :cascade do |t|
17
15
  t.string "name", null: false
18
16
  t.string "record_type", null: false
@@ -42,6 +40,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_023853) do
42
40
  t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
43
41
  end
44
42
 
43
+ create_table "users", force: :cascade do |t|
44
+ t.datetime "created_at", null: false
45
+ t.datetime "updated_at", null: false
46
+ end
47
+
45
48
  add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
46
49
  add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
47
50
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveStorageEncryption::EncryptedGCSServiceTest < ActiveSupport::TestCase
6
+ def config
7
+ {
8
+ project_id: "sandbox-ci-25b8",
9
+ bucket: "sandbox-ci-testing-secure-documents",
10
+ private_url_policy: "stream"
11
+ }
12
+ end
13
+
14
+ setup do
15
+ if ENV["GOOGLE_APPLICATION_CREDENTIALS"].blank?
16
+ skip "You need GOOGLE_APPLICATION_CREDENTIALS set in your env and it needs to point to the JSON keyfile for GCS"
17
+ end
18
+
19
+ @textfile = StringIO.new("Secure document that needs to be stored encrypted.")
20
+ @textfile2 = StringIO.new("While being neatly organized all in a days work aat the job.")
21
+ @service = ActiveStorageEncryption::EncryptedGCSService.new(**config)
22
+ @service.name = "encrypted_gcs_service"
23
+
24
+ @encryption_key = ActiveStorage::Blob.generate_random_encryption_key
25
+ @gcs_key_length_range = (0...ActiveStorageEncryption::EncryptedGCSService::GCS_ENCRYPTION_KEY_LENGTH_BYTES) # 32 bytes
26
+ end
27
+
28
+ def run_id
29
+ # We use a shared GCS bucket, and multiple runs of the test suite may write into it at the same time.
30
+ # To prevent clobbering and conflicts, assign a "test run ID" and mix it into the object keys. Keep that
31
+ # value stable across the test suite.
32
+ @test_suite_run_id ||= SecureRandom.base36(10)
33
+ end
34
+
35
+ def test_encrypted_question_method
36
+ assert @service.encrypted?
37
+ end
38
+
39
+ def test_forbids_private_urls_with_disabled_policy
40
+ @service.private_url_policy = :disable
41
+
42
+ rng = Random.new(Minitest.seed)
43
+ key = "#{run_id}-streamed-key-#{rng.hex(4)}"
44
+ encryption_key = Random.bytes(68)
45
+ plaintext_upload_bytes = rng.bytes(425)
46
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
47
+
48
+ # ActiveStorage wraps the passed filename in a wrapper thingy
49
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
50
+
51
+ assert_raises(ActiveStorageEncryption::StreamingDisabled) do
52
+ @service.url(key, filename: filename_with_sanitization, blob_byte_size: plaintext_upload_bytes.bytesize, content_type: "binary/octet-stream", disposition: "inline", encryption_key:, expires_in: 10.seconds)
53
+ end
54
+ end
55
+
56
+ def test_exists
57
+ rng = Random.new(Minitest.seed)
58
+
59
+ key = "#{run_id}-encrypted-exists-key-#{rng.hex(4)}"
60
+ encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
61
+ plaintext_upload_bytes = rng.bytes(1024)
62
+
63
+ assert_nothing_raised { @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) }
64
+ refute @service.exist?(key + "-definitely-not-present")
65
+ assert @service.exist?(key)
66
+ end
67
+
68
+ def test_generates_private_streaming_urls_with_streaming_policy
69
+ @service.private_url_policy = :stream
70
+
71
+ rng = Random.new(Minitest.seed)
72
+ key = "#{run_id}-streamed-key-#{rng.hex(4)}"
73
+ encryption_key = Random.bytes(68)
74
+ plaintext_upload_bytes = rng.bytes(425)
75
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
76
+
77
+ # The streaming URL generation uses Rails routing, so it needs
78
+ # ActiveStorage::Current.url_options to be set
79
+ # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts.
80
+ # see https://stackoverflow.com/a/60573259/153886
81
+ ActiveStorage::Current.url_options = {
82
+ host: "www.example.com",
83
+ protocol: "https"
84
+ }
85
+
86
+ # ActiveStorage wraps the passed filename in a wrapper thingy
87
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
88
+ url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize,
89
+ filename: filename_with_sanitization, content_type: "binary/octet-stream",
90
+ disposition: "inline", encryption_key:, expires_in: 10.seconds)
91
+ assert url.include?("/active-storage-encryption/blob/")
92
+ end
93
+
94
+ def test_generates_private_urls_with_require_headers_policy
95
+ @service.private_url_policy = :require_headers
96
+
97
+ rng = Random.new(Minitest.seed)
98
+ key = "#{run_id}-streamed-key-#{rng.hex(4)}"
99
+ encryption_key = Random.bytes(68)
100
+ plaintext_upload_bytes = rng.bytes(425)
101
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
102
+
103
+ # ActiveStorage wraps the passed filename in a wrapper thingy
104
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
105
+ url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize,
106
+ filename: filename_with_sanitization, content_type: "binary/octet-stream",
107
+ disposition: "inline", encryption_key:, expires_in: 240.seconds)
108
+
109
+ query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h
110
+
111
+ # Downcased header names for this test since that's what we get back from signing process.
112
+ expected_headers = ["x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"]
113
+ signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";")
114
+ assert expected_headers.all? { |header| header.in?(signed_headers) }
115
+
116
+ uri = URI(url)
117
+ req = Net::HTTP::Get.new(uri)
118
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http|
119
+ http.request(req)
120
+ }
121
+ assert_equal "400", res.code
122
+
123
+ # TODO make this a headers_for_private_download like in the s3 service
124
+ download_headers = {
125
+ "content-type" => "binary/octet-stream",
126
+ "Content-Disposition" => "inline; filename=\"temp.bin\"; filename*=UTF-8''temp.bin",
127
+ "x-goog-encryption-algorithm" => "AES256",
128
+ "x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]),
129
+ "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range])
130
+ }
131
+ download_headers.each_pair { |key, value| req[key] = value }
132
+
133
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http|
134
+ http.request(req)
135
+ }
136
+ assert_equal "200", res.code
137
+ assert_equal plaintext_upload_bytes, res.body
138
+ end
139
+
140
+ def test_basic_gcs_readback
141
+ rng = Random.new(Minitest.seed)
142
+
143
+ key = "#{run_id}-encrypted-key-#{rng.hex(4)}"
144
+ encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
145
+ plaintext_upload_bytes = rng.bytes(1024)
146
+
147
+ assert_nothing_raised do
148
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
149
+ end
150
+ readback = @service.download(key, encryption_key:)
151
+ assert_equal readback, plaintext_upload_bytes
152
+ end
153
+
154
+ def test_accepts_direct_upload_with_signature_and_headers
155
+ rng = Random.new(Minitest.seed)
156
+
157
+ key = "#{run_id}-encrypted-key-direct-upload-#{rng.hex(4)}"
158
+ encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
159
+ plaintext_upload_bytes = rng.bytes(1024)
160
+
161
+ url = @service.url_for_direct_upload(key,
162
+ encryption_key:,
163
+ expires_in: 5.minutes.to_i,
164
+ content_type: "binary/octet-stream",
165
+ content_length: plaintext_upload_bytes.bytesize,
166
+ checksum: Digest::MD5.base64digest(plaintext_upload_bytes))
167
+
168
+ query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h
169
+
170
+ # Downcased header names for this test since that's what we get back from signing process.
171
+ expected_headers = ["content-md5", "x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"]
172
+ signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";")
173
+ assert expected_headers.all? { |header| header.in?(signed_headers) }
174
+
175
+ assert_equal "300", query_params_hash["X-Goog-Expires"]
176
+
177
+ should_be_headers = {
178
+ "Content-Type" => "binary/octet-stream",
179
+ "Content-MD5" => Digest::MD5.base64digest(plaintext_upload_bytes),
180
+ "x-goog-encryption-algorithm" => "AES256",
181
+ "x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]),
182
+ "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range])
183
+ }
184
+
185
+ headers = @service.headers_for_direct_upload(key,
186
+ encryption_key:,
187
+ content_type: "binary/octet-stream",
188
+ content_length: plaintext_upload_bytes.bytesize,
189
+ checksum: Digest::MD5.base64digest(plaintext_upload_bytes))
190
+
191
+ assert_equal should_be_headers.sort, headers.sort
192
+
193
+ res = Net::HTTP.put(URI(url), plaintext_upload_bytes, headers)
194
+ assert_equal "200", res.code
195
+
196
+ assert_equal plaintext_upload_bytes, @service.download(key, encryption_key:)
197
+
198
+ @service.delete(key)
199
+ refute @service.exist?(key)
200
+ end
201
+ end
@@ -235,7 +235,10 @@ class ActiveStorageEncryption::EncryptedS3ServiceTest < ActiveSupport::TestCase
235
235
  # Read the objects from something slow, so that threads may switch between one another
236
236
  class SnoozyStringIO < StringIO
237
237
  def read(n = nil, outbuf = nil)
238
- sleep(rand((0.1..0.2)))
238
+ sleep_from = 0.1
239
+ sleep_to = 0.2
240
+ delay_s = rand(sleep_from..sleep_to)
241
+ sleep(delay_s)
239
242
  super
240
243
  end
241
244
  end
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveStorageEncryption::OverridesTest < ActiveSupport::TestCase
6
+ include ActiveJob::TestHelper
7
+
8
+ setup do
9
+ ActiveStorage::Current.url_options = {
10
+ host: "www.example.com",
11
+ protocol: "https"
12
+ }
13
+ end
14
+
15
+ def test_encryption_key_is_set_on_encrypted_service_before_saving
16
+ blob = ActiveStorage::Blob.create!(
17
+ key: "yoyo",
18
+ filename: "test.txt",
19
+ byte_size: 10,
20
+ checksum: "abab",
21
+ metadata: {"identified" => true},
22
+ content_type: "text/plain",
23
+ encryption_key: "blabla",
24
+ service_name: "encrypted_disk"
25
+ )
26
+
27
+ assert blob.valid?
28
+ blob.encryption_key = nil
29
+ refute blob.valid?
30
+ assert_equal ["Encryption key must be present for this service"], blob.errors.full_messages
31
+ end
32
+
33
+ def test_attach_download_and_destroy_with_encryption_works
34
+ user = User.create!
35
+ with_uploadable_random_file do |file|
36
+ user.file.attach(io: file, filename: "test.txt")
37
+ end
38
+ assert user.file.url.include?("/active-storage-encryption/blob/")
39
+ assert user.file.blob.encryption_key
40
+ with_uploadable_random_file do |file|
41
+ assert_equal file.size, user.file.blob.byte_size
42
+ end
43
+ with_uploadable_random_file do |file|
44
+ assert_equal file.read, user.file.download
45
+ end
46
+ user.file.destroy
47
+ user.reload
48
+ refute user.file.attached?
49
+ end
50
+
51
+ def test_generate_random_encryption_key_is_long_enough
52
+ key = ActiveStorage::Blob.generate_random_encryption_key
53
+ assert_equal 48, key.size
54
+ assert_equal Encoding::BINARY, key.encoding
55
+ end
56
+
57
+ def test_service_encrypted
58
+ blob = ActiveStorage::Blob.create!(service_name: :encrypted_disk, checksum: "yoyo", encryption_key: "haha", filename: "test", key: "hahahaha", byte_size: 50)
59
+ assert blob.service_encrypted?
60
+ blob_2 = ActiveStorage::Blob.create!(service_name: :test, checksum: "yoyo", filename: "test", key: "ok", byte_size: 50)
61
+ refute blob_2.service_encrypted?
62
+ end
63
+
64
+ def test_create_before_direct_upload_works_with_encryption_and_without
65
+ blob = with_uploadable_random_file do |file|
66
+ ActiveStorage::Blob.create_before_direct_upload!(
67
+ filename: "test_upload",
68
+ byte_size: file.size,
69
+ checksum: "something",
70
+ metadata: {"identified" => true},
71
+ service_name: "encrypted_disk"
72
+ )
73
+ end
74
+ assert_raises ActiveStorage::FileNotFoundError do
75
+ blob.download
76
+ end
77
+
78
+ assert blob.service_encrypted?
79
+ assert blob.encryption_key
80
+
81
+ blob_2 = with_uploadable_random_file do |file|
82
+ ActiveStorage::Blob.create_before_direct_upload!(
83
+ filename: "test_upload_2",
84
+ byte_size: file.size,
85
+ checksum: "something",
86
+ metadata: {"identified" => true},
87
+ service_name: "test"
88
+ )
89
+ end
90
+ refute blob_2.service_encrypted?
91
+ refute blob_2.encryption_key
92
+ end
93
+
94
+ def test_create_and_upload_works_with_encryption_and_without
95
+ encrypted_blob = with_uploadable_random_file do |file|
96
+ ActiveStorage::Blob.create_and_upload!(
97
+ io: file,
98
+ filename: "test_upload",
99
+ metadata: {"identified" => true},
100
+ service_name: "encrypted_disk"
101
+ )
102
+ end
103
+
104
+ assert encrypted_blob.service_encrypted?
105
+ assert encrypted_blob.url.include?("/active-storage-encryption/blob/")
106
+ assert encrypted_blob.encryption_key
107
+ with_uploadable_random_file do |file|
108
+ assert_equal file.size, encrypted_blob.byte_size
109
+ end
110
+ with_uploadable_random_file do |file|
111
+ assert_equal file.read, encrypted_blob.download
112
+ end
113
+
114
+ unencrypted_blob = with_uploadable_random_file do |file|
115
+ ActiveStorage::Blob.create_and_upload!(
116
+ io: file,
117
+ filename: "test_upload_2",
118
+ metadata: {"identified" => true},
119
+ service_name: "test"
120
+ )
121
+ end
122
+
123
+ refute unencrypted_blob.service_encrypted?
124
+ assert unencrypted_blob.url.include?("/rails/active_storage/disk/")
125
+ refute unencrypted_blob.encryption_key
126
+ with_uploadable_random_file do |file|
127
+ assert_equal file.size, unencrypted_blob.byte_size
128
+ end
129
+ with_uploadable_random_file do |file|
130
+ assert_equal file.read, unencrypted_blob.download
131
+ end
132
+ end
133
+
134
+ def test_open_temp_reads_the_content_of_the_blob_with_encryption_and_without
135
+ encrypted_blob = with_uploadable_random_file do |file|
136
+ ActiveStorage::Blob.create_and_upload!(
137
+ io: file,
138
+ filename: "test_upload",
139
+ metadata: {"identified" => true},
140
+ service_name: "encrypted_disk"
141
+ )
142
+ end
143
+
144
+ with_uploadable_random_file do |file|
145
+ encrypted_blob.open do |b|
146
+ assert_equal file.read, b.read
147
+ end
148
+ end
149
+
150
+ unencrypted_blob = with_uploadable_random_file do |file|
151
+ ActiveStorage::Blob.create_and_upload!(
152
+ io: file,
153
+ filename: "test_upload_2",
154
+ metadata: {"identified" => true},
155
+ service_name: "test"
156
+ )
157
+ end
158
+
159
+ with_uploadable_random_file do |file|
160
+ unencrypted_blob.open do |b|
161
+ assert_equal file.read, b.read
162
+ end
163
+ end
164
+ end
165
+
166
+ def test_can_download_a_chunk_with_encryption_and_without
167
+ encrypted_blob = with_uploadable_random_file do |file|
168
+ ActiveStorage::Blob.create_and_upload!(
169
+ io: file,
170
+ filename: "test_upload",
171
+ metadata: {"identified" => true},
172
+ service_name: "encrypted_disk"
173
+ )
174
+ end
175
+
176
+ chunk = encrypted_blob.download_chunk(0..5.bytes)
177
+ with_uploadable_random_file do |file|
178
+ assert_equal chunk, file.read(6)
179
+ end
180
+
181
+ unencrypted_blob = with_uploadable_random_file do |file|
182
+ ActiveStorage::Blob.create_and_upload!(
183
+ io: file,
184
+ filename: "test_upload_2",
185
+ metadata: {"identified" => true},
186
+ service_name: "test"
187
+ )
188
+ end
189
+
190
+ chunk = unencrypted_blob.download_chunk(0..5.bytes)
191
+ with_uploadable_random_file do |file|
192
+ assert_equal chunk, file.read(6)
193
+ end
194
+ end
195
+
196
+ def test_serializable_hash_works_with_encryption_and_without
197
+ encrypted_blob = with_uploadable_random_file do |file|
198
+ ActiveStorage::Blob.create_and_upload!(
199
+ io: file,
200
+ filename: "test_upload",
201
+ metadata: {"identified" => true},
202
+ service_name: "encrypted_disk"
203
+ )
204
+ end
205
+ encrypted_blob_hash = {
206
+ "id" => encrypted_blob.id,
207
+ "key" => encrypted_blob.key,
208
+ "filename" => encrypted_blob.filename,
209
+ "content_type" => encrypted_blob.content_type,
210
+ "metadata" => encrypted_blob.metadata,
211
+ "service_name" => encrypted_blob.service_name,
212
+ "byte_size" => encrypted_blob.byte_size,
213
+ "checksum" => encrypted_blob.checksum,
214
+ "created_at" => encrypted_blob.created_at
215
+ }
216
+ assert_equal encrypted_blob_hash.sort, encrypted_blob.serializable_hash.sort
217
+
218
+ unencrypted_blob = with_uploadable_random_file do |file|
219
+ ActiveStorage::Blob.create_and_upload!(
220
+ io: file,
221
+ filename: "test_upload_2",
222
+ metadata: {"identified" => true},
223
+ service_name: "encrypted_disk"
224
+ )
225
+ end
226
+ unencrypted_blob_hash = {
227
+ "id" => unencrypted_blob.id,
228
+ "key" => unencrypted_blob.key,
229
+ "filename" => unencrypted_blob.filename,
230
+ "content_type" => encrypted_blob.content_type,
231
+ "metadata" => unencrypted_blob.metadata,
232
+ "service_name" => unencrypted_blob.service_name,
233
+ "byte_size" => unencrypted_blob.byte_size,
234
+ "checksum" => unencrypted_blob.checksum,
235
+ "created_at" => unencrypted_blob.created_at
236
+ }
237
+ assert_equal unencrypted_blob_hash, unencrypted_blob.serializable_hash
238
+ end
239
+
240
+ def test_instance_compose_works_with_encryption_and_without
241
+ rng = Random.new(Minitest.seed)
242
+
243
+ encrypted_blob_1 = with_uploadable_random_file do |file|
244
+ ActiveStorage::Blob.create_and_upload!(
245
+ io: file, filename: "test_upload", metadata: {"identified" => true},
246
+ service_name: "encrypted_disk"
247
+ )
248
+ end
249
+ encrypted_blob_2 = with_uploadable_random_file do |file|
250
+ ActiveStorage::Blob.create_and_upload!(
251
+ io: file, filename: "test_upload_2", metadata: {"identified" => true},
252
+ service_name: "encrypted_disk"
253
+ )
254
+ end
255
+ new_encrypted_blob = ActiveStorage::Blob.create_before_direct_upload!(
256
+ key: "new_blob_key", filename: "combined_test_upload",
257
+ metadata: {"identified" => true}, content_type: "plain/text",
258
+ checksum: "okok",
259
+ byte_size: encrypted_blob_1.byte_size + encrypted_blob_2.byte_size,
260
+ encryption_key: rng.bytes(68),
261
+ service_name: "encrypted_disk"
262
+ )
263
+ new_encrypted_blob.compose([encrypted_blob_1.key, encrypted_blob_2.key], source_encryption_keys: [encrypted_blob_1.encryption_key, encrypted_blob_2.encryption_key])
264
+ with_uploadable_random_file do |file|
265
+ assert_equal file.read * 2, new_encrypted_blob.download
266
+ end
267
+
268
+ unencrypted_blob_1 = with_uploadable_random_file do |file|
269
+ ActiveStorage::Blob.create_and_upload!(
270
+ io: file, filename: "test_upload_3", metadata: {"identified" => true},
271
+ service_name: "test"
272
+ )
273
+ end
274
+ unencrypted_blob_2 = with_uploadable_random_file do |file|
275
+ ActiveStorage::Blob.create_and_upload!(
276
+ io: file, filename: "test_upload_4", metadata: {"identified" => true},
277
+ service_name: "test"
278
+ )
279
+ end
280
+ new_unencrypted_blob = ActiveStorage::Blob.create_before_direct_upload!(
281
+ key: "new_blob_key_2", filename: "combined_test_upload",
282
+ metadata: {"identified" => true}, content_type: "plain/text",
283
+ checksum: "okok",
284
+ byte_size: unencrypted_blob_1.byte_size + unencrypted_blob_2.byte_size
285
+ )
286
+ new_unencrypted_blob.compose([unencrypted_blob_1.key, unencrypted_blob_2.key])
287
+ with_uploadable_random_file do |file|
288
+ assert_equal file.read * 2, new_unencrypted_blob.download
289
+ end
290
+ end
291
+
292
+ def test_class_compose_works_with_and_without_encryption
293
+ rng = Random.new(Minitest.seed)
294
+
295
+ encrypted_blob_1 = with_uploadable_random_file do |file|
296
+ ActiveStorage::Blob.create_and_upload!(
297
+ io: file, filename: "test_upload", metadata: {"identified" => true},
298
+ service_name: "encrypted_disk"
299
+ )
300
+ end
301
+ encrypted_blob_2 = with_uploadable_random_file do |file|
302
+ ActiveStorage::Blob.create_and_upload!(
303
+ io: file, filename: "test_upload_2", metadata: {"identified" => true},
304
+ service_name: "encrypted_disk"
305
+ )
306
+ end
307
+ new_enc_blob = ActiveStorage::Blob.compose(
308
+ [encrypted_blob_1, encrypted_blob_2],
309
+ content_type: "text/plain",
310
+ filename: "composed_blob",
311
+ metadata: {"identified" => true},
312
+ key: "new_enc_blob",
313
+ service_name: "encrypted_disk",
314
+ encryption_key: rng.bytes(68)
315
+ )
316
+ with_uploadable_random_file do |file|
317
+ assert_equal file.read * 2, new_enc_blob.download
318
+ end
319
+
320
+ unencrypted_blob_1 = with_uploadable_random_file do |file|
321
+ ActiveStorage::Blob.create_and_upload!(
322
+ io: file, filename: "test_upload_3", metadata: {"identified" => true},
323
+ service_name: "test"
324
+ )
325
+ end
326
+ unencrypted_blob_2 = with_uploadable_random_file do |file|
327
+ ActiveStorage::Blob.create_and_upload!(
328
+ io: file, filename: "test_upload_4", metadata: {"identified" => true},
329
+ service_name: "test"
330
+ )
331
+ end
332
+ new_unencrypted_blob = ActiveStorage::Blob.compose(
333
+ [unencrypted_blob_1, unencrypted_blob_2],
334
+ key: "new_unencblob_key_2", filename: "combined_test_upload_2",
335
+ metadata: {"identified" => true}, service_name: "test"
336
+ )
337
+ with_uploadable_random_file do |file|
338
+ assert_equal file.read * 2, new_unencrypted_blob.download
339
+ end
340
+ end
341
+
342
+ private
343
+
344
+ def with_uploadable_random_file(size = 128, &blk)
345
+ rng = Random.new(Minitest.seed)
346
+ plaintext_upload_bytes = rng.bytes(size)
347
+ StringIO.open(plaintext_upload_bytes, "rb", &blk)
348
+ end
349
+ end