mongoid 9.1.0 → 9.1.2

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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +69 -0
  3. data/lib/mongoid/association/accessors.rb +27 -5
  4. data/lib/mongoid/association/depending.rb +14 -10
  5. data/lib/mongoid/association/eager.rb +9 -0
  6. data/lib/mongoid/association/eager_load/discriminated_inclusion.rb +88 -0
  7. data/lib/mongoid/association/eager_load/embedded_distributor.rb +151 -0
  8. data/lib/mongoid/association/eager_load/inclusion.rb +173 -0
  9. data/lib/mongoid/association/eager_load/inclusion_tree.rb +96 -0
  10. data/lib/mongoid/association/eager_load/lookup_pipeline.rb +121 -0
  11. data/lib/mongoid/association/eager_load/polymorphic_preloader.rb +61 -0
  12. data/lib/mongoid/association/eager_load/polymorphic_targets.rb +151 -0
  13. data/lib/mongoid/association/eager_loadable.rb +65 -101
  14. data/lib/mongoid/association/embedded/batchable.rb +17 -1
  15. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +6 -1
  16. data/lib/mongoid/association/embedded/embeds_one.rb +1 -0
  17. data/lib/mongoid/association/macros.rb +13 -1
  18. data/lib/mongoid/association/nested/many.rb +34 -5
  19. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  20. data/lib/mongoid/association/nested/one.rb +6 -1
  21. data/lib/mongoid/association/options.rb +17 -0
  22. data/lib/mongoid/association/referenced/belongs_to/eager.rb +6 -1
  23. data/lib/mongoid/association/referenced/belongs_to.rb +1 -0
  24. data/lib/mongoid/association/referenced/counter_cache.rb +3 -3
  25. data/lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb +6 -0
  26. data/lib/mongoid/association/referenced/has_and_belongs_to_many.rb +5 -0
  27. data/lib/mongoid/association/referenced/has_many/enumerable.rb +3 -1
  28. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  29. data/lib/mongoid/association/referenced/has_many_through/eager.rb +99 -0
  30. data/lib/mongoid/association/referenced/has_many_through/proxy.rb +68 -0
  31. data/lib/mongoid/association/referenced/has_many_through.rb +201 -0
  32. data/lib/mongoid/association/referenced/has_one.rb +1 -0
  33. data/lib/mongoid/association/referenced/has_one_through/eager.rb +65 -0
  34. data/lib/mongoid/association/referenced/has_one_through/proxy.rb +30 -0
  35. data/lib/mongoid/association/referenced/has_one_through.rb +167 -0
  36. data/lib/mongoid/association/referenced.rb +2 -0
  37. data/lib/mongoid/association/relatable.rb +58 -0
  38. data/lib/mongoid/association.rb +15 -0
  39. data/lib/mongoid/atomic/modifiers.rb +100 -16
  40. data/lib/mongoid/clients/factory.rb +9 -13
  41. data/lib/mongoid/collection_configurable.rb +8 -0
  42. data/lib/mongoid/config/defaults.rb +0 -1
  43. data/lib/mongoid/config/encryption.rb +170 -22
  44. data/lib/mongoid/config.rb +56 -5
  45. data/lib/mongoid/contextual/aggregable/memory.rb +6 -2
  46. data/lib/mongoid/contextual/memory.rb +18 -7
  47. data/lib/mongoid/criteria/includable.rb +35 -11
  48. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  49. data/lib/mongoid/criteria/queryable/selectable.rb +123 -7
  50. data/lib/mongoid/deprecable.rb +45 -2
  51. data/lib/mongoid/document.rb +3 -0
  52. data/lib/mongoid/encryptable.rb +73 -0
  53. data/lib/mongoid/errors/config_redactor.rb +43 -0
  54. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  55. data/lib/mongoid/errors/invalid_encryption_schema.rb +27 -0
  56. data/lib/mongoid/errors/mixed_client_configuration.rb +1 -1
  57. data/lib/mongoid/errors/no_client_database.rb +1 -1
  58. data/lib/mongoid/errors/no_client_hosts.rb +1 -1
  59. data/lib/mongoid/errors/no_encryption_schema.rb +30 -0
  60. data/lib/mongoid/errors/readonly_association.rb +29 -0
  61. data/lib/mongoid/errors.rb +5 -0
  62. data/lib/mongoid/extensions/vector.rb +64 -0
  63. data/lib/mongoid/extensions.rb +1 -0
  64. data/lib/mongoid/field_readable.rb +69 -0
  65. data/lib/mongoid/fields.rb +7 -0
  66. data/lib/mongoid/indexable.rb +38 -26
  67. data/lib/mongoid/matchable.rb +6 -1
  68. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +2 -4
  69. data/lib/mongoid/matcher/regex.rb +11 -12
  70. data/lib/mongoid/matcher/regexp_budget.rb +372 -0
  71. data/lib/mongoid/matcher.rb +1 -0
  72. data/lib/mongoid/persistable/creatable.rb +120 -14
  73. data/lib/mongoid/persistable/updatable.rb +9 -11
  74. data/lib/mongoid/persistable.rb +15 -15
  75. data/lib/mongoid/persistence_context.rb +77 -0
  76. data/lib/mongoid/search_indexable.rb +69 -24
  77. data/lib/mongoid/serializable.rb +12 -4
  78. data/lib/mongoid/tasks/database.rb +33 -16
  79. data/lib/mongoid/threaded.rb +36 -0
  80. data/lib/mongoid/timestamps/timeless.rb +84 -10
  81. data/lib/mongoid/touchable.rb +28 -1
  82. data/lib/mongoid/traversable.rb +2 -2
  83. data/lib/mongoid/validatable/associated.rb +18 -2
  84. data/lib/mongoid/version.rb +1 -1
  85. data/lib/mongoid/warnings.rb +4 -0
  86. data/spec/integration/app_spec.rb +7 -1
  87. data/spec/integration/associations/belongs_to_spec.rb +13 -0
  88. data/spec/integration/associations/embedded_delayed_atomic_sets_spec.rb +216 -0
  89. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +31 -2
  90. data/spec/integration/atomic/modifiers_spec.rb +283 -0
  91. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  92. data/spec/integration/encryption_spec.rb +300 -0
  93. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  94. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  95. data/spec/integration/query_operator_guard_spec.rb +89 -0
  96. data/spec/mongoid/association/accessors_spec.rb +35 -0
  97. data/spec/mongoid/association/eager_loadable_spec.rb +47 -0
  98. data/spec/mongoid/association/eager_spec.rb +2 -2
  99. data/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +33 -0
  100. data/spec/mongoid/association/fallback_spec.rb +425 -0
  101. data/spec/mongoid/association/macros_spec.rb +26 -0
  102. data/spec/mongoid/association/referenced/belongs_to/eager_spec.rb +17 -0
  103. data/spec/mongoid/association/referenced/has_and_belongs_to_many/buildable_spec.rb +11 -0
  104. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  105. data/spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb +50 -0
  106. data/spec/mongoid/association/referenced/has_many/eager_spec.rb +33 -0
  107. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  108. data/spec/mongoid/association/referenced/has_many_through/eager_spec.rb +58 -0
  109. data/spec/mongoid/association/referenced/has_many_through/proxy_spec.rb +62 -0
  110. data/spec/mongoid/association/referenced/has_many_through_spec.rb +399 -0
  111. data/spec/mongoid/association/referenced/has_one_through/eager_spec.rb +58 -0
  112. data/spec/mongoid/association/referenced/has_one_through/proxy_spec.rb +49 -0
  113. data/spec/mongoid/association/referenced/has_one_through_spec.rb +211 -0
  114. data/spec/mongoid/atomic/modifiers_spec.rb +347 -0
  115. data/spec/mongoid/attributes/nested_spec.rb +278 -8
  116. data/spec/mongoid/clients/factory_spec.rb +36 -0
  117. data/spec/mongoid/config/defaults_spec.rb +33 -0
  118. data/spec/mongoid/config/encryption_spec.rb +451 -0
  119. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  120. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  121. data/spec/mongoid/criteria/includable_spec.rb +1221 -0
  122. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +15 -1
  123. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +201 -0
  124. data/spec/mongoid/criteria_spec.rb +3 -0
  125. data/spec/mongoid/encryptable_spec.rb +115 -0
  126. data/spec/mongoid/errors/config_redactor_spec.rb +112 -0
  127. data/spec/mongoid/errors/mixed_client_configuration_spec.rb +31 -0
  128. data/spec/mongoid/errors/no_client_database_spec.rb +29 -0
  129. data/spec/mongoid/errors/no_client_hosts_spec.rb +26 -0
  130. data/spec/mongoid/errors/readonly_association_spec.rb +34 -0
  131. data/spec/mongoid/extensions/vector_spec.rb +175 -0
  132. data/spec/mongoid/fields_spec.rb +8 -0
  133. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  134. data/spec/mongoid/search_indexable_spec.rb +321 -9
  135. data/spec/mongoid/tasks/database_spec.rb +31 -0
  136. data/spec/mongoid/timestamps/timeless_spec.rb +197 -5
  137. data/spec/mongoid/touchable_spec.rb +242 -0
  138. data/spec/mongoid/touchable_spec_models.rb +47 -0
  139. data/spec/mongoid/validatable/associated_spec.rb +19 -0
  140. data/spec/spec_helper.rb +5 -1
  141. data/spec/support/crypt/models.rb +253 -0
  142. data/spec/support/models/modifier_models.rb +65 -0
  143. metadata +55 -2
