mongoid 9.0.10 → 9.0.12

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/Rakefile +31 -0
  4. data/lib/config/locales/en.yml +45 -0
  5. data/lib/mongoid/association/depending.rb +8 -4
  6. data/lib/mongoid/association/nested/many.rb +38 -3
  7. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  8. data/lib/mongoid/association/referenced/auto_save.rb +48 -6
  9. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  10. data/lib/mongoid/association/relatable.rb +17 -0
  11. data/lib/mongoid/collection_configurable.rb +8 -0
  12. data/lib/mongoid/config/defaults.rb +9 -5
  13. data/lib/mongoid/config/encryption.rb +36 -18
  14. data/lib/mongoid/config.rb +72 -0
  15. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  16. data/lib/mongoid/contextual/memory.rb +18 -7
  17. data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
  18. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  19. data/lib/mongoid/encryptable.rb +46 -0
  20. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  21. data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
  22. data/lib/mongoid/errors.rb +2 -0
  23. data/lib/mongoid/field_readable.rb +70 -0
  24. data/lib/mongoid/indexable.rb +39 -27
  25. data/lib/mongoid/matchable.rb +6 -1
  26. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
  27. data/lib/mongoid/matcher/regex.rb +9 -13
  28. data/lib/mongoid/matcher/regexp_budget.rb +383 -0
  29. data/lib/mongoid/matcher.rb +1 -0
  30. data/lib/mongoid/persistence_context.rb +35 -0
  31. data/lib/mongoid/search_indexable.rb +14 -7
  32. data/lib/mongoid/tasks/database.rb +23 -9
  33. data/lib/mongoid/threaded.rb +37 -0
  34. data/lib/mongoid/version.rb +1 -1
  35. data/lib/mongoid/warnings.rb +4 -0
  36. data/spec/integration/app_spec.rb +201 -36
  37. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  38. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  39. data/spec/integration/encryption_spec.rb +149 -0
  40. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  41. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  42. data/spec/integration/query_operator_guard_spec.rb +89 -0
  43. data/spec/mongoid/association/auto_save_spec.rb +98 -0
  44. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  45. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  46. data/spec/mongoid/attributes/nested_spec.rb +250 -30
  47. data/spec/mongoid/config/defaults_spec.rb +18 -0
  48. data/spec/mongoid/config/encryption_spec.rb +228 -0
  49. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  50. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  51. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  52. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
  53. data/spec/mongoid/criteria_spec.rb +2 -0
  54. data/spec/mongoid/encryptable_spec.rb +61 -0
  55. data/spec/mongoid/errors/document_not_found_spec.rb +10 -3
  56. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  57. data/spec/mongoid/tasks/database_spec.rb +18 -0
  58. data/spec/support/crypt/models.rb +157 -0
  59. metadata +28 -2
@@ -8,11 +8,38 @@ require_relative '../association/referenced/has_and_belongs_to_many_models'
8
8
  require_relative './nested_spec_models'
9
9
 
10
10
  describe Mongoid::Attributes::Nested do
11
+ # Expects the enclosing context to define `build_with_id`, taking an id
12
+ # value and returning the document built with it in nested attributes.
13
+ shared_examples 'rejects non-scalar ids' do |klass_name|
14
+ not_found = /not found for class #{klass_name}/
15
+
16
+ context 'when the id is an operator hash' do
17
+ it 'raises a document not found error' do
18
+ expect { build_with_id({ '$ne' => nil }) }
19
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
20
+ end
21
+ end
22
+
23
+ context 'when the id is an array' do
24
+ it 'raises a document not found error' do
25
+ expect { build_with_id([ BSON::ObjectId.new ]) }
26
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
27
+ end
28
+ end
29
+
30
+ context 'when the id is a malformed object id hash' do
31
+ it 'raises a document not found error' do
32
+ expect { build_with_id({ '$oid' => 'junk' }) }
33
+ .to raise_error(Mongoid::Errors::DocumentNotFound, not_found)
34
+ end
35
+ end
36
+ end
11
37
 