@@ -134,4 +134,287 @@ describe Mongoid::Atomic::Modifiers do
134
134
  expect(library.books[2].foreword).not_to be_nil
135
135
  end
136
136
  end
137
+
138
+ context 'when adding to a set on an existing embedded document and pushing a new one' do
139
+ let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
140
+ let(:roster) do
141
+ ModifierModels::Roster.create!(players: [
142
+ ModifierModels::Player.new(badges: [ badges[0] ]),
143
+ ModifierModels::Player.new(badges: [ badges[0] ])
144
+ ])
145
+ end
146
+
147
+ before do
148
+ roster.reload
149
+ roster.players.build
150
+ player = roster.players[0]
151
+ player.badge_ids = player.badge_ids + [ badges[1].id ]
152
+ roster.save!
153
+ roster.reload
154
+ end
155
+
156
+ it 'applies both operations' do
157
+ expect(roster.players.size).to eq(3)
158
+ expect(roster.players[0].badge_ids).to eq([ badges[0].id, badges[1].id ])
159
+ expect(roster.players[1].badge_ids).to eq([ badges[0].id ])
160
+ end
161
+ end
162
+
163
+ context 'when pulling all from an existing embedded document and pushing a new one' do
164
+ let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
165
+ let(:roster) do
166
+ ModifierModels::Roster.create!(players: [
167
+ ModifierModels::Player.new(badges: badges),
168
+ ModifierModels::Player.new
169
+ ])
170
+ end
171
+
172
+ before do
173
+ roster.reload
174
+ roster.players.build
175
+ player = roster.players[0]
176
+ player.badge_ids = player.badge_ids.drop(1)
177
+ roster.save!
178
+ roster.reload
179
+ end
180
+
181
+ it 'applies both operations' do
182
+ expect(roster.players.size).to eq(3)
183
+ expect(roster.players[0].badge_ids).to eq([ badges[1].id ])
184
+ end
185
+ end
186
+
187
+ context 'when unsetting a field on an existing embedded document and pushing a new one' do
188
+ let(:roster) do
189
+ ModifierModels::Roster.create!(players: [
190
+ ModifierModels::Player.new(medal: ModifierModels::Medal.new),
191
+ ModifierModels::Player.new
192
+ ])
193
+ end
194
+
195
+ before do
196
+ roster.reload
197
+ roster.players.build
198
+ roster.players[0].assign_attributes(medal: nil)
199
+ roster.save!
200
+ roster.reload
201
+ end
202
+
203
+ it 'applies both operations' do
204
+ expect(roster.players.size).to eq(3)
205
+ expect(roster.players[0].medal).to be_nil
206
+ end
207
+ end
208
+
209
+ context 'when unsetting an embedded field and pulling a sibling' do
210
+ let(:roster) do
211
+ ModifierModels::Roster.create!(players: [
212
+ ModifierModels::Player.new(medal: ModifierModels::Medal.new),
213
+ ModifierModels::Player.new(medal: ModifierModels::Medal.new)
214
+ ])
215
+ end
216
+
217
+ before do
218
+ roster.reload
219
+ roster.add_atomic_pull(roster.players[0])
220
+ roster.players[1].assign_attributes(medal: nil)
221
+ end
222
+
223
+ it 'fails without changing stored data' do
224
+ # $pull shifts array elements, so these operations cannot be safely
225
+ # applied in separate writes; MongoDB rejects them in a single write.
226
+ expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
227
+
228
+ roster.reload
229
+ expect(roster.players.size).to eq(2)
230
+ expect(roster.players[0].medal).not_to be_nil
231
+ expect(roster.players[1].medal).not_to be_nil
232
+ end
233
+ end
234
+
235
+ context 'when changing foreign keys of a remaining nested document after pulling its sibling' do
236
+ let(:badges) { Array.new(2) { ModifierModels::Badge.create! } }
237
+ let(:roster) do
238
+ ModifierModels::Roster.create!(players: [
239
+ ModifierModels::Player.new(badges: [ badges[0] ]),
240
+ ModifierModels::Player.new(
241
+ badges: [ badges[0] ],
242
+ awards: [
243
+ ModifierModels::Award.new(name: 'first', badges: [ badges[0] ]),
244
+ ModifierModels::Award.new(name: 'second', badges: [ badges[0] ])
245
+ ]
246
+ )
247
+ ])
248
+ end
249
+
250
+ before do
251
+ roster.reload
252
+ first_player, second_player = roster.players.to_a
253
+ first_award, second_award = second_player.awards.to_a
254
+ # The change on the first player comes before the nested $pull on the
255
+ # same root. The change on the second award uses its position after
256
+ # the $pull.
257
+ change.call(first_player)
258
+ roster.players_attributes = [
259
+ { id: second_player.id, awards_attributes: [ { id: first_award.id, _destroy: true } ] }
260
+ ]
261
+ change.call(second_award)
262
+ end
263
+
264
+ context 'when adding to the foreign keys' do
265
+ let(:change) do
266
+ ->(doc) { doc.badge_ids = doc.badge_ids + [ badges[1].id ] }
267
+ end
268
+
269
+ it 'fails without changing stored data' do
270
+ # The $addToSet for the second award would hit the first award if it
271
+ # were applied before the $pull, so it stays in the same update.
272
+ expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
273
+
274
+ roster.reload
275
+ expect(roster.players[0].badge_ids).to eq([ badges[0].id ])
276
+ expect(roster.players[1].awards.map(&:name)).to eq(%w[first second])
277
+ expect(roster.players[1].awards.map(&:badge_ids)).to eq([ [ badges[0].id ], [ badges[0].id ] ])
278
+ end
279
+ end
280
+
281
+ context 'when removing from the foreign keys' do
282
+ let(:change) do
283
+ ->(doc) { doc.badge_ids = [] }
284
+ end
285
+
286
+ it 'applies the change to the remaining nested document' do
287
+ roster.save!
288
+
289
+ roster.reload
290
+ expect(roster.players[0].badge_ids).to eq([])
291
+ expect(roster.players[1].awards.map(&:name)).to eq([ 'second' ])
292
+ expect(roster.players[1].awards[0].badge_ids).to eq([])
293
+ end
294
+ end
295
+ end
296
+
297
+ context 'when renaming a remaining nested document after pulling a preceding sibling and its sibling' do
298
+ let(:roster) do
299
+ ModifierModels::Roster.create!(players: [
300
+ ModifierModels::Player.new(nickname: 'a'),
301
+ ModifierModels::Player.new(nickname: 'b'),
302
+ ModifierModels::Player.new(
303
+ nickname: 'c',
304
+ awards: [
305
+ ModifierModels::Award.new(name: 'first'),
306
+ ModifierModels::Award.new(name: 'second')
307
+ ]
308
+ )
309
+ ])
310
+ end
311
+
312
+ before do
313
+ roster.reload
314
+ first_player, second_player, third_player = roster.players.to_a
315
+ first_award, second_award = third_player.awards.to_a
316
+ roster.players_attributes = [
317
+ { id: first_player.id, _destroy: true },
318
+ { id: second_player.id, nickname: 'B' },
319
+ { id: third_player.id, awards_attributes: [
320
+ { id: first_award.id, _destroy: true },
321
+ { id: second_award.id, name: 'SECOND' }
322
+ ] }
323
+ ]
324
+ end
325
+
326
+ it 'fails without changing stored data' do
327
+ # Both $pulls stay in the main update. The rename of the second award
328
+ # would hit the first award if it were applied before the nested $pull.
329
+ expect { roster.save! }.to raise_error(Mongo::Error::OperationFailure)
330
+
331
+ roster.reload
332
+ expect(roster.players.map(&:nickname)).to eq(%w[a b c])
333
+ expect(roster.players[2].awards.map(&:name)).to eq(%w[first second])
334
+ end
335
+ end
336
+
337
+ context 'when adding a nested document and pulling a sibling' do
338
+ let(:roster) do
339
+ ModifierModels::Roster.create!(players: [
340
+ ModifierModels::Player.new(nickname: 'a'),
341
+ ModifierModels::Player.new(
342
+ nickname: 'b',
343
+ awards: [ ModifierModels::Award.new(name: 'first') ]
344
+ )
345
+ ])
346
+ end
347
+
348
+ before do
349
+ roster.reload
350
+ first_player, second_player = roster.players.to_a
351
+ second_player.awards.build(name: 'second')
352
+ roster.players_attributes = [ { id: first_player.id, _destroy: true } ]
353
+ end
354
+
355
+ it 'applies both operations' do
356
+ roster.save!
357
+
358
+ roster.reload
359
+ expect(roster.players.map(&:nickname)).to eq([ 'b' ])
360
+ expect(roster.players[0].awards.map(&:name)).to eq(%w[first second])
361
+ end
362
+ end
363
+
364
+ context 'when assigning embeds_many attributes on a new embedded document' do
365
+ let(:cabinet) do
366
+ ModifierModels::Cabinet.create!
367
+ end
368
+
369
+ before do
370
+ cabinet.reload
371
+ end
372
+
373
+ context 'when the nested documents are assigned on the built document' do
374
+ before do
375
+ cabinet.build_drawer(matcher: 'all')
376
+ cabinet.drawer.assign_attributes(items: [ { name: 'a' } ])
377
+ end
378
+
379
+ it 'saves without conflicting $set paths' do
380
+ expect { cabinet.save! }.not_to raise_error
381
+
382
+ cabinet.reload
383
+ expect(cabinet.drawer.matcher).to eq('all')
384
+ expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
385
+ end
386
+ end
387
+
388
+ context 'when the nested documents are assigned via the parent' do
389
+ before do
390
+ cabinet.assign_attributes(drawer: { matcher: 'all', items: [ { name: 'a' } ] })
391
+ end
392
+
393
+ it 'saves without conflicting $set paths' do
394
+ expect { cabinet.save! }.not_to raise_error
395
+
396
+ cabinet.reload
397
+ expect(cabinet.drawer.matcher).to eq('all')
398
+ expect(cabinet.drawer.items.map(&:name)).to eq([ 'a' ])
399
+ end
400
+ end
401
+
402
+ context 'when an assigned child has its own embedded collection' do
403
+ let(:item) { ModifierModels::Item.new(name: 'a') }
404
+
405
+ before do
406
+ item.assign_attributes(widgets: [ { label: 'w' } ])
407
+ cabinet.build_drawer(matcher: 'all')
408
+ cabinet.drawer.assign_attributes(items: [ item ])
409
+ end
410
+
411
+ it 'does not write the child collection to the root document' do
412
+ expect { cabinet.save! }.not_to raise_error
413
+
414
+ stored = ModifierModels::Cabinet.collection.find(_id: cabinet.id).first
415
+ expect(stored.keys).to contain_exactly('_id', 'drawer')
416
+ expect(stored['drawer']['items'].first['widgets'].first['label']).to eq('w')
417
+ end
418
+ end
419
+ end
137
420
  end
@@ -253,10 +253,20 @@ describe 'Dots and Dollars' do
253
253
  DADMUser.where('$_amount': 0).first
254
254
  end
255
255
 
256
- it 'raise an error' do
256
+ it 'raises an error' do
257
257
  expect do
258
258
  queried
259
- end.to raise_error(Mongo::Error::OperationFailure)
259
+ end.to raise_error(Mongoid::Errors::InvalidQuery)
260
+ end
261
+
262
+ context 'when allow_unsafe_query_operators is true' do
263
+ config_override :allow_unsafe_query_operators, true
264
+
265
+ it 'raises an error from the server' do
266
+ expect do
267
+ queried
268
+ end.to raise_error(Mongo::Error::OperationFailure)
269
+ end
260
270
  end
261
271
  end
262
272
  end
@@ -33,19 +33,24 @@ describe 'Encryption' do
33
33
  around do |example|
34
34
  Mongoid.default_client[Crypt::Patient.collection_name].drop
35
35
  Mongoid.default_client[Crypt::Car.collection_name].drop
36
+ Mongoid.default_client[Crypt::Folder.collection_name].drop
36
37
  existing_key_id = Crypt::Patient.encrypt_metadata[:key_id]
37
38
  Crypt::Patient.set_key_id(data_key_id)