12
38
  describe ".accepts_nested_attributes_for" do
13
39
 
14
40
  context "when the autosave option is not defined" do
15
41
 
42
+
16
43
  let(:person) do
17
44
  Person.new
18
45
  end
@@ -164,24 +191,71 @@ describe Mongoid::Attributes::Nested do
164
191
  end
165
192
  end
166
193
 
167
- context "when the relation is a references many" do
168
-
194
+ context 'when the relation is a has-many' do
169
195
  before do
170
196
  Person.send(:undef_method, :posts_attributes=)
171
197
  Person.accepts_nested_attributes_for :posts
172
198
  end
173
199
 
174
- let(:person) do
175
- Person.new(posts_attributes: { "1" => { title: "First" }})
200
+ context 'when adding a new document to a relation' do
201
+ let(:person) do
202
+ Person.new(posts_attributes: { '1' => { title: 'First' } })
203
+ end
204
+
205
+ it 'sets the nested attributes' do
206
+ expect(person.posts.first.title).to eq('First')
207
+ end
176
208
  end
177
209
 
178
- it "sets the nested attributes" do
179
- expect(person.posts.first.title).to eq("First")
210
+ context 'when adding an existing document to a relation' do
211
+ let(:person1) { Person.create! }
212
+ let(:post) { person1.posts.create!(title: 'Sample Post') }
213
+
214
+ let(:person2) { Person.create!(posts_attributes: { '0' => { id: post.id, title: 'Reparented!' } }) }
215
+
216
+ context 'when allow_reparenting_via_nested_attributes is false' do
217
+ config_override :allow_reparenting_via_nested_attributes, false
218
+
219
+ it 'raises a document not found error' do
220
+ expect { person2 }.to raise_error(Mongoid::Errors::DocumentNotFound)
221
+ end
222
+ end
223
+
224
+ context 'when allow_reparenting_via_nested_attributes is true' do
225
+ config_override :allow_reparenting_via_nested_attributes, true
226
+
227
+ it 'sets the nested attributes' do
228
+ expect(person2.posts.map(&:title)).to eq([ 'Reparented!' ])
229
+ end
230
+ end
180
231
  end
181
- end
182
232
 
183
- context "when the relation is a references and referenced in many" do
233
+ context 'when the id in the attributes is not a scalar' do
234
+ let(:person) { Person.create! }
235
+
236
+ def build_with_id(id)
237
+ person.update!(posts_attributes: { '0' => { id: id, title: 'Crafted' } })
238
+ end
184
239
 
240
+ context 'when the association is empty' do
241
+ include_examples 'rejects non-scalar ids', 'Post'
242
+ end
243
+
244
+ context 'when the association is not empty' do
245
+ before { person.posts.create!(title: 'Existing') }
246
+
247
+ include_examples 'rejects non-scalar ids', 'Post'
248
+ end
249
+
250
+ context 'when allow_reparenting_via_nested_attributes is true' do
251
+ config_override :allow_reparenting_via_nested_attributes, true
252
+
253
+ include_examples 'rejects non-scalar ids', 'Post'
254
+ end
255
+ end
256
+ end
257
+
258
+ context 'when the relation is a has-and-belongs-to-many' do
185
259
  before do
186
260
  Person.send(:undef_method, :preferences_attributes=)
187
261
  Person.accepts_nested_attributes_for :preferences
@@ -203,16 +277,100 @@ describe Mongoid::Attributes::Nested do
203
277
  )
204
278
  end
205
279
 
206
- it "sets the nested attributes" do
207
- expect(person.preferences.map(&:name)).to eq([preference.name])
280
+ context 'when allow_reparenting_via_nested_attributes is false' do
281
+ config_override :allow_reparenting_via_nested_attributes, false
282
+
283
+ it 'raises a document not found error' do
284
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
285
+ /Document\(s\) not found for class Preference/)
286
+ end
208
287
  end