38
39
  Crypt::Car.set_key_id(data_key_id)
40
+ Crypt::Note.set_key_id(data_key_id)
39
41
  Mongoid::Config.send(:clients=, config)
40
42
  Mongoid::Clients.with_name(:key_vault)[key_vault_collection].drop
41
43
  Crypt::Patient.store_in(client: :encrypted)
42
44
  Crypt::Car.store_in(client: :encrypted, database: Crypt::Car.storage_options[:database])
45
+ Crypt::Folder.store_in(client: :encrypted)
43
46
 
44
47
  example.run
45
48
 
46
49
  Crypt::Patient.reset_storage_options!
50
+ Crypt::Folder.reset_storage_options!
47
51
  Crypt::Patient.set_key_id(existing_key_id)
48
52
  Crypt::Car.set_key_id(existing_key_id)
53
+ Crypt::Note.set_key_id(existing_key_id)
49
54
  end
50
55
 
51
56
  it 'encrypts and decrypts fields' do
@@ -85,6 +90,17 @@ describe 'Encryption' do
85
90
  end
86
91
  end
87
92
 
93
+ # Nothing about the parent document says it needs a schema: it has no
94
+ # encrypted field of its own and no encrypt_with. Its collection still needs
95
+ # one, or the embedded field goes out in the clear.
96
+ it 'stores an embedded field encrypted when the parent has no encrypted field' do
97
+ folder = Crypt::Folder.create!(note: Crypt::Note.new(text: 'SECRET'))
98
+ unencrypted_client[Crypt::Folder.collection.name].find(_id: folder.id).first.tap do |doc|
99
+ expect(doc['note']['text']).to be_a(BSON::Binary)
100
+ expect(doc['note']['text'].type).to eq(:ciphertext)
101
+ end
102
+ end
103
+
88
104
  it 'stores data encrypted in the non-default database' do