209
288
 
210
- it "updates attributes of existing document which is added to relation" do
211
- preference_name = 'updated preference'
212
- person = Person.new(
213
- preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
214
- )
215
- expect(person.preferences.map(&:name)).to eq([preference_name])
289
+ context 'when allow_reparenting_via_nested_attributes is true' do
290
+ config_override :allow_reparenting_via_nested_attributes, true
291
+
292
+ it 'sets the nested attributes' do
293
+ expect(person.preferences.map(&:name)).to eq([ preference.name ])
294
+ end
295
+
296
+ it 'updates attributes of existing document which is added to relation' do
297
+ preference_name = 'updated preference'
298
+ person = Person.new(
299
+ preferences_attributes: { 0 => { id: preference.id, name: preference_name } }
300
+ )
301
+ expect(person.preferences.map(&:name)).to eq([ preference_name ])
302
+ end
303
+
304
+ context 'when the id does not correspond to an existing document' do
305
+ let(:person) do
306
+ Person.new(
307
+ preferences_attributes: { 0 => { id: BSON::ObjectId.new, name: 'Ghost' } }
308
+ )
309
+ end
310
+
311
+ context 'when raise_not_found_error is true' do
312
+ config_override :raise_not_found_error, true
313
+
314
+ it 'raises a document not found error' do
315
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
316
+ /Document\(s\) not found for class Preference/)
317
+ end
318
+ end
319
+
320
+ context 'when raise_not_found_error is false' do
321
+ config_override :raise_not_found_error, false
322
+
323
+ it 'raises a document not found error' do
324
+ expect { person }.to raise_error(Mongoid::Errors::DocumentNotFound,
325
+ /Document\(s\) not found for class Preference/)
326
+ end
327
+ end
328
+ end
329
+ end
330
+
331
+ context 'when _destroy is true for a document not in the relation' do
332
+ before do
333
+ Person.send(:undef_method, :preferences_attributes=)
334
+ Person.accepts_nested_attributes_for :preferences, allow_destroy: true
335
+ end
336
+
337
+ let(:person) do
338
+ Person.new(
339
+ preferences_attributes: { 0 => { id: preference.id, _destroy: '1' } }
340
+ )
341
+ end
342
+
343
+ it 'does not raise UnknownAttribute' do
344
+ expect { person }.not_to raise_error
345
+ end
346
+
347
+ it 'does not add the document to the relation' do
348
+ expect(person.preferences).to be_empty
349
+ end
350
+ end
351
+ end
352
+
353
+ context 'when the id in the attributes is not a scalar' do
354
+ let(:target) { Person.create! }
355
+
356
+ def build_with_id(id)
357
+ target.update!(preferences_attributes: { 0 => { id: id, name: 'Crafted' } })
358
+ end
359
+
360
+ context 'when the association is empty' do
361
+ include_examples 'rejects non-scalar ids', 'Preference'
362
+ end
363
+
364
+ context 'when the association is not empty' do
365
+ before { target.preferences.create!(name: 'Existing') }
366
+
367
+ include_examples 'rejects non-scalar ids', 'Preference'
368
+ end
369
+
370
+ context 'when allow_reparenting_via_nested_attributes is true' do
371
+ config_override :allow_reparenting_via_nested_attributes, true
372
+
373
+ include_examples 'rejects non-scalar ids', 'Preference'
216
374
  end
217
375
  end
218
376
  end
@@ -1355,10 +1513,11 @@ describe Mongoid::Attributes::Nested do
1355
1513
  context "when the ids do not match" do
1356
1514
 
1357
1515
  it "raises an error" do
1358
- expect {
1516
+ expect do
1359
1517
  person.addresses_attributes =
1360
- { "foo" => { "id" => "test", "street" => "Test" } }
1361
- }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Address with id\(s\)/)
1518
+ { 'foo' => { 'id' => 'test', 'street' => 'Test' } }
1519
+ end.to raise_error(Mongoid::Errors::DocumentNotFound,
1520
+ /Document\(s\) not found for class Address/)
1362
1521
  end