89
105
  car = Crypt::Car.create!(vin: 'VA1234')
90
106
  unencrypted_client
@@ -94,4 +110,288 @@ describe 'Encryption' do
94
110
  expect(doc[:vin].type).to eq(:ciphertext)
95
111
  end
96
112
  end
113
+
114
+ # The encryption schema map is keyed by namespace and built once, when the
115
+ # client is constructed. Whenever the namespace an encrypted model actually
116
+ # writes to is not in that map, the driver encrypts nothing and reports no
117
+ # error, so the field lands in the clear. Mongoid fails closed instead.
118
+ describe 'when the target namespace is not in the encryption schema map' do
119
+ let(:scratch_databases) do
120
+ %w[vehicles_dynamic vehicles_tenant_b vehicles_tenant_c tenant_a]
121
+ end
122
+
123
+ around do |example|
124
+ original_options = {
125
+ Crypt::DynamicCar => Crypt::DynamicCar.storage_options,
126
+ Crypt::TenantCar => Crypt::TenantCar.storage_options
127
+ }
128
+ existing_key_ids = original_options.keys.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
129
+ original_options.each_key do |model|
130
+ model.set_key_id(data_key_id)
131
+ model.store_in(client: :encrypted)
132
+ end
133
+ clean_scratch_data
134
+ # The schema map is generated when the client is built, so the client has
135
+ # to be built after the storage options above are in place.
136
+ Mongoid::Clients.clear
137
+
138
+ example.run
139
+
140
+ clean_scratch_data
141
+ original_options.each { |model, options| model.storage_options = options }
142
+ existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
143
+ Mongoid::Clients.clear
144
+ end
145
+
146
+ def clean_scratch_data
147
+ scratch_databases.each { |database| unencrypted_client.use(database).database.drop }
148
+ # Crypt::Car writes into the shared 'vehicles' database, which other
149
+ # examples rely on, so remove only the documents these examples create.
150
+ unencrypted_client
151
+ .use(Crypt::Car.storage_options[:database])[Crypt::Car.collection_name.to_s]
152
+ .delete_many(vin: { '$in' => %w[CLIENT-1 CLIENT-2] })
153
+ end
154
+
155
+ # Reads with a client that has no automatic encryption, to see what really
156
+ # landed on disk.
157
+ def documents_with_plaintext_vin(database, collection, vin)
158
+ unencrypted_client.use(database)[collection].find(vin: vin).to_a
159
+ end
160
+
161
+ context 'when the database name is a callable' do
162
+ it 'does not store the field in plaintext', :aggregate_failures do
163
+ expect { Crypt::DynamicCar.create!(vin: 'DYNAMIC-1') }
164
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
165
+
166
+ expect(
167
+ documents_with_plaintext_vin('vehicles_dynamic', Crypt::DynamicCar.collection_name.to_s, 'DYNAMIC-1')
168
+ ).to be_empty
169
+ end
170
+ end
171
+
172
+ context 'when the database name is a callable resolved per tenant' do
173
+ around do |example|
174
+ Thread.current[:tenant_database] = 'tenant_a'
175
+ example.run
176
+ Thread.current[:tenant_database] = nil
177
+ end
178
+
179
+ it 'does not store the field in plaintext', :aggregate_failures do
180
+ expect { Crypt::TenantCar.create!(vin: 'TENANT-1') }
181
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
182
+
183
+ expect(
184
+ documents_with_plaintext_vin('tenant_a', Crypt::TenantCar.collection_name.to_s, 'TENANT-1')
185
+ ).to be_empty
186
+ end
187
+ end
188
+
189
+ context 'when the database is overridden for the block' do
190
+ it 'does not store the field in plaintext', :aggregate_failures do
191
+ expect { Crypt::Car.with(database: 'vehicles_tenant_b') { |car| car.create!(vin: 'BLOCK-1') } }
192
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
193
+
194
+ expect(
195
+ documents_with_plaintext_vin('vehicles_tenant_b', Crypt::Car.collection_name.to_s, 'BLOCK-1')
196
+ ).to be_empty
197
+ end
198
+ end
199
+
200
+ context 'when the database is overridden globally' do
201
+ persistence_context_override :database, 'vehicles_tenant_c'
202
+
203
+ it 'does not store the field in plaintext', :aggregate_failures do
204
+ expect { Crypt::Car.create!(vin: 'GLOBAL-1') }
205
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
206
+
207
+ expect(
208
+ documents_with_plaintext_vin('vehicles_tenant_c', Crypt::Car.collection_name.to_s, 'GLOBAL-1')
209
+ ).to be_empty
210
+ end
211
+ end
212
+
213
+ context 'when the model is routed to a client without automatic encryption' do
214
+ it 'does not store the field in plaintext', :aggregate_failures do
215
+ expect { Crypt::Car.with(client: :default) { |car| car.create!(vin: 'CLIENT-1') } }
216
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
217
+
218
+ expect(
219
+ documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-1')
220
+ ).to be_empty
221
+ end
222
+ end
223
+
224
+ context 'when the parent has no encrypted field of its own' do
225
+ it 'does not store the embedded field in plaintext', :aggregate_failures do
226
+ expect { Crypt::Folder.with(client: :default) { |folder| folder.create!(note: Crypt::Note.new(text: 'EMBEDDED-1')) } }
227
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
228
+
229
+ expect(
230
+ unencrypted_client[Crypt::Folder.collection_name.to_s].find('note.text' => 'EMBEDDED-1').to_a
231
+ ).to be_empty
232
+ end
233
+ end
234
+
235
+ context 'when the client is overridden globally to one without automatic encryption' do
236
+ persistence_context_override :client, :default
237
+
238
+ it 'does not store the field in plaintext', :aggregate_failures do
239
+ expect { Crypt::Car.create!(vin: 'CLIENT-2') }
240
+ .to raise_error(Mongoid::Errors::NoEncryptionSchema)
241
+
242
+ expect(
243
+ documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-2')
244
+ ).to be_empty
245
+ end
246
+ end
247
+ end
248
+
249
+ # The schema map has one entry per namespace, and it has to cover every model
250
+ # that writes there: models sharing a collection, subclasses, and subclasses
251
+ # of embedded models. Documents are read back through a client without
252
+ # automatic encryption, to see what is actually stored.
253
+ describe 'when more than one model writes to a namespace' do
254
+ let(:root_models) do
255
+ [ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::Listing, Crypt::Box, Crypt::Locker ]
256
+ end
257
+
258
+ let(:keyed_models) do
259
+ [ Crypt::Badge, Crypt::BadgeScan, Crypt::Member, Crypt::PrivateListing, Crypt::Box, Crypt::SecretContent ]
260
+ end
261
+
262
+ around do |example|
263
+ original_options = root_models.to_h { |model| [ model, model.storage_options ] }
264
+ existing_key_ids = keyed_models.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
265
+ keyed_models.each { |model| model.set_key_id(data_key_id) }
266
+ root_models.each do |model|
267
+ unencrypted_client[model.collection_name.to_s].drop
268
+ model.store_in(client: :encrypted)
269
+ end
270
+ # The schema map is generated when the client is built, so the client has
271
+ # to be built after the key ids above are in place.
272
+ Mongoid::Clients.clear
273
+
274
+ example.run
275
+
276
+ original_options.each { |model, options| model.storage_options = options }
277
+ existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
278
+ Mongoid::Clients.clear
279
+ end
280
+
281
+ def stored_document(model, id)
282
+ unencrypted_client[model.collection_name.to_s].find(_id: id).first
283
+ end
284
+
285
+ def expect_ciphertext(value)
286
+ expect(value).to be_a(BSON::Binary).and(have_attributes(type: :ciphertext))
287
+ end
288
+
289
+ context 'when two models are stored in one collection' do
290
+ it 'stores the fields of both models encrypted', :aggregate_failures do
291
+ badge = Crypt::Badge.create!(serial: 'SERIAL-1')
292
+ scan = Crypt::BadgeScan.create!(holder: 'HOLDER-1')
293
+
294
+ expect_ciphertext(stored_document(Crypt::Badge, badge.id)['serial'])
295
+ expect_ciphertext(stored_document(Crypt::BadgeScan, scan.id)['holder'])
296
+ end
297
+ end
298
+
299
+ context 'when a subclass adds an encrypted field to an encrypted parent' do
300
+ it 'stores the fields of the parent and of the subclass encrypted', :aggregate_failures do
301
+ employee = Crypt::Employee.create!(ssn: 'SSN-1', salary: 'SALARY-1')
302
+
303
+ stored_document(Crypt::Member, employee.id).tap do |doc|
304
+ expect_ciphertext(doc['ssn'])
305
+ expect_ciphertext(doc['salary'])
306
+ end
307
+ end
308
+
309
+ it 'stores the fields of a sibling subclass encrypted' do
310
+ contractor = Crypt::Contractor.create!(ssn: 'SSN-2', rate: 'RATE-1')
311
+
312
+ expect_ciphertext(stored_document(Crypt::Member, contractor.id)['rate'])
313
+ end
314
+
315
+ it 'reads the fields back' do
316
+ employee = Crypt::Employee.create!(ssn: 'SSN-3', salary: 'SALARY-3')
317
+
318
+ expect(Crypt::Member.find(employee.id).salary).to eq('SALARY-3')
319
+ end
320
+ end
321
+
322
+ context 'when only a subclass has encrypted fields' do
323
+ it 'stores the fields of the subclass encrypted' do
324
+ listing = Crypt::PrivateListing.create!(title: 'TITLE-1', owner_ssn: 'SSN-4')
325
+
326
+ expect_ciphertext(stored_document(Crypt::Listing, listing.id)['owner_ssn'])
327
+ end
328
+ end
329
+
330
+ context 'when a subclass of an embeds_one target adds an encrypted field' do
331
+ it 'stores the fields of the target and of the subclass encrypted', :aggregate_failures do
332
+ box = Crypt::Box.create!(item: Crypt::SealedItem.new(label: 'LABEL-1', seal: 'SEAL-1'))
333
+
334
+ stored_document(Crypt::Box, box.id)['item'].tap do |item|
335
+ expect_ciphertext(item['label'])
336
+ expect_ciphertext(item['seal'])
337
+ end
338
+ end
339
+ end
340
+
341
+ context 'when only a subclass of an embeds_one target has encrypted fields' do
342
+ it 'stores the fields of the subclass encrypted' do
343
+ locker = Crypt::Locker.create!(content: Crypt::SecretContent.new(label: 'LABEL-2', code: 'CODE-1'))
344
+
345
+ expect_ciphertext(stored_document(Crypt::Locker, locker.id)['content']['code'])
346
+ end
347
+ end
348
+
349
+ # An entry for the namespace exists, but it does not cover every encrypted
350
+ # field of the model. The driver encrypts what the entry lists and writes
351
+ # the rest in plaintext.
352
+ context 'when the schema entry for the namespace misses an encrypted field' do
353
+ let(:config) do
354
+ super().merge(
355
+ partial: {
356
+ hosts: SpecConfig.instance.addresses,
357
+ database: database_id,
358
+ options: {
359
+ auto_encryption_options: {
360
+ key_vault_client: :key_vault,
361
+ kms_providers: kms_providers,
362
+ key_vault_namespace: key_vault_namespace,
363
+ extra_options: extra_options,
364
+ schema_map: partial_schema_map
365
+ }
366
+ }
367
+ }
368
+ )
369
+ end
370
+
371
+ let(:partial_schema_map) do
372
+ {
373
+ "#{database_id}.#{Crypt::Member.collection_name}" => {
374
+ 'bsonType' => 'object',
375
+ 'encryptMetadata' => {
376
+ 'keyId' => [ BSON::Binary.new(Base64.decode64(data_key_id), :uuid) ],
377
+ 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
378
+ },
379
+ 'properties' => {
380
+ 'ssn' => { 'encrypt' => { 'bsonType' => 'string' } }
381
+ }
382
+ }
383
+ }
384
+ end
385
+
386
+ it 'does not store the field in plaintext', :aggregate_failures do
387
+ expect do
388
+ Crypt::Employee.with(client: :partial) { |klass| klass.create!(ssn: 'SSN-5', salary: 'PARTIAL-1') }
389
+ end.to raise_error(Mongoid::Errors::NoEncryptionSchema)
390
+
391
+ expect(
392
+ unencrypted_client[Crypt::Member.collection_name.to_s].find(salary: 'PARTIAL-1').to_a
393
+ ).to be_empty
394
+ end
395
+ end
396
+ end
97
397
  end
@@ -135,6 +135,27 @@
135
135
  $regex: bar
136
136
  matches: false
137
137
 
138
+ # Object#=~ is gone as of Ruby 3.2, so an element that cannot be matched
139
+ # against has to be skipped rather than passed to =~.
140
+ - name: array field value - element that cannot be matched against
141
+ document:
142
+ title:
143
+ - 42
144
+ - foo
145
+ query:
146
+ title:
147
+ $regex: foo
148
+ matches: true
149
+
150
+ - name: array field value - no element can be matched against
151
+ document:
152
+ title:
153
+ - 42
154
+ query:
155
+ title:
156
+ $regex: foo
157
+ matches: false
158
+
138
159
  - name: true field value
139
160
  document:
140
161
  title: true