1363
1522
  end
1364
1523
  end
@@ -3012,12 +3171,12 @@ describe Mongoid::Attributes::Nested do
3012
3171
  end
3013
3172
 
3014
3173
  it "raises a document not found error" do
3015
- expect {
3174
+ expect do
3016
3175
  person.posts_attributes =
3017
- { "0" =>
3018
- { "id" => BSON::ObjectId.new.to_s, "title" => "Rogue" }
3019
- }
3020
- }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Post with id\(s\)/)
3176
+ { '0' =>
3177
+ { 'id' => BSON::ObjectId.new.to_s, 'title' => 'Rogue' } }
3178
+ end.to raise_error(Mongoid::Errors::DocumentNotFound,
3179
+ /Document not found for class Post/)
3021
3180
  end
3022
3181
  end
3023
3182
  end
@@ -3048,10 +3207,11 @@ describe Mongoid::Attributes::Nested do
3048
3207
  context "when the ids do not match" do
3049
3208
 
3050
3209
  it "raises an error" do
3051
- expect {
3210
+ expect do
3052
3211
  person.posts_attributes =
3053
- { "foo" => { "id" => "test", "title" => "Test" } }
3054
- }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Post with id\(s\)/)
3212
+ { 'foo' => { 'id' => 'test', 'title' => 'Test' } }
3213
+ end.to raise_error(Mongoid::Errors::DocumentNotFound,
3214
+ /Document not found for class Post/)
3055
3215
  end
3056
3216
  end
3057
3217
  end
@@ -3765,10 +3925,11 @@ describe Mongoid::Attributes::Nested do
3765
3925
  context "when the ids do not match" do
3766
3926
 
3767
3927
  it "raises an error" do
3768
- expect {
3928
+ expect do
3769
3929
  person.preferences_attributes =
3770
- { "foo" => { "id" => "test", "name" => "Test" } }
3771
- }.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Preference with id\(s\)/)
3930
+ { 'foo' => { 'id' => 'test', 'name' => 'Test' } }
3931
+ end.to raise_error(Mongoid::Errors::DocumentNotFound,
3932
+ /Document\(s\) not found for class Preference/)
3772
3933
  end
3773
3934
  end
3774
3935
  end
@@ -4999,4 +5160,63 @@ describe Mongoid::Attributes::Nested do
4999
5160
  end
5000
5161
  end
5001
5162
  end
5163
+
5164
+ context 'when an id in nested attributes belongs to another parent' do
5165
+ let(:victim) { Person.create! }
5166
+ let(:attacker) { Person.create! }
5167
+
5168
+ context 'when the association is a has-many' do
5169
+ before do
5170
+ Person.send(:undef_method, :posts_attributes=)
5171
+ Person.accepts_nested_attributes_for :posts
5172
+ end
5173
+
5174
+ let!(:victim_post) { victim.posts.create!(title: 'Private') }
5175
+
5176
+ it 'does not update the other parent\'s document' do
5177
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5178
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5179
+ expect(victim_post.reload.title).to eq('Private')
5180
+ end
5181
+
5182
+ it 'does not add the other parent\'s document to the association' do
5183
+ expect { attacker.update!(posts_attributes: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5184
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5185
+ expect(attacker.reload.posts).to be_empty
5186
+ end
5187
+
5188
+ it 'does not update via a direct association assignment' do
5189
+ expect { attacker.update!(posts: { '0' => { id: victim_post.id, title: 'Overwritten' } }) }
5190
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5191
+ expect(victim_post.reload.title).to eq('Private')
5192
+ end
5193
+ end
5194
+
5195
+ context 'when the association is a has-and-belongs-to-many' do
5196
+ before do
5197
+ Person.send(:undef_method, :preferences_attributes=)
5198
+ Person.accepts_nested_attributes_for :preferences
5199
+ end
5200
+
5201
+ let!(:victim_preference) { victim.preferences.create!(name: 'Private') }
5202
+
5203
+ it 'does not update the other parent\'s document' do
5204
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5205
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5206
+ expect(victim_preference.reload.name).to eq('Private')
5207
+ end
5208
+
5209
+ it 'does not add the other parent\'s document to the association' do
5210
+ expect { attacker.update!(preferences_attributes: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5211
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5212
+ expect(attacker.reload.preferences).to be_empty
5213
+ end
5214
+
5215
+ it 'does not update via a direct association assignment' do
5216
+ expect { attacker.update!(preferences: { '0' => { id: victim_preference.id, name: 'Overwritten' } }) }
5217
+ .to raise_error(Mongoid::Errors::DocumentNotFound)
5218
+ expect(victim_preference.reload.name).to eq('Private')
5219
+ end
5220
+ end
5221
+ end
5002
5222
  end
@@ -41,6 +41,18 @@ describe Mongoid::Config::Defaults do
41
41
  end
42
42
  end
43
43
 
44
+ shared_examples 'uses settings for 9.0' do
45
+ it 'uses settings for 9.0' do
46
+ expect(Mongoid.autosave_saves_unchanged_documents).to be true
47
+ end
48
+ end
49
+
50
+ shared_examples 'never allows reparenting via nested attributes' do
51
+ it 'leaves allow_reparenting_via_nested_attributes false' do
52
+ expect(Mongoid.allow_reparenting_via_nested_attributes).to be false
53
+ end
54
+ end
55
+
44
56
  context "when giving a valid version" do
45
57
 
46
58
  before do
@@ -57,6 +69,8 @@ describe Mongoid::Config::Defaults do
57
69
 
58
70
  it_behaves_like "uses settings for 8.0"
59
71
  it_behaves_like "uses settings for 8.1"
72
+ it_behaves_like 'uses settings for 9.0'
73
+ it_behaves_like 'never allows reparenting via nested attributes'
60
74
  end
61
75
 
62
76
  context "when the given version is 8.1" do
@@ -65,6 +79,8 @@ describe Mongoid::Config::Defaults do
65
79
 
66
80
  it_behaves_like "does not use settings for 8.0"
67
81
  it_behaves_like "uses settings for 8.1"
82
+ it_behaves_like 'uses settings for 9.0'
83
+ it_behaves_like 'never allows reparenting via nested attributes'
68
84
  end
69
85
 
70
86
  context "when the given version is 9.0" do
@@ -73,6 +89,8 @@ describe Mongoid::Config::Defaults do
73
89
 
74
90
  it_behaves_like "does not use settings for 8.0"
75
91
  it_behaves_like "does not use settings for 8.1"
92
+ it_behaves_like 'uses settings for 9.0'
93
+ it_behaves_like 'never allows reparenting via nested attributes'
76
94
  end
77
95
  end
78
96
 
@@ -75,6 +75,19 @@ describe Mongoid::Config::Encryption do
75
75
  end
76
76
  end
77
77
 
78
+ # Models are registered in class load order, and Rails eager loads
79
+ # app/models alphabetically, so a child whose file name sorts before
80
+ # its parent's arrives first.
81
+ context 'when the embedded model comes before its parent' do
82
+ let(:models) do
83
+ [ Crypt::Insurance, Crypt::Patient ]
84
+ end
85
+
86
+ it 'returns a map of encryption schemas' do
87
+ expect(encryption_schema_map).to eq(expected_schema_map)
88
+ end
89
+ end
90
+
78
91
  context 'and fields do not have encryption options' do
79
92
  let(:models) do
80
93
  [Crypt::Car]
@@ -109,6 +122,7 @@ describe Mongoid::Config::Encryption do
109
122
  let(:expected_schema_map) do
110
123
  {
111
124
  "mongoid_test.crypt_users" => {
125
+ 'bsonType' => 'object',
112
126
  "properties" => {
113
127
  "name" => {
114
128
  "encrypt" => {
@@ -148,5 +162,219 @@ describe Mongoid::Config::Encryption do
148
162
  expect(encryption_schema_map).to eq({})
149
163
  end
150
164
  end
165
+
166
+ # A field left out of the schema map is written in plaintext, with no
167
+ # exception and nothing logged, so an omission here is silent data
168
+ # exposure rather than a broken feature.
169
+ context 'when the encrypted fields are on an embedded model' do
170
+ let(:token_properties) do
171
+ {
172
+ 'bsonType' => 'object',
173
+ 'properties' => {
174
+ 'value' => {
175
+ 'encrypt' => {
176
+ 'bsonType' => 'string',
177
+ 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
178
+ }
179
+ }
180
+ }
181
+ }
182
+ end
183
+
184
+ context 'when two models embed the same encrypted model' do
185
+ let(:models) do
186
+ [ Crypt::Vault, Crypt::Chest ]
187
+ end
188
+
189
+ it 'maps the embedded model for the first parent' do
190
+ expect(encryption_schema_map.dig('mongoid_test.crypt_vaults', 'properties', 'token'))
191
+ .to eq(token_properties)
192
+ end
193
+
194
+ it 'maps the embedded model for the second parent' do
195
+ expect(encryption_schema_map.dig('mongoid_test.crypt_chests', 'properties', 'token'))
196
+ .to eq(token_properties)
197
+ end
198
+ end
199
+
200
+ context 'when one model embeds the same encrypted model twice' do
201
+ let(:models) do
202
+ [ Crypt::Ledger ]
203
+ end
204
+
205
+ it 'maps the first relation' do
206
+ expect(encryption_schema_map.dig('mongoid_test.crypt_ledgers', 'properties', 'primary_token'))
207
+ .to eq(token_properties)
208
+ end
209
+
210
+ it 'maps the second relation' do
211
+ expect(encryption_schema_map.dig('mongoid_test.crypt_ledgers', 'properties', 'backup_token'))
212
+ .to eq(token_properties)
213
+ end
214
+ end
215
+
216
+ # relation_class constantizes, and a polymorphic embedded_in has no class
217
+ # to resolve, so the walk has to look at the relation type first.
218
+ context 'when the embedded model is embedded polymorphically' do
219
+ let(:models) do
220
+ [ Crypt::Vault ]
221
+ end
222
+
223
+ it 'does not raise' do
224
+ expect { encryption_schema_map }.not_to raise_error
225
+ end
226
+ end
227
+
228
+ context 'when the embedded model comes before its parent' do
229
+ let(:models) do
230
+ [ Crypt::Token, Crypt::Vault ]
231
+ end
232
+
233
+ it 'maps the embedded model' do
234
+ expect(encryption_schema_map.dig('mongoid_test.crypt_vaults', 'properties', 'token'))
235
+ .to eq(token_properties)
236
+ end
237
+ end
238
+
239
+ context 'when the parent has no encrypted field of its own' do
240
+ let(:models) do
241
+ [ Crypt::Wallet, Crypt::Token ]
242
+ end
243
+
244
+ it 'maps the embedded model' do
245
+ expect(encryption_schema_map.dig('mongoid_test.crypt_wallets', 'properties', 'token'))
246
+ .to eq(token_properties)
247
+ end
248
+ end
249
+
250
+ context 'when the encrypted model is nested three levels deep' do
251
+ let(:account_properties) do
252
+ {
253
+ 'bsonType' => 'object',
254
+ 'properties' => {
255
+ 'credential' => {
256
+ 'bsonType' => 'object',
257
+ 'properties' => {
258
+ 'secret' => {
259
+ 'encrypt' => {
260
+ 'bsonType' => 'string',
261
+ 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ end
269
+
270
+ context 'when the models are listed outermost first' do
271
+ let(:models) do
272
+ [ Crypt::Owner, Crypt::Account, Crypt::Credential ]
273
+ end
274
+
275
+ it 'maps the whole subtree' do
276
+ expect(encryption_schema_map.dig('mongoid_test.crypt_owners', 'properties', 'account'))
277
+ .to eq(account_properties)
278
+ end
279
+ end
280
+
281
+ context 'when the middle model comes first' do
282
+ let(:models) do
283
+ [ Crypt::Account, Crypt::Owner, Crypt::Credential ]
284
+ end
285
+
286
+ it 'maps the whole subtree' do
287
+ expect(encryption_schema_map.dig('mongoid_test.crypt_owners', 'properties', 'account'))
288
+ .to eq(account_properties)
289
+ end
290
+ end
291
+ end
292
+
293
+ # The walk has to keep stopping at a model it is already inside of,
294
+ # otherwise a self-embedding model recurses forever.
295
+ context 'when a model embeds itself' do
296
+ let(:models) do
297
+ [ Crypt::Article ]
298
+ end
299
+
300
+ it 'terminates' do
301
+ expect { encryption_schema_map }.not_to raise_error
302
+ end
303
+
304
+ it 'maps the embedded model without descending into the cycle' do
305
+ expect(encryption_schema_map.dig('mongoid_test.crypt_articles', 'properties', 'comment')).to eq(
306
+ 'bsonType' => 'object',
307
+ 'properties' => {
308
+ 'body' => {
309
+ 'encrypt' => {
310
+ 'bsonType' => 'string',
311
+ 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
312
+ }
313
+ }
314
+ }
315
+ )
316
+ end
317
+ end
318
+ end
319
+
320
+ context 'when an encrypted model has a callable database name' do
321
+ let(:models) do
322
+ [ Crypt::DynamicCar ]
323
+ end
324
+
325
+ it 'does not build a key from the unresolved callable' do
326
+ expect(encryption_schema_map.keys.grep(/Proc/)).to be_empty
327
+ end
328
+
329
+ it 'does not build a key that the model can never write to' do
330
+ expect(encryption_schema_map.keys).to all(satisfy { |key| key.count('.') == 1 })
331
+ end
332
+ end
333
+
334
+ context 'when an encrypted model has a callable database name that needs a tenant' do
335
+ let(:models) do
336
+ [ Crypt::TenantCar ]
337
+ end
338
+
339
+ # Resolving the callable while the map is built is not a fix: the
340
+ # documented multi-tenant idiom has no correct value at client
341
+ # construction time. Generating the map must not depend on it.
342
+ it 'does not raise' do
343
+ expect { encryption_schema_map }.not_to raise_error
344
+ end
345
+
346
+ it 'does not pin the map to whichever tenant happens to be current' do
347
+ Thread.current[:tenant_database] = 'tenant_a'
348
+ expect(encryption_schema_map.keys).not_to include('tenant_a.crypt_tenant_cars')
349
+ ensure
350
+ Thread.current[:tenant_database] = nil
351
+ end
352
+ end
353
+
354
+ # The map is generated over every model in the application, and a model may
355
+ # name an embedded class that is never defined. Resolving that name raises,
356
+ # which would take down every client build.
357
+ context 'when a model names an embedded class that is not defined' do
358
+ let(:models) do
359
+ [ Crypt::Drawer, Crypt::Cabinet ]
360
+ end
361
+
362
+ it 'does not raise' do
363
+ expect { encryption_schema_map }.not_to raise_error
364
+ end
365
+
366
+ it 'leaves out the model with nothing to encrypt' do
367
+ expect(encryption_schema_map.keys).not_to include('mongoid_test.crypt_drawers')
368
+ end
369
+
370
+ it 'still maps the encrypted fields of the model itself' do
371
+ expect(encryption_schema_map.dig('mongoid_test.crypt_cabinets', 'properties', 'label')).to eq(
372
+ 'encrypt' => {
373
+ 'bsonType' => 'string',
374
+ 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
375
+ }
376
+ )
377
+ end
378
+ end
151
379
  end
152
380
  end