powerhome-scimitar 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +708 -0
  4. data/Rakefile +16 -0
  5. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +257 -0
  6. data/app/controllers/scimitar/application_controller.rb +157 -0
  7. data/app/controllers/scimitar/resource_types_controller.rb +28 -0
  8. data/app/controllers/scimitar/resources_controller.rb +203 -0
  9. data/app/controllers/scimitar/schemas_controller.rb +21 -0
  10. data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
  11. data/app/models/scimitar/authentication_error.rb +9 -0
  12. data/app/models/scimitar/authentication_scheme.rb +18 -0
  13. data/app/models/scimitar/bulk.rb +8 -0
  14. data/app/models/scimitar/complex_types/address.rb +12 -0
  15. data/app/models/scimitar/complex_types/base.rb +83 -0
  16. data/app/models/scimitar/complex_types/email.rb +12 -0
  17. data/app/models/scimitar/complex_types/entitlement.rb +12 -0
  18. data/app/models/scimitar/complex_types/ims.rb +12 -0
  19. data/app/models/scimitar/complex_types/name.rb +12 -0
  20. data/app/models/scimitar/complex_types/phone_number.rb +12 -0
  21. data/app/models/scimitar/complex_types/photo.rb +12 -0
  22. data/app/models/scimitar/complex_types/reference_group.rb +12 -0
  23. data/app/models/scimitar/complex_types/reference_member.rb +12 -0
  24. data/app/models/scimitar/complex_types/role.rb +12 -0
  25. data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
  26. data/app/models/scimitar/engine_configuration.rb +32 -0
  27. data/app/models/scimitar/error_response.rb +32 -0
  28. data/app/models/scimitar/errors.rb +14 -0
  29. data/app/models/scimitar/filter.rb +11 -0
  30. data/app/models/scimitar/filter_error.rb +22 -0
  31. data/app/models/scimitar/invalid_syntax_error.rb +9 -0
  32. data/app/models/scimitar/lists/count.rb +64 -0
  33. data/app/models/scimitar/lists/query_parser.rb +745 -0
  34. data/app/models/scimitar/meta.rb +7 -0
  35. data/app/models/scimitar/not_found_error.rb +10 -0
  36. data/app/models/scimitar/resource_invalid_error.rb +9 -0
  37. data/app/models/scimitar/resource_type.rb +29 -0
  38. data/app/models/scimitar/resources/base.rb +190 -0
  39. data/app/models/scimitar/resources/group.rb +13 -0
  40. data/app/models/scimitar/resources/mixin.rb +1524 -0
  41. data/app/models/scimitar/resources/user.rb +13 -0
  42. data/app/models/scimitar/schema/address.rb +25 -0
  43. data/app/models/scimitar/schema/attribute.rb +132 -0
  44. data/app/models/scimitar/schema/base.rb +90 -0
  45. data/app/models/scimitar/schema/derived_attributes.rb +24 -0
  46. data/app/models/scimitar/schema/email.rb +10 -0
  47. data/app/models/scimitar/schema/entitlement.rb +10 -0
  48. data/app/models/scimitar/schema/group.rb +27 -0
  49. data/app/models/scimitar/schema/ims.rb +10 -0
  50. data/app/models/scimitar/schema/name.rb +20 -0
  51. data/app/models/scimitar/schema/phone_number.rb +10 -0
  52. data/app/models/scimitar/schema/photo.rb +10 -0
  53. data/app/models/scimitar/schema/reference_group.rb +23 -0
  54. data/app/models/scimitar/schema/reference_member.rb +21 -0
  55. data/app/models/scimitar/schema/role.rb +10 -0
  56. data/app/models/scimitar/schema/user.rb +52 -0
  57. data/app/models/scimitar/schema/vdtp.rb +18 -0
  58. data/app/models/scimitar/schema/x509_certificate.rb +22 -0
  59. data/app/models/scimitar/service_provider_configuration.rb +60 -0
  60. data/app/models/scimitar/supportable.rb +14 -0
  61. data/app/views/layouts/scimitar/application.html.erb +14 -0
  62. data/config/initializers/scimitar.rb +111 -0
  63. data/config/routes.rb +6 -0
  64. data/lib/scimitar/engine.rb +63 -0
  65. data/lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb +216 -0
  66. data/lib/scimitar/support/utilities.rb +51 -0
  67. data/lib/scimitar/version.rb +13 -0
  68. data/lib/scimitar.rb +29 -0
  69. data/spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb +25 -0
  70. data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
  71. data/spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb +25 -0
  72. data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
  73. data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
  74. data/spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb +25 -0
  75. data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
  76. data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
  77. data/spec/apps/dummy/app/models/mock_group.rb +83 -0
  78. data/spec/apps/dummy/app/models/mock_user.rb +132 -0
  79. data/spec/apps/dummy/config/application.rb +18 -0
  80. data/spec/apps/dummy/config/boot.rb +2 -0
  81. data/spec/apps/dummy/config/environment.rb +2 -0
  82. data/spec/apps/dummy/config/environments/test.rb +38 -0
  83. data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
  84. data/spec/apps/dummy/config/initializers/scimitar.rb +61 -0
  85. data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
  86. data/spec/apps/dummy/config/routes.rb +45 -0
  87. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +24 -0
  88. data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
  89. data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +13 -0
  90. data/spec/apps/dummy/db/schema.rb +48 -0
  91. data/spec/controllers/scimitar/application_controller_spec.rb +296 -0
  92. data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
  93. data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
  94. data/spec/controllers/scimitar/schemas_controller_spec.rb +83 -0
  95. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
  96. data/spec/models/scimitar/complex_types/address_spec.rb +18 -0
  97. data/spec/models/scimitar/complex_types/email_spec.rb +21 -0
  98. data/spec/models/scimitar/lists/count_spec.rb +147 -0
  99. data/spec/models/scimitar/lists/query_parser_spec.rb +830 -0
  100. data/spec/models/scimitar/resource_type_spec.rb +21 -0
  101. data/spec/models/scimitar/resources/base_spec.rb +485 -0
  102. data/spec/models/scimitar/resources/base_validation_spec.rb +86 -0
  103. data/spec/models/scimitar/resources/mixin_spec.rb +3562 -0
  104. data/spec/models/scimitar/resources/user_spec.rb +68 -0
  105. data/spec/models/scimitar/schema/attribute_spec.rb +99 -0
  106. data/spec/models/scimitar/schema/base_spec.rb +64 -0
  107. data/spec/models/scimitar/schema/group_spec.rb +87 -0
  108. data/spec/models/scimitar/schema/user_spec.rb +720 -0
  109. data/spec/requests/active_record_backed_resources_controller_spec.rb +1354 -0
  110. data/spec/requests/application_controller_spec.rb +61 -0
  111. data/spec/requests/controller_configuration_spec.rb +17 -0
  112. data/spec/requests/engine_spec.rb +45 -0
  113. data/spec/spec_helper.rb +101 -0
  114. data/spec/spec_helper_spec.rb +30 -0
  115. data/spec/support/hash_with_indifferent_case_insensitive_access_spec.rb +169 -0
  116. metadata +321 -0
@@ -0,0 +1,3562 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Scimitar::Resources::Mixin do
4
+
5
+ # ===========================================================================
6
+ # Internal classes used in some tests
7
+ # ===========================================================================
8
+
9
+ class StaticMapTest
10
+ include ActiveModel::Model
11
+
12
+ attr_accessor :work_email_address,
13
+ :home_email_address
14
+
15
+ def self.scim_resource_type
16
+ return Scimitar::Resources::User
17
+ end
18
+
19
+ def self.scim_attributes_map
20
+ return {
21
+ emails: [
22
+ {
23
+ match: 'type',
24
+ with: 'work',
25
+ using: {
26
+ value: :work_email_address,
27
+ primary: false
28
+ }
29
+ },
30
+ {
31
+ match: 'type',
32
+ with: 'home',
33
+ using: { value: :home_email_address }
34
+ }
35
+ ]
36
+ }
37
+ end
38
+
39
+ def self.scim_mutable_attributes
40
+ return nil
41
+ end
42
+
43
+ def self.scim_queryable_attributes
44
+ return nil
45
+ end
46
+
47
+ include Scimitar::Resources::Mixin
48
+ end
49
+
50
+ class DynamicMapTest
51
+ include ActiveModel::Model
52
+
53
+ attr_accessor :groups
54
+
55
+ def self.scim_resource_type
56
+ return Scimitar::Resources::User
57
+ end
58
+
59
+ def self.scim_attributes_map
60
+ return {
61
+ groups: [
62
+ {
63
+ list: :groups,
64
+ using: {
65
+ value: :id, # <-- i.e. DynamicMapTest.groups[n].id
66
+ display: :full_name # <-- i.e. DynamicMapTest.groups[n].full_name
67
+ }
68
+ }
69
+ ]
70
+ }
71
+ end
72
+
73
+ def self.scim_mutable_attributes
74
+ return nil
75
+ end
76
+
77
+ def self.scim_queryable_attributes
78
+ return nil
79
+ end
80
+
81
+ include Scimitar::Resources::Mixin
82
+ end
83
+
84
+ # A simple schema containing two attributes that looks very like complex
85
+ # type "name", except shorter and with "familyName" never returned.
86
+ #
87
+ NestedReturnedNeverTestNameSchema = Class.new(Scimitar::Schema::Base) do
88
+ def self.id
89
+ 'nested-returned-never-name-id'
90
+ end
91
+
92
+ def self.scim_attributes
93
+ @scim_attributes ||= [
94
+ Scimitar::Schema::Attribute.new(name: 'familyName', type: 'string', required: true, returned: 'never'),
95
+ Scimitar::Schema::Attribute.new(name: 'givenName', type: 'string', required: true)
96
+ ]
97
+ end
98
+ end
99
+
100
+ # A complex type that uses the above schema, giving us the ability to define
101
+ # an attribute using this complex type, with therefore the *nested* attribute
102
+ # "familyName" being never returned.
103
+ #
104
+ NestedReturnedNeverTestNameType = Class.new(Scimitar::ComplexTypes::Base) do
105
+ set_schema NestedReturnedNeverTestNameSchema
106
+ end
107
+
108
+ # A test schema that uses the above type, the standard name type (but that
109
+ # *entire* top-level attribute is never returned) and a simple String item.
110
+ #
111
+ NestedReturnedNeverTestSchema = Class.new(Scimitar::Schema::Base) do
112
+ def self.id
113
+ 'nested-returned-never-id'
114
+ end
115
+
116
+ def self.scim_attributes
117
+ [
118
+ Scimitar::Schema::Attribute.new(
119
+ name: 'name', complexType: NestedReturnedNeverTestNameType
120
+ ),
121
+ Scimitar::Schema::Attribute.new(
122
+ name: 'privateName', complexType: Scimitar::ComplexTypes::Name, returned: 'never'
123
+ ),
124
+ Scimitar::Schema::Attribute.new(
125
+ name: 'simpleName', type: 'string'
126
+ )
127
+ ]
128
+ end
129
+ end
130
+
131
+ # Define a resource that is based upon the above schema.
132
+ #
133
+ NestedReturnedNeverTestResourse = Class.new(Scimitar::Resources::Base) do
134
+ set_schema NestedReturnedNeverTestSchema
135
+ end
136
+
137
+ # Create a testable model that is our internal representation of the above
138
+ # resource.
139
+ #
140
+ class NestedReturnedNeverTest
141
+ include ActiveModel::Model
142
+
143
+ def self.scim_resource_type
144
+ return NestedReturnedNeverTestResourse
145
+ end
146
+
147
+ attr_accessor :given_name,
148
+ :last_name,
149
+ :private_given_name,
150
+ :private_last_name,
151
+ :simple_name
152
+
153
+ def self.scim_attributes_map
154
+ return {
155
+ name: {
156
+ givenName: :given_name,
157
+ familyName: :last_name
158
+ },
159
+
160
+ privateName: {
161
+ givenName: :private_given_name,
162
+ familyName: :private_last_name
163
+ },
164
+
165
+ simpleName: :simple_name
166
+ }
167
+ end
168
+
169
+ def self.scim_mutable_attributes
170
+ return nil
171
+ end
172
+
173
+ def self.scim_queryable_attributes
174
+ return nil
175
+ end
176
+
177
+ include Scimitar::Resources::Mixin
178
+ end
179
+
180
+ # ===========================================================================
181
+ # Errant class definitions
182
+ # ===========================================================================
183
+
184
+ context 'with bad class definitions' do
185
+ it 'complains about missing mandatory methods' do
186
+ mandatory_class_methods = %w{
187
+ scim_resource_type
188
+ scim_attributes_map
189
+ scim_mutable_attributes
190
+ scim_queryable_attributes
191
+ }
192
+
193
+ mandatory_class_methods.each do | required_class_method |
194
+
195
+ # E.g. "You must define ::scim_resource_type in #<Class:...>"
196
+ #
197
+ expect {
198
+ klass = Class.new(BasicObject) do
199
+ fewer_class_methods = mandatory_class_methods - [required_class_method]
200
+ fewer_class_methods.each do | method_to_define |
201
+ define_singleton_method(method_to_define) do
202
+ puts 'I am defined'
203
+ end
204
+ end
205
+
206
+ include Scimitar::Resources::Mixin
207
+ end
208
+ }.to raise_error(RuntimeError, /#{Regexp.escape(required_class_method)}/)
209
+ end
210
+ end
211
+ end # "context 'with bad class definitions' do"
212
+
213
+ # ===========================================================================
214
+ # Correct class definitions
215
+ # ===========================================================================
216
+
217
+ context 'with good class definitons' do
218
+
219
+ require_relative '../../../apps/dummy/app/models/mock_user.rb'
220
+ require_relative '../../../apps/dummy/app/models/mock_group.rb'
221
+
222
+ # =========================================================================
223
+ # Support methods
224
+ # =========================================================================
225
+
226
+ context '#scim_queryable_attributes' do
227
+ it 'exposes queryable attributes as an instance method' do
228
+ instance_result = MockUser.new.scim_queryable_attributes()
229
+ class_result = MockUser.scim_queryable_attributes()
230
+
231
+ expect(instance_result).to match_array(class_result)
232
+ end
233
+ end # "context '#scim_queryable_attributes' do"
234
+
235
+ context '#scim_mutable_attributes' do
236
+ it 'self-compiles mutable attributes and exposes them as an instance method' do
237
+ readwrite_attrs = MockUser::READWRITE_ATTRS.map(&:to_sym)
238
+ readwrite_attrs.delete(:id) # Should never be offered as writable in SCIM
239
+
240
+ result = MockUser.new.scim_mutable_attributes()
241
+ expect(result).to match_array(readwrite_attrs)
242
+ end
243
+
244
+ it 'includes read-write dynamic list attributes' do
245
+ readwrite_attrs = MockGroup::READWRITE_ATTRS.map(&:to_sym)
246
+ readwrite_attrs.delete(:id) # Should never be offered as writable in SCIM
247
+
248
+ result = MockGroup.new.scim_mutable_attributes()
249
+ expect(result).to match_array(readwrite_attrs)
250
+ end
251
+ end # "context '#scim_mutable_attributes' do"
252
+
253
+ # =========================================================================
254
+ # #to_scim
255
+ # =========================================================================
256
+
257
+ context '#to_scim' do
258
+ context 'with list of requested attributes' do
259
+ it 'compiles instance attribute values into a SCIM representation, including only the requested attributes' do
260
+ uuid = SecureRandom.uuid
261
+
262
+ instance = MockUser.new
263
+ instance.primary_key = uuid
264
+ instance.scim_uid = 'AA02984'
265
+ instance.username = 'foo'
266
+ instance.password = 'correcthorsebatterystaple'
267
+ instance.first_name = 'Foo'
268
+ instance.last_name = 'Bar'
269
+ instance.work_email_address = 'foo.bar@test.com'
270
+ instance.home_email_address = nil
271
+ instance.work_phone_number = '+642201234567'
272
+ instance.organization = 'SOMEORG'
273
+
274
+ g1 = MockGroup.create!(display_name: 'Group 1')
275
+ g2 = MockGroup.create!(display_name: 'Group 2')
276
+ g3 = MockGroup.create!(display_name: 'Group 3')
277
+
278
+ g1.mock_users << instance
279
+ g3.mock_users << instance
280
+
281
+ scim = instance.to_scim(location: "https://test.com/mock_users/#{uuid}", include_attributes: %w[id userName name groups.display groups.value organization])
282
+ json = scim.to_json()
283
+ hash = JSON.parse(json)
284
+
285
+ expect(hash).to eql({
286
+ 'id' => uuid,
287
+ 'userName' => 'foo',
288
+ 'name' => {'givenName'=>'Foo', 'familyName'=>'Bar'},
289
+ 'groups' => [{'display'=>g1.display_name, 'value'=>g1.id.to_s}, {'display'=>g3.display_name, 'value'=>g3.id.to_s}],
290
+ 'meta' => {'location'=>"https://test.com/mock_users/#{uuid}", 'resourceType'=>'User'},
291
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'],
292
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
293
+ 'organization' => 'SOMEORG',
294
+ },
295
+ })
296
+ end
297
+ end # "context 'with list of requested attributes' do"
298
+
299
+ context 'with a UUID, renamed primary key column' do
300
+ it 'compiles instance attribute values into a SCIM representation, but omits do-not-return fields' do
301
+ uuid = SecureRandom.uuid
302
+
303
+ instance = MockUser.new
304
+ instance.primary_key = uuid
305
+ instance.scim_uid = 'AA02984'
306
+ instance.username = 'foo'
307
+ instance.password = 'correcthorsebatterystaple'
308
+ instance.first_name = 'Foo'
309
+ instance.last_name = 'Bar'
310
+ instance.work_email_address = 'foo.bar@test.com'
311
+ instance.home_email_address = nil
312
+ instance.work_phone_number = '+642201234567'
313
+ instance.organization = 'SOMEORG'
314
+
315
+ g1 = MockGroup.create!(display_name: 'Group 1')
316
+ g2 = MockGroup.create!(display_name: 'Group 2')
317
+ g3 = MockGroup.create!(display_name: 'Group 3')
318
+
319
+ g1.mock_users << instance
320
+ g3.mock_users << instance
321
+
322
+ scim = instance.to_scim(location: "https://test.com/mock_users/#{uuid}")
323
+ json = scim.to_json()
324
+ hash = JSON.parse(json)
325
+
326
+ expect(hash).to eql({
327
+ 'userName' => 'foo',
328
+ 'name' => {'givenName'=>'Foo', 'familyName'=>'Bar'},
329
+ 'active' => true,
330
+ 'emails' => [{'type'=>'work', 'primary'=>true, 'value'=>'foo.bar@test.com'}, {"primary"=>false, "type"=>"home"}],
331
+ 'phoneNumbers'=> [{'type'=>'work', 'primary'=>false, 'value'=>'+642201234567'}],
332
+ 'id' => uuid,
333
+ 'externalId' => 'AA02984',
334
+ 'groups' => [{'display'=>g1.display_name, 'value'=>g1.id.to_s}, {'display'=>g3.display_name, 'value'=>g3.id.to_s}],
335
+ 'meta' => {'location'=>"https://test.com/mock_users/#{uuid}", 'resourceType'=>'User'},
336
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'],
337
+
338
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
339
+ 'organization' => 'SOMEORG',
340
+ }
341
+ })
342
+ end
343
+ end # "context 'with a UUID, renamed primary key column' do"
344
+
345
+ context 'with an integer, conventionally named primary key column' do
346
+ it 'compiles instance attribute values into a SCIM representation' do
347
+ instance = MockGroup.new
348
+ instance.id = 42
349
+ instance.scim_uid = 'GG02984'
350
+ instance.display_name = 'Some group'
351
+
352
+ scim = instance.to_scim(location: 'https://test.com/mock_groups/42')
353
+ json = scim.to_json()
354
+ hash = JSON.parse(json)
355
+
356
+ expect(hash).to eql({
357
+ 'displayName' => 'Some group',
358
+ 'id' => '42', # Note, String
359
+ 'externalId' => 'GG02984',
360
+ 'members' => [],
361
+ 'meta' => {'location'=>'https://test.com/mock_groups/42', 'resourceType'=>'Group'},
362
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:Group']
363
+ })
364
+ end
365
+ end # "context 'with an integer, conventionally named primary key column' do"
366
+
367
+ context 'with optional timestamps' do
368
+ context 'creation only' do
369
+ class CreationOnlyTest < MockUser
370
+ attr_accessor :created_at
371
+
372
+ def self.scim_timestamps_map
373
+ { created: :created_at }
374
+ end
375
+ end
376
+
377
+ it 'renders the creation date/time' do
378
+ instance = CreationOnlyTest.new
379
+ instance.created_at = Time.now
380
+
381
+ scim = instance.to_scim(location: 'https://test.com/mock_users/42')
382
+ json = scim.to_json()
383
+ hash = JSON.parse(json)
384
+
385
+ expect(hash['meta']).to eql({
386
+ 'created' => instance.created_at.iso8601(0),
387
+ 'location' => 'https://test.com/mock_users/42',
388
+ 'resourceType' => 'User'
389
+ })
390
+ end
391
+ end # "context 'creation only' do"
392
+
393
+ context 'update only' do
394
+ class UpdateOnlyTest < MockUser
395
+ attr_accessor :updated_at
396
+
397
+ def self.scim_timestamps_map
398
+ { lastModified: :updated_at }
399
+ end
400
+ end
401
+
402
+ it 'renders the modification date/time' do
403
+ instance = UpdateOnlyTest.new
404
+ instance.updated_at = Time.now
405
+
406
+ scim = instance.to_scim(location: 'https://test.com/mock_users/42')
407
+ json = scim.to_json()
408
+ hash = JSON.parse(json)
409
+
410
+ expect(hash['meta']).to eql({
411
+ 'lastModified' => instance.updated_at.iso8601(0),
412
+ 'location' => 'https://test.com/mock_users/42',
413
+ 'resourceType' => 'User'
414
+ })
415
+ end
416
+ end # "context 'update only' do"
417
+
418
+ context 'create and update' do
419
+ class CreateAndUpdateTest < MockUser
420
+ attr_accessor :created_at, :updated_at
421
+
422
+ def self.scim_timestamps_map
423
+ {
424
+ created: :created_at,
425
+ lastModified: :updated_at
426
+ }
427
+ end
428
+ end
429
+
430
+ it 'renders the creation and modification date/times' do
431
+ instance = CreateAndUpdateTest.new
432
+ instance.created_at = Time.now - 1.month
433
+ instance.updated_at = Time.now
434
+
435
+ scim = instance.to_scim(location: 'https://test.com/mock_users/42')
436
+ json = scim.to_json()
437
+ hash = JSON.parse(json)
438
+
439
+ expect(hash['meta']).to eql({
440
+ 'created' => instance.created_at.iso8601(0),
441
+ 'lastModified' => instance.updated_at.iso8601(0),
442
+ 'location' => 'https://test.com/mock_users/42',
443
+ 'resourceType' => 'User'
444
+ })
445
+ end
446
+ end # "context 'create and update' do"
447
+ end # "context 'with optional timestamps' do"
448
+
449
+ context 'with arrays' do
450
+ context 'using static mappings' do
451
+ it 'converts to a SCIM representation' do
452
+ instance = StaticMapTest.new(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
453
+ scim = instance.to_scim(location: 'https://test.com/static_map_test')
454
+ json = scim.to_json()
455
+ hash = JSON.parse(json)
456
+
457
+ expect(hash).to eql({
458
+ 'emails' => [
459
+ {'type'=>'work', 'primary'=>false, 'value'=>'work@test.com'},
460
+ {'type'=>'home', 'value'=>'home@test.com'},
461
+ ],
462
+
463
+ 'meta' => {'location'=>'https://test.com/static_map_test', 'resourceType'=>'User'},
464
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'],
465
+
466
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {}
467
+ })
468
+ end
469
+ end # "context 'using static mappings' do"
470
+
471
+ context 'using dynamic lists' do
472
+ it 'converts to a SCIM representation' do
473
+ group = Struct.new(:id, :full_name, keyword_init: true)
474
+ groups = [
475
+ group.new(id: 1, full_name: 'Group 1'),
476
+ group.new(id: 2, full_name: 'Group 2'),
477
+ group.new(id: 3, full_name: 'Group 3'),
478
+ ]
479
+
480
+ instance = DynamicMapTest.new(groups: groups)
481
+ scim = instance.to_scim(location: 'https://test.com/dynamic_map_test')
482
+ json = scim.to_json()
483
+ hash = JSON.parse(json)
484
+
485
+ expect(hash).to eql({
486
+ 'groups' => [
487
+ {'display'=>'Group 1', 'value'=>'1'},
488
+ {'display'=>'Group 2', 'value'=>'2'},
489
+ {'display'=>'Group 3', 'value'=>'3'},
490
+ ],
491
+
492
+ 'meta' => {'location'=>'https://test.com/dynamic_map_test', 'resourceType'=>'User'},
493
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'],
494
+
495
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {}
496
+ })
497
+ end
498
+ end # "context 'using dynamic lists' do"
499
+ end # "context 'with arrays' do"
500
+
501
+ context 'with "returned: \'never\' fields' do
502
+ it 'hides appropriate top-level and nested attributes' do
503
+ instance = NestedReturnedNeverTest.new(
504
+ given_name: 'One',
505
+ last_name: 'Two',
506
+ private_given_name: 'Three',
507
+ private_last_name: 'Four',
508
+ simple_name: 'Five'
509
+ )
510
+
511
+ scim = instance.to_scim(location: 'https://test.com/never_retutrned_test')
512
+ json = scim.to_json()
513
+ hash = JSON.parse(json)
514
+
515
+ expect(hash).to eql({
516
+ 'name' => { 'givenName' => 'One' },
517
+ 'simpleName' => 'Five',
518
+
519
+ 'meta' => {'location'=>'https://test.com/never_retutrned_test', 'resourceType'=>'NestedReturnedNeverTestResourse'},
520
+ 'schemas' => ['nested-returned-never-id']
521
+ })
522
+ end
523
+ end # "context 'with "returned: \'never\' fields' do"
524
+
525
+ context 'with bad definitions' do
526
+ it 'complains about non-Hash entries in mapping Arrays' do
527
+ expect(StaticMapTest).to receive(:scim_attributes_map).and_return({
528
+ emails: [
529
+ 'this is not Hash'
530
+ ]
531
+ })
532
+
533
+ instance = StaticMapTest.new(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
534
+
535
+ expect do
536
+ scim = instance.to_scim(location: 'https://test.com/static_map_test')
537
+ end.to raise_error(RuntimeError) { |e| expect(e.message).to include('Array contains someting other than mapping Hash(es)') }
538
+ end
539
+
540
+ it 'complains about bad Hash entries in mapping Arrays' do
541
+ expect(StaticMapTest).to receive(:scim_attributes_map).and_return({
542
+ emails: [
543
+ {
544
+ this_is_not: :a_valid_entry
545
+ }
546
+ ]
547
+ })
548
+
549
+ instance = StaticMapTest.new(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
550
+
551
+ expect do
552
+ scim = instance.to_scim(location: 'https://test.com/static_map_test')
553
+ end.to raise_error(RuntimeError) { |e| expect(e.message).to include('Mapping Hash inside Array does not contain supported data') }
554
+ end
555
+ end # "context 'with bad definitions' do"
556
+ end # "context '#to_scim' do"
557
+
558
+ # =========================================================================
559
+ # #from_scim!
560
+ # =========================================================================
561
+
562
+ context '#from_scim!' do
563
+ shared_examples 'a creator' do | force_upper_case: |
564
+ context 'which writes instance attribute values from a SCIM representation while' do
565
+ it 'ignoring read-only lists' do
566
+ hash = {
567
+ 'userName' => 'foo',
568
+ 'password' => 'staplebatteryhorsecorrect',
569
+ 'name' => {'givenName' => 'Foo', 'familyName' => 'Bar'},
570
+ 'active' => true,
571
+ 'emails' => [{'type' => 'work', 'primary' => true, 'value' => 'foo.bar@test.com'}],
572
+ 'phoneNumbers' => [{'type' => 'work', 'primary' => false, 'value' => '+642201234567' }],
573
+ 'groups' => [{'type' => 'Group', 'value' => '1'}, {'type' => 'Group', 'value' => '2'}],
574
+ 'id' => '42', # Note, String
575
+ 'externalId' => 'AA02984',
576
+ 'meta' => {'location' => 'https://test.com/mock_users/42', 'resourceType' => 'User'},
577
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'],
578
+
579
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
580
+ 'organization' => 'SOMEORG',
581
+ 'DEPARTMENT' => 'SOMEDPT'
582
+ }
583
+ }
584
+
585
+ hash = spec_helper_hupcase(hash) if force_upper_case
586
+
587
+ instance = MockUser.new
588
+ instance.home_email_address = 'home@test.com' # Should be cleared as no home e-mail specified in SCIM hash above
589
+ instance.from_scim!(scim_hash: hash)
590
+
591
+ expect(instance.scim_uid ).to eql('AA02984')
592
+ expect(instance.username ).to eql('foo')
593
+ expect(instance.password ).to eql('staplebatteryhorsecorrect')
594
+ expect(instance.first_name ).to eql('Foo')
595
+ expect(instance.last_name ).to eql('Bar')
596
+ expect(instance.work_email_address).to eql('foo.bar@test.com')
597
+ expect(instance.home_email_address).to be_nil
598
+ expect(instance.work_phone_number ).to eql('+642201234567')
599
+ expect(instance.organization ).to eql('SOMEORG')
600
+ expect(instance.department ).to eql('SOMEDPT')
601
+ end
602
+
603
+ it 'honouring read-write lists' do
604
+ g1 = MockGroup.create!(display_name: 'Nested group')
605
+
606
+ u1 = MockUser.create!(username: '1', first_name: 'Member 1')
607
+ u2 = MockUser.create!(username: '2', first_name: 'Member 2')
608
+ u3 = MockUser.create!(username: '3', first_name: 'Member 3')
609
+
610
+ hash = {
611
+ 'displayName' => 'Foo Group',
612
+ 'members' => [
613
+ {'type' => 'Group', 'value' => g1.id.to_s},
614
+ {'type' => 'User', 'value' => u1.primary_key.to_s},
615
+ {'type' => 'User', 'value' => u3.primary_key.to_s}
616
+ ],
617
+ 'externalId' => 'GG01536',
618
+ 'meta' => {'location'=>'https://test.com/mock_groups/1', 'resourceType'=>'Group'},
619
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:Group']
620
+ }
621
+
622
+ hash = spec_helper_hupcase(hash) if force_upper_case
623
+
624
+ instance = MockGroup.new
625
+ instance.from_scim!(scim_hash: hash)
626
+
627
+ expect(instance.scim_uid ).to eql('GG01536')
628
+ expect(instance.display_name ).to eql('Foo Group')
629
+ expect(instance.mock_users ).to match_array([u1, u3])
630
+ expect(instance.child_mock_groups).to match_array([g1])
631
+
632
+ instance.save!
633
+ expect(g1.reload.parent_id).to eql(instance.id)
634
+ end
635
+
636
+ it 'handling missing inbound lists' do
637
+ hash = {
638
+ 'displayName' => 'Foo Group'
639
+ }
640
+
641
+ hash = spec_helper_hupcase(hash) if force_upper_case
642
+
643
+ instance = MockGroup.new
644
+ instance.from_scim!(scim_hash: hash)
645
+
646
+ expect(instance.display_name ).to eql('Foo Group')
647
+ expect(instance.mock_users ).to be_empty
648
+ expect(instance.child_mock_groups).to be_empty
649
+ end
650
+ end # "context 'which writes instance attribute values from a SCIM representation while' do"
651
+ end # "shared_examples 'a creator' do | force_upper_case: |"
652
+
653
+ context 'using schema-matched case' do
654
+ it_behaves_like 'a creator', force_upper_case: false
655
+ end # "context 'using schema-matched case' do"
656
+
657
+ context 'using upper case' do
658
+ it_behaves_like 'a creator', force_upper_case: true
659
+ end # "context 'using upper case' do"
660
+
661
+ it 'clears things not present in input' do
662
+ uuid = SecureRandom.uuid
663
+
664
+ instance = MockUser.new
665
+ instance.primary_key = uuid
666
+ instance.scim_uid = 'AA02984'
667
+ instance.username = 'foo'
668
+ instance.first_name = 'Foo'
669
+ instance.last_name = 'Bar'
670
+ instance.work_email_address = 'work@test.com'
671
+ instance.home_email_address = 'home@test.com'
672
+ instance.work_phone_number = '+642201234567'
673
+
674
+ instance.from_scim!(scim_hash: {})
675
+
676
+ expect(instance.primary_key ).to eql(uuid)
677
+ expect(instance.scim_uid ).to be_nil
678
+ expect(instance.username ).to be_nil
679
+ expect(instance.first_name ).to be_nil
680
+ expect(instance.last_name ).to be_nil
681
+ expect(instance.work_email_address).to be_nil
682
+ expect(instance.home_email_address).to be_nil
683
+ expect(instance.work_phone_number ).to be_nil
684
+ end
685
+ end # "context '#from_scim!' do"
686
+
687
+ # =========================================================================
688
+ # #from_scim_patch!
689
+ # =========================================================================
690
+
691
+ context '#from_scim_patch!' do
692
+ before :each do
693
+ @instance = MockUser.new
694
+ end
695
+
696
+ # -------------------------------------------------------------------
697
+ # Internal
698
+ # -------------------------------------------------------------------
699
+ #
700
+ # PATCH is so enormously complex that we do lots of unit tests on private
701
+ # methods before even bothering with the higher level "unit" (more like
702
+ # integration!) tests on #from_scim_patch! itself.
703
+ #
704
+ # These were used during development to debug the implementation.
705
+ #
706
+ context 'internal unit tests' do
707
+
708
+ # ---------------------------------------------------------------------
709
+ # Internal: #extract_filter_from
710
+ # ---------------------------------------------------------------------
711
+ #
712
+ context '#extract_filter_from' do
713
+ it 'handles normal path components' do
714
+ path_component, filter = @instance.send(:extract_filter_from, path_component: 'emails')
715
+
716
+ expect(path_component).to eql('emails')
717
+ expect(filter ).to be_nil
718
+ end
719
+
720
+ it 'handles path components with filter strings' do
721
+ path_component, filter = @instance.send(:extract_filter_from, path_component: 'addresses[type eq "work"]')
722
+
723
+ expect(path_component).to eql('addresses')
724
+ expect(filter ).to eql('type eq "work"')
725
+ end
726
+ end # "context '#extract_filter_from' do"
727
+
728
+ # ---------------------------------------------------------------------
729
+ # Internal: #all_matching_filter
730
+ # ---------------------------------------------------------------------
731
+ #
732
+ context '#all_matching_filter' do
733
+ it 'complains about unsupported operators' do
734
+ expect do
735
+ @instance.send(:all_matching_filter, filter: 'type ne "work"', within_array: []) do
736
+ fail # Block should never be called!
737
+ end
738
+ end.to raise_error(RuntimeError)
739
+ end
740
+
741
+ it 'complaints about unsupported multiple operators' do
742
+ expect do
743
+ @instance.send(:all_matching_filter, filter: 'type eq "work" and primary eq true', within_array: []) do
744
+ fail # Block should never be called!
745
+ end
746
+ end.to raise_error(RuntimeError)
747
+ end
748
+
749
+ it 'complaints about unsupported multiple operators, handling value spaces' do
750
+ expect do
751
+ @instance.send(:all_matching_filter, filter: 'type eq "work with spaces" and primary pr', within_array: []) do
752
+ fail # Block should never be called!
753
+ end
754
+ end.to raise_error(RuntimeError)
755
+ end
756
+
757
+ it 'complaints about unquoted values with spaces' do
758
+ expect do
759
+ @instance.send(:all_matching_filter, filter: 'type eq work with spaces', within_array: []) do
760
+ fail # Block should never be called!
761
+ end
762
+ end.to raise_error(RuntimeError)
763
+ end
764
+
765
+ it 'calls block with matches' do
766
+ array = [
767
+ {
768
+ 'type' => 'work',
769
+ 'value' => 'work_1@test.com'
770
+ },
771
+ {
772
+ 'type' => 'home',
773
+ 'value' => 'home@test.com'
774
+ },
775
+ {
776
+ 'type' => 'work',
777
+ 'value' => 'work_2@test.com'
778
+ }
779
+ ]
780
+
781
+ unhandled = ['work_1@test.com', 'work_2@test.com']
782
+
783
+ @instance.send(:all_matching_filter, filter: 'type eq "work"', within_array: array) do |matched_hash, index|
784
+ expect(array[index]).to eql(matched_hash)
785
+
786
+ expect(matched_hash['type']).to eql('work')
787
+ expect(matched_hash).to have_key('value')
788
+
789
+ unhandled.delete(matched_hash['value'])
790
+ end
791
+
792
+ expect(unhandled).to be_empty
793
+ end
794
+
795
+ it 'handles edge cases' do
796
+ array = [
797
+ {
798
+ 'type' => '"work',
799
+ 'value' => 'work_leading_dquote@test.com'
800
+ },
801
+ {
802
+ 'type' => true,
803
+ 'value' => 'boolean@test.com'
804
+ },
805
+ {
806
+ 'type' => 'work"',
807
+ 'value' => 'work_trailing_dquote@test.com'
808
+ },
809
+ {
810
+ 'type' => 'spaced',
811
+ 'value' => 'value with spaces'
812
+ }
813
+ ]
814
+
815
+ call_count = 0
816
+
817
+ @instance.send(:all_matching_filter, filter: 'type eq "work', within_array: array) do |matched_hash, index|
818
+ call_count += 1
819
+ expect(matched_hash['value']).to eql('work_leading_dquote@test.com')
820
+ end
821
+
822
+ @instance.send(:all_matching_filter, filter: 'type eq work"', within_array: array) do |matched_hash, index|
823
+ call_count += 1
824
+ expect(matched_hash['value']).to eql('work_trailing_dquote@test.com')
825
+ end
826
+
827
+ @instance.send(:all_matching_filter, filter: 'type eq true', within_array: array) do |matched_hash, index|
828
+ call_count += 1
829
+ expect(matched_hash['value']).to eql('boolean@test.com')
830
+ end
831
+
832
+ @instance.send(:all_matching_filter, filter: 'value eq "value with spaces"', within_array: array) do |matched_hash, index|
833
+ call_count += 1
834
+ expect(matched_hash['type']).to eql('spaced')
835
+ end
836
+
837
+ expect(call_count).to eql(4)
838
+ end
839
+ end # "context '#all_matching_filter' do"
840
+
841
+ # ---------------------------------------------------------------------
842
+ # Internal: #from_patch_backend
843
+ # ---------------------------------------------------------------------
844
+ #
845
+ context '#from_patch_backend!' do
846
+
847
+ # -------------------------------------------------------------------
848
+ # Internal: #from_patch_backend - add
849
+ # -------------------------------------------------------------------
850
+ #
851
+ # Except for filter and array behaviour at the leaf of the path,
852
+ # "add" and "replace" are pretty much identical.
853
+ #
854
+ context 'add' do
855
+ context 'when prior value already exists' do
856
+ it 'simple value: overwrites' do
857
+ path = [ 'userName' ]
858
+ scim_hash = { 'userName' => 'bar' }.with_indifferent_case_insensitive_access()
859
+
860
+ @instance.send(
861
+ :from_patch_backend!,
862
+ nature: 'add',
863
+ path: path,
864
+ value: 'foo',
865
+ altering_hash: scim_hash,
866
+ with_attr_map: { userName: :user_name }
867
+ )
868
+
869
+ expect(scim_hash['userName']).to eql('foo')
870
+ end
871
+
872
+ it 'nested simple value: overwrites' do
873
+ path = [ 'name', 'givenName' ]
874
+ scim_hash = { 'name' => { 'givenName' => 'Foo', 'familyName' => 'Bar' } }.with_indifferent_case_insensitive_access()
875
+
876
+ @instance.send(
877
+ :from_patch_backend!,
878
+ nature: 'add',
879
+ path: path,
880
+ value: 'Baz',
881
+ altering_hash: scim_hash,
882
+ with_attr_map: { name: { givenName: :first_name, familyName: :last_name } }
883
+ )
884
+
885
+ expect(scim_hash['name']['givenName' ]).to eql('Baz')
886
+ expect(scim_hash['name']['familyName']).to eql('Bar')
887
+ end
888
+
889
+ it 'with schema extensions: overwrites' do
890
+ path = [ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', 'organization' ]
891
+ scim_hash = { 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => { 'organization' => 'SOMEORG' } }.with_indifferent_case_insensitive_access()
892
+
893
+ @instance.send(
894
+ :from_patch_backend!,
895
+ nature: 'add',
896
+ path: path,
897
+ value: 'OTHERORG',
898
+ altering_hash: scim_hash,
899
+ with_attr_map: { organization: :org_name }
900
+ )
901
+
902
+ expect(scim_hash['urn:ietf:params:scim:schemas:extension:enterprise:2.0:User']['organization' ]).to eql('OTHERORG')
903
+ end
904
+
905
+ # For 'add', filter at end-of-path is nonsensical and not
906
+ # supported by spec or Scimitar; we only test mid-path filters.
907
+ #
908
+ context 'with filter mid-path' do
909
+ it 'by string match: overwrites' do
910
+ path = [ 'emails[type eq "work"]', 'value' ]
911
+ scim_hash = {
912
+ 'emails' => [
913
+ {
914
+ 'type' => 'home',
915
+ 'value' => 'home@test.com'
916
+ },
917
+ {
918
+ 'type' => 'work',
919
+ 'value' => 'work@test.com'
920
+ }
921
+ ]
922
+ }.with_indifferent_case_insensitive_access()
923
+
924
+ @instance.send(
925
+ :from_patch_backend!,
926
+ nature: 'add',
927
+ path: path,
928
+ value: 'added_over_original@test.com',
929
+ altering_hash: scim_hash,
930
+ with_attr_map: {
931
+ emails: [
932
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
933
+ { match: 'type', with: 'work', using: { value: :work_email } },
934
+ ]
935
+ }
936
+ )
937
+
938
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
939
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_original@test.com')
940
+ end
941
+
942
+ it 'by boolean match: overwrites' do
943
+ path = [ 'emails[primary eq true]', 'value' ]
944
+ scim_hash = {
945
+ 'emails' => [
946
+ {
947
+ 'value' => 'home@test.com'
948
+ },
949
+ {
950
+ 'value' => 'work@test.com',
951
+ 'primary' => true
952
+ }
953
+ ]
954
+ }.with_indifferent_case_insensitive_access()
955
+
956
+ @instance.send(
957
+ :from_patch_backend!,
958
+ nature: 'add',
959
+ path: path,
960
+ value: 'added_over_original@test.com',
961
+ altering_hash: scim_hash,
962
+ with_attr_map: {
963
+ emails: [
964
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
965
+ { match: 'type', with: 'work', using: { value: :work_email } },
966
+ ]
967
+ }
968
+ )
969
+
970
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
971
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_original@test.com')
972
+ end
973
+
974
+ it 'multiple matches: overwrites all' do
975
+ path = [ 'emails[type eq "work"]', 'value' ]
976
+ scim_hash = {
977
+ 'emails' => [
978
+ {
979
+ 'type' => 'work',
980
+ 'value' => 'work_1@test.com'
981
+ },
982
+ {
983
+ 'type' => 'work',
984
+ 'value' => 'work_2@test.com'
985
+ }
986
+ ]
987
+ }.with_indifferent_case_insensitive_access()
988
+
989
+ @instance.send(
990
+ :from_patch_backend!,
991
+ nature: 'add',
992
+ path: path,
993
+ value: 'added_over_original@test.com',
994
+ altering_hash: scim_hash,
995
+ with_attr_map: {
996
+ emails: [
997
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
998
+ { match: 'type', with: 'work', using: { value: :work_email } },
999
+ ]
1000
+ }
1001
+ )
1002
+
1003
+ expect(scim_hash['emails'][0]['value']).to eql('added_over_original@test.com')
1004
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_original@test.com')
1005
+ end
1006
+ end # "context 'with filter mid-path' do"
1007
+
1008
+ it 'with arrays: appends' do
1009
+ path = [ 'emails' ]
1010
+ scim_hash = {
1011
+ 'emails' => [
1012
+ {
1013
+ 'type' => 'home',
1014
+ 'value' => 'home@test.com'
1015
+ }
1016
+ ]
1017
+ }.with_indifferent_case_insensitive_access()
1018
+
1019
+ @instance.send(
1020
+ :from_patch_backend!,
1021
+ nature: 'add',
1022
+ path: path,
1023
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
1024
+ altering_hash: scim_hash,
1025
+ with_attr_map: {
1026
+ emails: [
1027
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1028
+ { match: 'type', with: 'work', using: { value: :work_email } },
1029
+ ]
1030
+ }
1031
+ )
1032
+
1033
+ expect(scim_hash['emails'].size).to eql(2)
1034
+ expect(scim_hash['emails'][1]['type' ]).to eql('work')
1035
+ expect(scim_hash['emails'][1]['value']).to eql('work@test.com')
1036
+ end
1037
+
1038
+ context 'with complex value addition' do
1039
+ it 'adds to arrays' do
1040
+ scim_hash = {
1041
+ 'root' => {
1042
+ 'members' => [
1043
+ {'value' => '1'},
1044
+ {'value' => '2'}
1045
+ ]
1046
+ }
1047
+ }.with_indifferent_case_insensitive_access()
1048
+
1049
+ # Example seen at:
1050
+ #
1051
+ # https://docs.databricks.com/dev-tools/api/latest/scim/scim-groups.html
1052
+ #
1053
+ # The core of it is:
1054
+ #
1055
+ # "Operations":[
1056
+ # {
1057
+ # "op":"add",
1058
+ # "value":{
1059
+ # "members":[
1060
+ # {
1061
+ # "value":"<user-id>"
1062
+ # }
1063
+ # ]
1064
+ # }
1065
+ # }
1066
+ # ]
1067
+ #
1068
+ # ...so the path is missing ("root"), but the value is
1069
+ # complex and includes implied paths within. We expect to
1070
+ # have the given value Hash added to the members Array,
1071
+ # rather than having e.g. members replaced by this.
1072
+ #
1073
+ @instance.send(
1074
+ :from_patch_backend!,
1075
+ nature: 'add',
1076
+ path: ['root'],
1077
+ value: {'members' => [{'value' => '3'}]},
1078
+ altering_hash: scim_hash,
1079
+ with_attr_map: {
1080
+ members: [
1081
+ { list: :members, using: { value: :id } }
1082
+ ]
1083
+ }
1084
+ )
1085
+
1086
+ expect(scim_hash['root']['members']).to match_array([{'value' => '1'}, {'value' => '2'}, {'value' => '3'}])
1087
+ end
1088
+ end # "context 'with complex value addition' do"
1089
+ end # context 'when prior value already exists' do
1090
+
1091
+ context 'when value is not present' do
1092
+ it 'simple value: adds' do
1093
+ path = [ 'userName' ]
1094
+ scim_hash = {}.with_indifferent_case_insensitive_access()
1095
+
1096
+ @instance.send(
1097
+ :from_patch_backend!,
1098
+ nature: 'add',
1099
+ path: path,
1100
+ value: 'foo',
1101
+ altering_hash: scim_hash,
1102
+ with_attr_map: { userName: :user_name }
1103
+ )
1104
+
1105
+ expect(scim_hash['userName']).to eql('foo')
1106
+ end
1107
+
1108
+ it 'nested simple value: adds' do
1109
+ path = [ 'name', 'givenName' ]
1110
+ scim_hash = {}.with_indifferent_case_insensitive_access()
1111
+
1112
+ @instance.send(
1113
+ :from_patch_backend!,
1114
+ nature: 'add',
1115
+ path: path,
1116
+ value: 'Baz',
1117
+ altering_hash: scim_hash,
1118
+ with_attr_map: { name: { givenName: :first_name, familyName: :last_name } }
1119
+ )
1120
+
1121
+ expect(scim_hash['name']['givenName']).to eql('Baz')
1122
+ end
1123
+
1124
+ it 'with schema extensions: adds' do
1125
+ path = [ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', 'organization' ]
1126
+ scim_hash = {}.with_indifferent_case_insensitive_access()
1127
+
1128
+ @instance.send(
1129
+ :from_patch_backend!,
1130
+ nature: 'add',
1131
+ path: path,
1132
+ value: 'SOMEORG',
1133
+ altering_hash: scim_hash,
1134
+ with_attr_map: { organization: :org_name }
1135
+ )
1136
+
1137
+ expect(scim_hash['urn:ietf:params:scim:schemas:extension:enterprise:2.0:User']['organization' ]).to eql('SOMEORG')
1138
+ end
1139
+
1140
+ context 'with filter mid-path: adds' do
1141
+ it 'by string match' do
1142
+ path = [ 'emails[type eq "work"]', 'value' ]
1143
+ scim_hash = {
1144
+ 'emails' => [
1145
+ {
1146
+ 'type' => 'home',
1147
+ 'value' => 'home@test.com'
1148
+ },
1149
+ {
1150
+ 'type' => 'work'
1151
+ }
1152
+ ]
1153
+ }.with_indifferent_case_insensitive_access()
1154
+
1155
+ @instance.send(
1156
+ :from_patch_backend!,
1157
+ nature: 'add',
1158
+ path: path,
1159
+ value: 'added@test.com',
1160
+ altering_hash: scim_hash,
1161
+ with_attr_map: {
1162
+ emails: [
1163
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1164
+ { match: 'type', with: 'work', using: { value: :work_email } },
1165
+ ]
1166
+ }
1167
+ )
1168
+
1169
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1170
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
1171
+ end
1172
+
1173
+ it 'by boolean match: adds' do
1174
+ path = [ 'emails[primary eq true]', 'value' ]
1175
+ scim_hash = {
1176
+ 'emails' => [
1177
+ {
1178
+ 'value' => 'home@test.com'
1179
+ },
1180
+ {
1181
+ 'primary' => true
1182
+ }
1183
+ ]
1184
+ }.with_indifferent_case_insensitive_access()
1185
+
1186
+ @instance.send(
1187
+ :from_patch_backend!,
1188
+ nature: 'add',
1189
+ path: path,
1190
+ value: 'added@test.com',
1191
+ altering_hash: scim_hash,
1192
+ with_attr_map: {
1193
+ emails: [
1194
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1195
+ { match: 'type', with: 'work', using: { value: :work_email } },
1196
+ ]
1197
+ }
1198
+ )
1199
+
1200
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1201
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
1202
+ end
1203
+
1204
+ it 'with no match: still adds' do
1205
+ path = [ 'emails[type eq "work"]', 'value' ]
1206
+ scim_hash = {}.with_indifferent_case_insensitive_access()
1207
+
1208
+ @instance.send(
1209
+ :from_patch_backend!,
1210
+ nature: 'add',
1211
+ path: path,
1212
+ value: 'added@test.com',
1213
+ altering_hash: scim_hash,
1214
+ with_attr_map: {
1215
+ emails: [
1216
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1217
+ { match: 'type', with: 'work', using: { value: :work_email } },
1218
+ ]
1219
+ }
1220
+ )
1221
+
1222
+ expect(scim_hash['emails'][0]['value']).to eql('added@test.com')
1223
+ end
1224
+
1225
+ it 'multiple matches: adds to all' do
1226
+ path = [ 'emails[type eq "work"]', 'value' ]
1227
+ scim_hash = {
1228
+ 'emails' => [
1229
+ {
1230
+ 'type' => 'work'
1231
+ },
1232
+ {
1233
+ 'type' => 'work'
1234
+ }
1235
+ ]
1236
+ }.with_indifferent_case_insensitive_access()
1237
+
1238
+ @instance.send(
1239
+ :from_patch_backend!,
1240
+ nature: 'add',
1241
+ path: path,
1242
+ value: 'added@test.com',
1243
+ altering_hash: scim_hash,
1244
+ with_attr_map: {
1245
+ emails: [
1246
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1247
+ { match: 'type', with: 'work', using: { value: :work_email } },
1248
+ ]
1249
+ }
1250
+ )
1251
+
1252
+ expect(scim_hash['emails'][0]['value']).to eql('added@test.com')
1253
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
1254
+ end
1255
+ end # "context 'with filter mid-path' do"
1256
+
1257
+ it 'with arrays: appends' do
1258
+ path = [ 'emails' ]
1259
+ scim_hash = {}.with_indifferent_case_insensitive_access()
1260
+
1261
+ @instance.send(
1262
+ :from_patch_backend!,
1263
+ nature: 'add',
1264
+ path: path,
1265
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
1266
+ altering_hash: scim_hash,
1267
+ with_attr_map: {
1268
+ emails: [
1269
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1270
+ { match: 'type', with: 'work', using: { value: :work_email } },
1271
+ ]
1272
+ }
1273
+ )
1274
+
1275
+ expect(scim_hash['emails'].size).to eql(1)
1276
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
1277
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
1278
+ end
1279
+ end # context 'when value is not present' do
1280
+ end # "context 'add' do"
1281
+
1282
+ # -------------------------------------------------------------------
1283
+ # Internal: #from_patch_backend - remove
1284
+ # -------------------------------------------------------------------
1285
+ #
1286
+ context 'remove' do
1287
+ context 'when prior value already exists' do
1288
+ it 'simple value: clears to "nil" in order to remove' do
1289
+ path = [ 'userName' ]
1290
+ scim_hash = { 'userName' => 'bar' }.with_indifferent_case_insensitive_access()
1291
+
1292
+ @instance.send(
1293
+ :from_patch_backend!,
1294
+ nature: 'remove',
1295
+ path: path,
1296
+ value: nil,
1297
+ altering_hash: scim_hash,
1298
+ with_attr_map: { userName: :user_name }
1299
+ )
1300
+
1301
+ expect(scim_hash).to eql({ 'userName' => nil })
1302
+ end
1303
+
1304
+ it 'nested simple value: clears to "nil" in order to remove' do
1305
+ path = [ 'name', 'givenName' ]
1306
+ scim_hash = { 'name' => { 'givenName' => 'Foo', 'familyName' => 'Bar' } }.with_indifferent_case_insensitive_access()
1307
+
1308
+ @instance.send(
1309
+ :from_patch_backend!,
1310
+ nature: 'remove',
1311
+ path: path,
1312
+ value: nil,
1313
+ altering_hash: scim_hash,
1314
+ with_attr_map: { name: { givenName: :first_name, familyName: :last_name } }
1315
+ )
1316
+
1317
+ expect(scim_hash).to eql({ 'name' => { 'givenName' => nil, 'familyName' => 'Bar' } })
1318
+ end
1319
+
1320
+ context 'with filter mid-path' do
1321
+ it 'by string match: clears to "nil" in order to remove' do
1322
+ path = [ 'emails[type eq "work"]', 'value' ]
1323
+ scim_hash = {
1324
+ 'emails' => [
1325
+ {
1326
+ 'type' => 'home',
1327
+ 'value' => 'home@test.com'
1328
+ },
1329
+ {
1330
+ 'type' => 'work',
1331
+ 'value' => 'work@test.com'
1332
+ }
1333
+ ]
1334
+ }.with_indifferent_case_insensitive_access()
1335
+
1336
+ @instance.send(
1337
+ :from_patch_backend!,
1338
+ nature: 'remove',
1339
+ path: path,
1340
+ value: nil,
1341
+ altering_hash: scim_hash,
1342
+ with_attr_map: {
1343
+ emails: [
1344
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1345
+ { match: 'type', with: 'work', using: { value: :work_email } },
1346
+ ]
1347
+ }
1348
+ )
1349
+
1350
+ expect(scim_hash).to eql({
1351
+ 'emails' => [
1352
+ {
1353
+ 'type' => 'home',
1354
+ 'value' => 'home@test.com'
1355
+ },
1356
+ {
1357
+ 'type' => 'work',
1358
+ 'value' => nil
1359
+ }
1360
+ ]
1361
+ })
1362
+ end
1363
+
1364
+ it 'by boolean match: clears to "nil" in order to remove' do
1365
+ path = [ 'emails[primary eq true]', 'value' ]
1366
+ scim_hash = {
1367
+ 'emails' => [
1368
+ {
1369
+ 'value' => 'home@test.com'
1370
+ },
1371
+ {
1372
+ 'value' => 'work@test.com',
1373
+ 'primary' => true
1374
+ }
1375
+ ]
1376
+ }.with_indifferent_case_insensitive_access()
1377
+
1378
+ @instance.send(
1379
+ :from_patch_backend!,
1380
+ nature: 'remove',
1381
+ path: path,
1382
+ value: nil,
1383
+ altering_hash: scim_hash,
1384
+ with_attr_map: {
1385
+ emails: [
1386
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1387
+ { match: 'type', with: 'work', using: { value: :work_email } },
1388
+ ]
1389
+ }
1390
+ )
1391
+
1392
+ expect(scim_hash).to eql({
1393
+ 'emails' => [
1394
+ {
1395
+ 'value' => 'home@test.com'
1396
+ },
1397
+ {
1398
+ 'value' => nil,
1399
+ 'primary' => true
1400
+ }
1401
+ ]
1402
+ })
1403
+ end
1404
+
1405
+ it 'multiple matches: clears all to "nil" in order to remove' do
1406
+ path = [ 'emails[type eq "work"]', 'value' ]
1407
+ scim_hash = {
1408
+ 'emails' => [
1409
+ {
1410
+ 'type' => 'work',
1411
+ 'value' => 'work_1@test.com'
1412
+ },
1413
+ {
1414
+ 'type' => 'work',
1415
+ 'value' => 'work_2@test.com'
1416
+ },
1417
+ {
1418
+ 'type' => 'home',
1419
+ 'value' => 'home@test.com'
1420
+ },
1421
+ ]
1422
+ }.with_indifferent_case_insensitive_access()
1423
+
1424
+ @instance.send(
1425
+ :from_patch_backend!,
1426
+ nature: 'remove',
1427
+ path: path,
1428
+ value: nil,
1429
+ altering_hash: scim_hash,
1430
+ with_attr_map: {
1431
+ emails: [
1432
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1433
+ { match: 'type', with: 'work', using: { value: :work_email } },
1434
+ ]
1435
+ }
1436
+ )
1437
+
1438
+ expect(scim_hash).to eql({
1439
+ 'emails' => [
1440
+ {
1441
+ 'type' => 'work',
1442
+ 'value' => nil
1443
+ },
1444
+ {
1445
+ 'type' => 'work',
1446
+ 'value' => nil
1447
+ },
1448
+ {
1449
+ 'type' => 'home',
1450
+ 'value' => 'home@test.com'
1451
+ },
1452
+ ]
1453
+ })
1454
+ end
1455
+ end # "context 'with filter mid-path' do"
1456
+
1457
+ context 'with filter at end of path' do
1458
+ it 'by string match: clears to "nil" in order to remove' do
1459
+ path = [ 'emails[type eq "work"]' ]
1460
+ scim_hash = {
1461
+ 'emails' => [
1462
+ {
1463
+ 'type' => 'home',
1464
+ 'value' => 'home@test.com'
1465
+ },
1466
+ {
1467
+ 'type' => 'work',
1468
+ 'value' => 'work@test.com'
1469
+ }
1470
+ ]
1471
+ }.with_indifferent_case_insensitive_access()
1472
+
1473
+ @instance.send(
1474
+ :from_patch_backend!,
1475
+ nature: 'remove',
1476
+ path: path,
1477
+ value: nil,
1478
+ altering_hash: scim_hash,
1479
+ with_attr_map: {
1480
+ emails: [
1481
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1482
+ { match: 'type', with: 'work', using: { value: :work_email } },
1483
+ ]
1484
+ }
1485
+ )
1486
+
1487
+ expect(scim_hash).to eql({
1488
+ 'emails' => [
1489
+ {
1490
+ 'type' => 'home',
1491
+ 'value' => 'home@test.com'
1492
+ },
1493
+ {
1494
+ 'type' => 'work',
1495
+ 'value' => nil
1496
+ }
1497
+ ]
1498
+ })
1499
+ end
1500
+
1501
+ it 'by boolean match: clears to "nil" in order to remove' do
1502
+ path = [ 'emails[primary eq true]' ]
1503
+ scim_hash = {
1504
+ 'emails' => [
1505
+ {
1506
+ 'value' => 'home@test.com',
1507
+ 'primary' => true
1508
+ },
1509
+ {
1510
+ 'value' => 'work@test.com'
1511
+ }
1512
+ ]
1513
+ }.with_indifferent_case_insensitive_access()
1514
+
1515
+ @instance.send(
1516
+ :from_patch_backend!,
1517
+ nature: 'remove',
1518
+ path: path,
1519
+ value: nil,
1520
+ altering_hash: scim_hash,
1521
+ with_attr_map: {
1522
+ emails: [
1523
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1524
+ { match: 'type', with: 'work', using: { value: :work_email } },
1525
+ ]
1526
+ }
1527
+ )
1528
+
1529
+ expect(scim_hash).to eql({
1530
+ 'emails' => [
1531
+ {
1532
+ 'value' => nil,
1533
+ 'primary' => true
1534
+ },
1535
+ {
1536
+ 'value' => 'work@test.com'
1537
+ }
1538
+ ]
1539
+ })
1540
+ end
1541
+
1542
+ it 'multiple matches: clears all to "nil" in order to remove' do
1543
+ path = [ 'emails[type eq "work"]' ]
1544
+ scim_hash = {
1545
+ 'emails' => [
1546
+ {
1547
+ 'type' => 'work',
1548
+ 'value' => 'work_1@test.com'
1549
+ },
1550
+ {
1551
+ 'type' => 'work',
1552
+ 'value' => 'work_2@test.com'
1553
+ },
1554
+ {
1555
+ 'type' => 'home',
1556
+ 'value' => 'home@test.com'
1557
+ },
1558
+ ]
1559
+ }.with_indifferent_case_insensitive_access()
1560
+
1561
+ @instance.send(
1562
+ :from_patch_backend!,
1563
+ nature: 'remove',
1564
+ path: path,
1565
+ value: nil,
1566
+ altering_hash: scim_hash,
1567
+ with_attr_map: {
1568
+ emails: [
1569
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1570
+ { match: 'type', with: 'work', using: { value: :work_email } },
1571
+ ]
1572
+ }
1573
+ )
1574
+
1575
+ expect(scim_hash).to eql({
1576
+ 'emails' => [
1577
+ {
1578
+ 'type' => 'work',
1579
+ 'value' => nil
1580
+ },
1581
+ {
1582
+ 'type' => 'work',
1583
+ 'value' => nil
1584
+ },
1585
+ {
1586
+ 'type' => 'home',
1587
+ 'value' => 'home@test.com'
1588
+ },
1589
+ ]
1590
+ })
1591
+ end
1592
+ end # "context 'with filter at end of path' do"
1593
+
1594
+ it 'whole array: clears mapped values to "nil" to remove them' do
1595
+ path = [ 'emails' ]
1596
+ scim_hash = {
1597
+ 'emails' => [
1598
+ {
1599
+ 'type' => 'home',
1600
+ 'value' => 'home@test.com'
1601
+ },
1602
+ {
1603
+ 'type' => 'work',
1604
+ 'value' => 'work@test.com'
1605
+ }
1606
+ ]
1607
+ }.with_indifferent_case_insensitive_access()
1608
+
1609
+ @instance.send(
1610
+ :from_patch_backend!,
1611
+ nature: 'remove',
1612
+ path: path,
1613
+ value: nil,
1614
+ altering_hash: scim_hash,
1615
+ with_attr_map: {
1616
+ emails: [
1617
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
1618
+ { match: 'type', with: 'work', using: { value: :work_email } },
1619
+ ]
1620
+ }
1621
+ )
1622
+
1623
+ expect(scim_hash).to eql({
1624
+ 'emails' => [
1625
+ {
1626
+ 'type' => 'home',
1627
+ 'value' => nil
1628
+ },
1629
+ {
1630
+ 'type' => 'work',
1631
+ 'value' => nil
1632
+ }
1633
+ ]
1634
+ })
1635
+ end
1636
+
1637
+ # What we expect:
1638
+ #
1639
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2.2
1640
+ # https://docs.snowflake.com/en/user-guide/scim-intro.html#patch-scim-v2-groups-id
1641
+ #
1642
+ # ...vs accounting for the unusual payloads we sometimes get,
1643
+ # tested here.
1644
+ #
1645
+ context 'special cases' do
1646
+
1647
+ # https://learn.microsoft.com/en-us/azure/active-directory/app-provisioning/use-scim-to-provision-users-and-groups#update-group-remove-members
1648
+ #
1649
+ context 'Microsoft-style payload' do
1650
+ context 'removing a user from a group' do
1651
+ it 'removes identified user' do
1652
+ path = [ 'members' ]
1653
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ]
1654
+ scim_hash = {
1655
+ 'displayname' => 'Mock group',
1656
+ 'members' => [
1657
+ {
1658
+ 'value' => '50ca93d04ab0c2de4772',
1659
+ 'display' => 'Ingrid Smith',
1660
+ 'type' => 'User'
1661
+ },
1662
+ {
1663
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1664
+ 'display' => 'Fred Smith',
1665
+ 'type' => 'User'
1666
+ },
1667
+ {
1668
+ 'value' => 'a774d480e8112101375b',
1669
+ 'display' => 'Taylor Smith',
1670
+ 'type' => 'User'
1671
+ }
1672
+ ]
1673
+ }.with_indifferent_case_insensitive_access()
1674
+
1675
+ @instance.send(
1676
+ :from_patch_backend!,
1677
+ nature: 'remove',
1678
+ path: path,
1679
+ value: value,
1680
+ altering_hash: scim_hash,
1681
+ with_attr_map: {
1682
+ members: [
1683
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1684
+ ]
1685
+ }
1686
+ )
1687
+
1688
+ expect(scim_hash).to eql({
1689
+ 'displayname' => 'Mock group',
1690
+ 'members' => [
1691
+ {
1692
+ 'value' => '50ca93d04ab0c2de4772',
1693
+ 'display' => 'Ingrid Smith',
1694
+ 'type' => 'User'
1695
+ },
1696
+ {
1697
+ 'value' => 'a774d480e8112101375b',
1698
+ 'display' => 'Taylor Smith',
1699
+ 'type' => 'User'
1700
+ }
1701
+ ]
1702
+ })
1703
+ end
1704
+
1705
+ it 'removes multiple identified users' do
1706
+ path = [ 'members' ]
1707
+ value = [
1708
+ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' },
1709
+ { '$ref' => nil, 'value' => '50ca93d04ab0c2de4772' }
1710
+ ]
1711
+ scim_hash = {
1712
+ 'displayname' => 'Mock group',
1713
+ 'members' => [
1714
+ {
1715
+ 'value' => '50ca93d04ab0c2de4772',
1716
+ 'display' => 'Ingrid Smith',
1717
+ 'type' => 'User'
1718
+ },
1719
+ {
1720
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1721
+ 'display' => 'Fred Smith',
1722
+ 'type' => 'User'
1723
+ },
1724
+ {
1725
+ 'value' => 'a774d480e8112101375b',
1726
+ 'display' => 'Taylor Smith',
1727
+ 'type' => 'User'
1728
+ }
1729
+ ]
1730
+ }.with_indifferent_case_insensitive_access()
1731
+
1732
+ @instance.send(
1733
+ :from_patch_backend!,
1734
+ nature: 'remove',
1735
+ path: path,
1736
+ value: value,
1737
+ altering_hash: scim_hash,
1738
+ with_attr_map: {
1739
+ members: [
1740
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1741
+ ]
1742
+ }
1743
+ )
1744
+
1745
+ expect(scim_hash).to eql({
1746
+ 'displayname' => 'Mock group',
1747
+ 'members' => [
1748
+ {
1749
+ 'value' => 'a774d480e8112101375b',
1750
+ 'display' => 'Taylor Smith',
1751
+ 'type' => 'User'
1752
+ }
1753
+ ]
1754
+ })
1755
+ end
1756
+
1757
+ it 'removes all users individually without error' do
1758
+ path = [ 'members' ]
1759
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ]
1760
+ scim_hash = {
1761
+ 'displayname' => 'Mock group',
1762
+ 'members' => [
1763
+ {
1764
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1765
+ 'display' => 'Fred Smith',
1766
+ 'type' => 'User'
1767
+ }
1768
+ ]
1769
+ }.with_indifferent_case_insensitive_access()
1770
+
1771
+ @instance.send(
1772
+ :from_patch_backend!,
1773
+ nature: 'remove',
1774
+ path: path,
1775
+ value: value,
1776
+ altering_hash: scim_hash,
1777
+ with_attr_map: {
1778
+ members: [
1779
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1780
+ ]
1781
+ }
1782
+ )
1783
+
1784
+ expect(scim_hash).to eql({
1785
+ 'displayname' => 'Mock group',
1786
+ 'members' => []
1787
+ })
1788
+ end
1789
+
1790
+ it 'can match on multiple attributes' do
1791
+ path = [ 'members' ]
1792
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c', 'type' => 'User' } ]
1793
+ scim_hash = {
1794
+ 'displayname' => 'Mock group',
1795
+ 'members' => [
1796
+ {
1797
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1798
+ 'display' => 'Fred Smith',
1799
+ 'type' => 'User'
1800
+ }
1801
+ ]
1802
+ }.with_indifferent_case_insensitive_access()
1803
+
1804
+ @instance.send(
1805
+ :from_patch_backend!,
1806
+ nature: 'remove',
1807
+ path: path,
1808
+ value: value,
1809
+ altering_hash: scim_hash,
1810
+ with_attr_map: {
1811
+ members: [
1812
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1813
+ ]
1814
+ }
1815
+ )
1816
+
1817
+ expect(scim_hash).to eql({
1818
+ 'displayname' => 'Mock group',
1819
+ 'members' => []
1820
+ })
1821
+ end
1822
+
1823
+ it 'ignores unrecognised users' do
1824
+ path = [ 'members' ]
1825
+ value = [ { '$ref' => nil, 'value' => '11b054a9c85216ed9356' } ]
1826
+ scim_hash = {
1827
+ 'displayname' => 'Mock group',
1828
+ 'members' => [
1829
+ {
1830
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1831
+ 'display' => 'Fred Smith',
1832
+ 'type' => 'User'
1833
+ }
1834
+ ]
1835
+ }.with_indifferent_case_insensitive_access()
1836
+
1837
+ @instance.send(
1838
+ :from_patch_backend!,
1839
+ nature: 'remove',
1840
+ path: path,
1841
+ value: value,
1842
+ altering_hash: scim_hash,
1843
+ with_attr_map: {
1844
+ members: [
1845
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1846
+ ]
1847
+ }
1848
+ )
1849
+
1850
+ # The 'value' mismatched, so the user was not removed.
1851
+ #
1852
+ expect(scim_hash).to eql({
1853
+ 'displayname' => 'Mock group',
1854
+ 'members' => [
1855
+ {
1856
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1857
+ 'display' => 'Fred Smith',
1858
+ 'type' => 'User'
1859
+ }
1860
+ ]
1861
+ })
1862
+ end
1863
+
1864
+ it 'ignores a mismatch on (for example) "type"' do
1865
+ path = [ 'members' ]
1866
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c', 'type' => 'Group' } ]
1867
+ scim_hash = {
1868
+ 'displayname' => 'Mock group',
1869
+ 'members' => [
1870
+ {
1871
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1872
+ 'display' => 'Fred Smith',
1873
+ 'type' => 'User'
1874
+ }
1875
+ ]
1876
+ }.with_indifferent_case_insensitive_access()
1877
+
1878
+ @instance.send(
1879
+ :from_patch_backend!,
1880
+ nature: 'remove',
1881
+ path: path,
1882
+ value: value,
1883
+ altering_hash: scim_hash,
1884
+ with_attr_map: {
1885
+ members: [
1886
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1887
+ ]
1888
+ }
1889
+ )
1890
+
1891
+ # Type 'Group' mismatches 'User', so the user was not
1892
+ # removed.
1893
+ #
1894
+ expect(scim_hash).to eql({
1895
+ 'displayname' => 'Mock group',
1896
+ 'members' => [
1897
+ {
1898
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1899
+ 'display' => 'Fred Smith',
1900
+ 'type' => 'User'
1901
+ }
1902
+ ]
1903
+ })
1904
+ end
1905
+
1906
+ it 'matches keys case-insensitive (but preserves case in response)' do
1907
+ path = [ 'members' ]
1908
+ value = [ { '$ref' => nil, 'VALUe' => 'f648f8d5ea4e4cd38e9c' } ]
1909
+ scim_hash = {
1910
+ 'displayname' => 'Mock group',
1911
+ 'memBERS' => [
1912
+ {
1913
+ 'vaLUe' => 'f648f8d5ea4e4cd38e9c',
1914
+ 'display' => 'Fred Smith',
1915
+ 'type' => 'User'
1916
+ }
1917
+ ]
1918
+ }.with_indifferent_case_insensitive_access()
1919
+
1920
+ @instance.send(
1921
+ :from_patch_backend!,
1922
+ nature: 'remove',
1923
+ path: path,
1924
+ value: value,
1925
+ altering_hash: scim_hash,
1926
+ with_attr_map: {
1927
+ members: [
1928
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1929
+ ]
1930
+ }
1931
+ )
1932
+
1933
+ expect(scim_hash).to eql({
1934
+ 'displayname' => 'Mock group',
1935
+ 'memBERS' => []
1936
+ })
1937
+ end
1938
+
1939
+ it 'matches values case-sensitive' do
1940
+ path = [ 'members' ]
1941
+ value = [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c', 'type' => 'USER' } ]
1942
+ scim_hash = {
1943
+ 'displayName' => 'Mock group',
1944
+ 'members' => [
1945
+ {
1946
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1947
+ 'display' => 'Fred Smith',
1948
+ 'type' => 'User'
1949
+ }
1950
+ ]
1951
+ }.with_indifferent_case_insensitive_access()
1952
+
1953
+ @instance.send(
1954
+ :from_patch_backend!,
1955
+ nature: 'remove',
1956
+ path: path,
1957
+ value: value,
1958
+ altering_hash: scim_hash,
1959
+ with_attr_map: {
1960
+ members: [
1961
+ { list: :members, using: { value: :id, display: :full_name, type: 'User' } }
1962
+ ]
1963
+ }
1964
+ )
1965
+
1966
+ # USER mismatches User, so the user was not removed.
1967
+ #
1968
+ expect(scim_hash).to eql({
1969
+ 'displayName' => 'Mock group',
1970
+ 'members' => [
1971
+ {
1972
+ 'value' => 'f648f8d5ea4e4cd38e9c',
1973
+ 'display' => 'Fred Smith',
1974
+ 'type' => 'User'
1975
+ }
1976
+ ]
1977
+ })
1978
+ end
1979
+ end # "context 'removing a user from a group' do"
1980
+
1981
+ context 'generic use' do
1982
+ it 'clears static map matched items to "nil" in order to remove' do
1983
+ path = [ 'emails' ]
1984
+ value = [ { 'type' => 'work' } ]
1985
+ scim_hash = {
1986
+ 'emails' => [
1987
+ {
1988
+ 'type' => 'home',
1989
+ 'value' => 'home@test.com'
1990
+ },
1991
+ {
1992
+ 'type' => 'work',
1993
+ 'value' => 'work@test.com'
1994
+ }
1995
+ ]
1996
+ }.with_indifferent_case_insensitive_access()
1997
+
1998
+ @instance.send(
1999
+ :from_patch_backend!,
2000
+ nature: 'remove',
2001
+ path: path,
2002
+ value: value,
2003
+ altering_hash: scim_hash,
2004
+ with_attr_map: {
2005
+ emails: [
2006
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2007
+ { match: 'type', with: 'work', using: { value: :work_email } },
2008
+ ]
2009
+ }
2010
+ )
2011
+
2012
+ expect(scim_hash).to eql({
2013
+ 'emails' => [
2014
+ {
2015
+ 'type' => 'home',
2016
+ 'value' => 'home@test.com'
2017
+ },
2018
+ {
2019
+ 'type' => 'work',
2020
+ 'value' => nil
2021
+ }
2022
+ ]
2023
+ })
2024
+ end
2025
+
2026
+ it 'ignores unmatched items' do
2027
+ path = [ 'emails' ]
2028
+ value = [ { 'type' => 'missing' } ]
2029
+ scim_hash = {
2030
+ 'emails' => [
2031
+ {
2032
+ 'type' => 'home',
2033
+ 'value' => 'home@test.com'
2034
+ },
2035
+ {
2036
+ 'type' => 'work',
2037
+ 'value' => 'work@test.com'
2038
+ }
2039
+ ]
2040
+ }.with_indifferent_case_insensitive_access()
2041
+
2042
+ @instance.send(
2043
+ :from_patch_backend!,
2044
+ nature: 'remove',
2045
+ path: path,
2046
+ value: value,
2047
+ altering_hash: scim_hash,
2048
+ with_attr_map: {
2049
+ emails: [
2050
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2051
+ { match: 'type', with: 'work', using: { value: :work_email } },
2052
+ ]
2053
+ }
2054
+ )
2055
+
2056
+ expect(scim_hash).to eql({
2057
+ 'emails' => [
2058
+ {
2059
+ 'type' => 'home',
2060
+ 'value' => 'home@test.com'
2061
+ },
2062
+ {
2063
+ 'type' => 'work',
2064
+ 'value' => 'work@test.com'
2065
+ }
2066
+ ]
2067
+ })
2068
+ end
2069
+
2070
+ it 'compares string forms' do
2071
+ path = [ 'test' ]
2072
+ value = [
2073
+ { 'active' => true, 'value' => '12' },
2074
+ { 'active' => 'false', 'value' => 42 }
2075
+ ]
2076
+ scim_hash = {
2077
+ 'test' => [
2078
+ {
2079
+ 'active' => 'true',
2080
+ 'value' => 12
2081
+ },
2082
+ {
2083
+ 'active' => false,
2084
+ 'value' => '42'
2085
+ },
2086
+ {
2087
+ 'active' => 'hello',
2088
+ 'value' => 'world'
2089
+ }
2090
+ ]
2091
+ }.with_indifferent_case_insensitive_access()
2092
+
2093
+ @instance.send(
2094
+ :from_patch_backend!,
2095
+ nature: 'remove',
2096
+ path: path,
2097
+ value: value,
2098
+ altering_hash: scim_hash,
2099
+ with_attr_map: {
2100
+ test: [
2101
+ {
2102
+ list: :test,
2103
+ using: {
2104
+ active: :active,
2105
+ value: :value
2106
+ }
2107
+ }
2108
+ ]
2109
+ }
2110
+ )
2111
+
2112
+ expect(scim_hash).to eql({
2113
+ 'test' => [
2114
+ {
2115
+ 'active' => 'hello',
2116
+ 'value' => 'world'
2117
+ }
2118
+ ]
2119
+ })
2120
+ end
2121
+
2122
+ it 'handles a singular to-remove value rather than an array' do
2123
+ path = [ 'emails' ]
2124
+ value = { 'type' => 'work' }
2125
+ scim_hash = {
2126
+ 'emails' => [
2127
+ {
2128
+ 'type' => 'home',
2129
+ 'value' => 'home@test.com'
2130
+ },
2131
+ {
2132
+ 'type' => 'work',
2133
+ 'value' => 'work@test.com'
2134
+ }
2135
+ ]
2136
+ }.with_indifferent_case_insensitive_access()
2137
+
2138
+ @instance.send(
2139
+ :from_patch_backend!,
2140
+ nature: 'remove',
2141
+ path: path,
2142
+ value: value,
2143
+ altering_hash: scim_hash,
2144
+ with_attr_map: {
2145
+ emails: [
2146
+ { match: 'type', with: 'home', using: { value: :home_email } },
2147
+ { match: 'type', with: 'work', using: { value: :work_email } },
2148
+ ]
2149
+ }
2150
+ )
2151
+
2152
+ expect(scim_hash).to eql({
2153
+ 'emails' => [
2154
+ {
2155
+ 'type' => 'home',
2156
+ 'value' => 'home@test.com'
2157
+ },
2158
+ {
2159
+ 'type' => 'work',
2160
+ 'value' => nil
2161
+ }
2162
+ ]
2163
+ })
2164
+ end
2165
+
2166
+ it 'handles simple values rather than object (Hash) values' do
2167
+ path = [ 'test' ]
2168
+ value = 42
2169
+ scim_hash = {
2170
+ 'test' => [
2171
+ '21',
2172
+ '42',
2173
+ '15'
2174
+ ]
2175
+ }.with_indifferent_case_insensitive_access()
2176
+
2177
+ @instance.send(
2178
+ :from_patch_backend!,
2179
+ nature: 'remove',
2180
+ path: path,
2181
+ value: value,
2182
+ altering_hash: scim_hash,
2183
+ with_attr_map: {
2184
+ test: []
2185
+ }
2186
+ )
2187
+
2188
+ expect(scim_hash).to eql({
2189
+ 'test' => [
2190
+ '21',
2191
+ '15'
2192
+ ]
2193
+ })
2194
+ end
2195
+ end
2196
+ end # "context 'Microsoft-style payload' do"
2197
+
2198
+ # https://help.salesforce.com/s/articleView?id=sf.identity_scim_manage_groups.htm&type=5
2199
+ #
2200
+ context 'Salesforce-style payload' do
2201
+ it 'removes identified user' do
2202
+ path = [ 'members' ]
2203
+ value = { 'members' => [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ] }
2204
+ scim_hash = {
2205
+ 'displayname' => 'Mock group',
2206
+ 'members' => [
2207
+ {
2208
+ 'value' => '50ca93d04ab0c2de4772',
2209
+ 'display' => 'Ingrid Smith',
2210
+ 'type' => 'User'
2211
+ },
2212
+ {
2213
+ 'value' => 'f648f8d5ea4e4cd38e9c',
2214
+ 'display' => 'Fred Smith',
2215
+ 'type' => 'User'
2216
+ }
2217
+ ]
2218
+ }.with_indifferent_case_insensitive_access()
2219
+
2220
+ @instance.send(
2221
+ :from_patch_backend!,
2222
+ nature: 'remove',
2223
+ path: path,
2224
+ value: value,
2225
+ altering_hash: scim_hash,
2226
+ with_attr_map: {
2227
+ displayName: :name,
2228
+ members: [
2229
+ list: :members,
2230
+ using: {
2231
+ value: :id,
2232
+ display: :full_name,
2233
+ type: 'User'
2234
+ }
2235
+ ]
2236
+ }
2237
+ )
2238
+
2239
+ expect(scim_hash).to eql({
2240
+ 'displayname' => 'Mock group',
2241
+ 'members' => [
2242
+ {
2243
+ 'value' => '50ca93d04ab0c2de4772',
2244
+ 'display' => 'Ingrid Smith',
2245
+ 'type' => 'User'
2246
+ }
2247
+ ]
2248
+ })
2249
+ end
2250
+
2251
+ it 'matches the "members" key case-insensitive' do
2252
+ path = [ 'members' ]
2253
+ value = { 'MEMBERS' => [ { '$ref' => nil, 'value' => 'f648f8d5ea4e4cd38e9c' } ] }
2254
+ scim_hash = {
2255
+ 'displayname' => 'Mock group',
2256
+ 'members' => [
2257
+ {
2258
+ 'value' => 'f648f8d5ea4e4cd38e9c',
2259
+ 'display' => 'Fred Smith',
2260
+ 'type' => 'User'
2261
+ },
2262
+ {
2263
+ 'value' => 'a774d480e8112101375b',
2264
+ 'display' => 'Taylor Smith',
2265
+ 'type' => 'User'
2266
+ }
2267
+ ]
2268
+ }.with_indifferent_case_insensitive_access()
2269
+
2270
+ @instance.send(
2271
+ :from_patch_backend!,
2272
+ nature: 'remove',
2273
+ path: path,
2274
+ value: value,
2275
+ altering_hash: scim_hash,
2276
+ with_attr_map: {
2277
+ displayName: :name,
2278
+ members: [
2279
+ list: :members,
2280
+ using: {
2281
+ value: :id,
2282
+ display: :full_name,
2283
+ type: 'User'
2284
+ }
2285
+ ]
2286
+ }
2287
+ )
2288
+
2289
+ expect(scim_hash).to eql({
2290
+ 'displayname' => 'Mock group',
2291
+ 'members' => [
2292
+ {
2293
+ 'value' => 'a774d480e8112101375b',
2294
+ 'display' => 'Taylor Smith',
2295
+ 'type' => 'User'
2296
+ }
2297
+ ]
2298
+ })
2299
+ end
2300
+
2301
+ it 'ignores unrecognised users' do
2302
+ path = [ 'members' ]
2303
+ value = { 'members' => [ { '$ref' => nil, 'value' => '11b054a9c85216ed9356' } ] }
2304
+ scim_hash = {
2305
+ 'displayname' => 'Mock group',
2306
+ 'members' => [
2307
+ {
2308
+ 'value' => 'f648f8d5ea4e4cd38e9c',
2309
+ 'display' => 'Fred Smith',
2310
+ 'type' => 'User'
2311
+ }
2312
+ ]
2313
+ }.with_indifferent_case_insensitive_access()
2314
+
2315
+ @instance.send(
2316
+ :from_patch_backend!,
2317
+ nature: 'remove',
2318
+ path: path,
2319
+ value: value,
2320
+ altering_hash: scim_hash,
2321
+ with_attr_map: {
2322
+ displayName: :name,
2323
+ members: [
2324
+ list: :members,
2325
+ using: {
2326
+ value: :id,
2327
+ display: :full_name,
2328
+ type: 'User'
2329
+ }
2330
+ ]
2331
+ }
2332
+ )
2333
+
2334
+ # The 'value' mismatched, so the user was not removed.
2335
+ #
2336
+ expect(scim_hash).to eql({
2337
+ 'displayname' => 'Mock group',
2338
+ 'members' => [
2339
+ {
2340
+ 'value' => 'f648f8d5ea4e4cd38e9c',
2341
+ 'display' => 'Fred Smith',
2342
+ 'type' => 'User'
2343
+ }
2344
+ ]
2345
+ })
2346
+ end
2347
+ end # "context 'Salesforce-style payload' do"
2348
+ end # "context 'special cases' do"
2349
+ end # context 'when prior value already exists' do
2350
+
2351
+ context 'when value is not present' do
2352
+ it 'simple value: does nothing' do
2353
+ path = [ 'userName' ]
2354
+ scim_hash = {}.with_indifferent_case_insensitive_access()
2355
+
2356
+ @instance.send(
2357
+ :from_patch_backend!,
2358
+ nature: 'remove',
2359
+ path: path,
2360
+ value: nil,
2361
+ altering_hash: scim_hash,
2362
+ with_attr_map: { userName: :user_name }
2363
+ )
2364
+
2365
+ expect(scim_hash).to be_empty
2366
+ end
2367
+
2368
+ it 'nested simple value: does nothing' do
2369
+ path = [ 'name', 'givenName' ]
2370
+ scim_hash = { 'name' => {'familyName' => 'Bar' } }.with_indifferent_case_insensitive_access()
2371
+
2372
+ @instance.send(
2373
+ :from_patch_backend!,
2374
+ nature: 'remove',
2375
+ path: path,
2376
+ value: nil,
2377
+ altering_hash: scim_hash,
2378
+ with_attr_map: { name: { givenName: :first_name, familyName: :last_name } }
2379
+ )
2380
+
2381
+ expect(scim_hash['name']).to_not have_key('givenName')
2382
+ expect(scim_hash['name']['familyName']).to eql('Bar')
2383
+ end
2384
+
2385
+ context 'with filter mid-path' do
2386
+ it 'by string match: does nothing' do
2387
+ path = [ 'emails[type eq "work"]', 'value' ]
2388
+ scim_hash = {
2389
+ 'emails' => [
2390
+ {
2391
+ 'type' => 'home',
2392
+ 'value' => 'home@test.com'
2393
+ }
2394
+ ]
2395
+ }.with_indifferent_case_insensitive_access()
2396
+
2397
+ @instance.send(
2398
+ :from_patch_backend!,
2399
+ nature: 'remove',
2400
+ path: path,
2401
+ value: nil,
2402
+ altering_hash: scim_hash,
2403
+ with_attr_map: {
2404
+ emails: [
2405
+ { match: 'type', with: 'home', using: { value: :home_email } },
2406
+ { match: 'type', with: 'work', using: { value: :work_email } },
2407
+ ]
2408
+ }
2409
+ )
2410
+
2411
+ expect(scim_hash['emails'].size).to eql(1)
2412
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2413
+ end
2414
+
2415
+ it 'by boolean match: does nothing' do
2416
+ path = [ 'emails[primary eq true]', 'value' ]
2417
+ scim_hash = {
2418
+ 'emails' => [
2419
+ {
2420
+ 'value' => 'home@test.com'
2421
+ }
2422
+ ]
2423
+ }.with_indifferent_case_insensitive_access()
2424
+
2425
+ @instance.send(
2426
+ :from_patch_backend!,
2427
+ nature: 'remove',
2428
+ path: path,
2429
+ value: nil,
2430
+ altering_hash: scim_hash,
2431
+ with_attr_map: {
2432
+ emails: [
2433
+ { match: 'type', with: 'home', using: { value: :home_email } },
2434
+ { match: 'type', with: 'work', using: { value: :work_email } },
2435
+ ]
2436
+ }
2437
+ )
2438
+
2439
+ expect(scim_hash['emails'].size).to eql(1)
2440
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2441
+ end
2442
+
2443
+ it 'multiple matches: does nothing' do
2444
+ path = [ 'emails[type eq "work"]', 'value' ]
2445
+ scim_hash = {
2446
+ 'emails' => [
2447
+ {
2448
+ 'type' => 'home',
2449
+ 'value' => 'home@test.com'
2450
+ }
2451
+ ]
2452
+ }.with_indifferent_case_insensitive_access()
2453
+
2454
+ @instance.send(
2455
+ :from_patch_backend!,
2456
+ nature: 'remove',
2457
+ path: path,
2458
+ value: nil,
2459
+ altering_hash: scim_hash,
2460
+ with_attr_map: {
2461
+ emails: [
2462
+ { match: 'type', with: 'home', using: { value: :home_email } },
2463
+ { match: 'type', with: 'work', using: { value: :work_email } },
2464
+ ]
2465
+ }
2466
+ )
2467
+
2468
+ expect(scim_hash['emails'].size).to eql(1)
2469
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2470
+ end
2471
+ end # "context 'with filter mid-path' do"
2472
+
2473
+ context 'with filter at end of path' do
2474
+ it 'by string match: does nothing' do
2475
+ path = [ 'emails[type eq "work"]' ]
2476
+ scim_hash = {}.with_indifferent_case_insensitive_access()
2477
+
2478
+ @instance.send(
2479
+ :from_patch_backend!,
2480
+ nature: 'remove',
2481
+ path: path,
2482
+ value: nil,
2483
+ altering_hash: scim_hash,
2484
+ with_attr_map: {
2485
+ emails: [
2486
+ { match: 'type', with: 'home', using: { value: :home_email } },
2487
+ { match: 'type', with: 'work', using: { value: :work_email } },
2488
+ ]
2489
+ }
2490
+ )
2491
+
2492
+ expect(scim_hash).to be_empty
2493
+ end
2494
+
2495
+ it 'by boolean match: does nothing' do
2496
+ path = [ 'emails[primary eq true]' ]
2497
+ scim_hash = {
2498
+ 'emails' => [
2499
+ {
2500
+ 'value' => 'home@test.com',
2501
+ 'primary' => false
2502
+ }
2503
+ ]
2504
+ }.with_indifferent_case_insensitive_access()
2505
+
2506
+ @instance.send(
2507
+ :from_patch_backend!,
2508
+ nature: 'remove',
2509
+ path: path,
2510
+ value: nil,
2511
+ altering_hash: scim_hash,
2512
+ with_attr_map: {
2513
+ emails: [
2514
+ { match: 'type', with: 'home', using: { value: :home_email } },
2515
+ { match: 'type', with: 'work', using: { value: :work_email } },
2516
+ ]
2517
+ }
2518
+ )
2519
+
2520
+ expect(scim_hash['emails'].size).to eql(1)
2521
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2522
+ end
2523
+ end # "context 'with filter at end of path' do"
2524
+
2525
+ it 'remove whole array: does nothing' do
2526
+ path = [ 'emails' ]
2527
+ scim_hash = {}.with_indifferent_case_insensitive_access()
2528
+
2529
+ @instance.send(
2530
+ :from_patch_backend!,
2531
+ nature: 'remove',
2532
+ path: path,
2533
+ value: nil,
2534
+ altering_hash: scim_hash,
2535
+ with_attr_map: {
2536
+ emails: [
2537
+ { match: 'type', with: 'home', using: { value: :home_email } },
2538
+ { match: 'type', with: 'work', using: { value: :work_email } },
2539
+ ]
2540
+ }
2541
+ )
2542
+
2543
+ expect(scim_hash).to_not have_key('emails')
2544
+ end
2545
+ end # context 'when value is not present' do
2546
+ end # "context 'remove' do"
2547
+
2548
+ # -------------------------------------------------------------------
2549
+ # Internal: #from_patch_backend - replace
2550
+ # -------------------------------------------------------------------
2551
+ #
2552
+ # Except for filter and array behaviour at the leaf of the path,
2553
+ # "add" and "replace" are pretty much identical.
2554
+ #
2555
+ context 'replace' do
2556
+ context 'when prior value already exists' do
2557
+ it 'simple value: overwrites' do
2558
+ path = [ 'userName' ]
2559
+ scim_hash = { 'userName' => 'bar' }.with_indifferent_case_insensitive_access()
2560
+
2561
+ @instance.send(
2562
+ :from_patch_backend!,
2563
+ nature: 'replace',
2564
+ path: path,
2565
+ value: 'foo',
2566
+ altering_hash: scim_hash,
2567
+ with_attr_map: { userName: :user_name }
2568
+ )
2569
+
2570
+ expect(scim_hash['userName']).to eql('foo')
2571
+ end
2572
+
2573
+ it 'nested simple value: overwrites' do
2574
+ path = [ 'name', 'givenName' ]
2575
+ scim_hash = { 'name' => { 'givenName' => 'Foo', 'familyName' => 'Bar' } }.with_indifferent_case_insensitive_access()
2576
+
2577
+ @instance.send(
2578
+ :from_patch_backend!,
2579
+ nature: 'replace',
2580
+ path: path,
2581
+ value: 'Baz',
2582
+ altering_hash: scim_hash,
2583
+ with_attr_map: { name: { givenName: :first_name, familyName: :last_name } }
2584
+ )
2585
+
2586
+ expect(scim_hash['name']['givenName' ]).to eql('Baz')
2587
+ expect(scim_hash['name']['familyName']).to eql('Bar')
2588
+ end
2589
+
2590
+ context 'with filter mid-path' do
2591
+ it 'by string match: overwrites' do
2592
+ path = [ 'emails[type eq "work"]', 'value' ]
2593
+ scim_hash = {
2594
+ 'emails' => [
2595
+ {
2596
+ 'type' => 'home',
2597
+ 'value' => 'home@test.com'
2598
+ },
2599
+ {
2600
+ 'type' => 'work',
2601
+ 'value' => 'work@test.com'
2602
+ }
2603
+ ]
2604
+ }.with_indifferent_case_insensitive_access()
2605
+
2606
+ @instance.send(
2607
+ :from_patch_backend!,
2608
+ nature: 'replace',
2609
+ path: path,
2610
+ value: 'added_over_original@test.com',
2611
+ altering_hash: scim_hash,
2612
+ with_attr_map: {
2613
+ emails: [
2614
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2615
+ { match: 'type', with: 'work', using: { value: :work_email } },
2616
+ ]
2617
+ }
2618
+ )
2619
+
2620
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2621
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_original@test.com')
2622
+ end
2623
+
2624
+ it 'by boolean match: overwrites' do
2625
+ path = [ 'emails[primary eq true]', 'value' ]
2626
+ scim_hash = {
2627
+ 'emails' => [
2628
+ {
2629
+ 'value' => 'home@test.com'
2630
+ },
2631
+ {
2632
+ 'value' => 'work@test.com',
2633
+ 'primary' => true
2634
+ }
2635
+ ]
2636
+ }.with_indifferent_case_insensitive_access()
2637
+
2638
+ @instance.send(
2639
+ :from_patch_backend!,
2640
+ nature: 'replace',
2641
+ path: path,
2642
+ value: 'added_over_original@test.com',
2643
+ altering_hash: scim_hash,
2644
+ with_attr_map: {
2645
+ emails: [
2646
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2647
+ { match: 'type', with: 'work', using: { value: :work_email } },
2648
+ ]
2649
+ }
2650
+ )
2651
+
2652
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2653
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_original@test.com')
2654
+ end
2655
+
2656
+ it 'multiple matches: overwrites all' do
2657
+ path = [ 'emails[type eq "work"]', 'value' ]
2658
+ scim_hash = {
2659
+ 'emails' => [
2660
+ {
2661
+ 'type' => 'work',
2662
+ 'value' => 'work_1@test.com'
2663
+ },
2664
+ {
2665
+ 'type' => 'work',
2666
+ 'value' => 'work_2@test.com'
2667
+ }
2668
+ ]
2669
+ }.with_indifferent_case_insensitive_access()
2670
+
2671
+ @instance.send(
2672
+ :from_patch_backend!,
2673
+ nature: 'replace',
2674
+ path: path,
2675
+ value: 'added_over_original@test.com',
2676
+ altering_hash: scim_hash,
2677
+ with_attr_map: {
2678
+ emails: [
2679
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2680
+ { match: 'type', with: 'work', using: { value: :work_email } },
2681
+ ]
2682
+ }
2683
+ )
2684
+
2685
+ expect(scim_hash['emails'][0]['value']).to eql('added_over_original@test.com')
2686
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_original@test.com')
2687
+ end
2688
+ end # "context 'with filter mid-path' do"
2689
+
2690
+ context 'with filter at end of path' do
2691
+ it 'by string match: replaces matching array entry' do
2692
+ path = [ 'emails[type eq "work"]' ]
2693
+ scim_hash = {
2694
+ 'emails' => [
2695
+ {
2696
+ 'type' => 'holiday',
2697
+ 'value' => 'holiday@test.com'
2698
+ },
2699
+ {
2700
+ 'type' => 'work',
2701
+ 'value' => 'work@test.com'
2702
+ }
2703
+ ]
2704
+ }.with_indifferent_case_insensitive_access()
2705
+
2706
+ @instance.send(
2707
+ :from_patch_backend!,
2708
+ nature: 'replace',
2709
+ path: path,
2710
+ value: {'type' => 'home', 'primary' => true, 'value' => 'home@test.com'},
2711
+ altering_hash: scim_hash,
2712
+ with_attr_map: {
2713
+ emails: [
2714
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2715
+ { match: 'type', with: 'work', using: { value: :work_email } },
2716
+ ]
2717
+ }
2718
+ )
2719
+
2720
+ expect(scim_hash['emails'].size).to eql(2)
2721
+ expect(scim_hash['emails'][0]['type' ]).to eql('holiday') # unchanged
2722
+ expect(scim_hash['emails'][1]['type' ]).to eql('home') # "work" became "home"
2723
+ expect(scim_hash['emails'][1]['primary']).to eql(true)
2724
+ expect(scim_hash['emails'][1]['value' ]).to eql('home@test.com')
2725
+ end
2726
+
2727
+ it 'multiple matches: replaces all matching array entries' do
2728
+ path = [ 'emails[type eq "work"]' ]
2729
+ scim_hash = {
2730
+ 'emails' => [
2731
+ {
2732
+ 'type' => 'work',
2733
+ 'value' => 'work_1@test.com'
2734
+ },
2735
+ {
2736
+ 'type' => 'work',
2737
+ 'value' => 'work_2@test.com'
2738
+ },
2739
+ {
2740
+ 'type' => 'home',
2741
+ 'value' => 'home@test.com'
2742
+ },
2743
+ ]
2744
+ }.with_indifferent_case_insensitive_access()
2745
+
2746
+ @instance.send(
2747
+ :from_patch_backend!,
2748
+ nature: 'replace',
2749
+ path: path,
2750
+ value: {'type' => 'workinate', 'value' => 'replaced@test.com'},
2751
+ altering_hash: scim_hash,
2752
+ with_attr_map: {
2753
+ emails: [
2754
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2755
+ { match: 'type', with: 'work', using: { value: :work_email } },
2756
+ ]
2757
+ }
2758
+ )
2759
+
2760
+ expect(scim_hash['emails'].size).to eql(3)
2761
+ expect(scim_hash['emails'][0]['type' ]).to eql('workinate')
2762
+ expect(scim_hash['emails'][0]['value']).to eql('replaced@test.com')
2763
+ expect(scim_hash['emails'][1]['type' ]).to eql('workinate')
2764
+ expect(scim_hash['emails'][1]['value']).to eql('replaced@test.com')
2765
+ expect(scim_hash['emails'][2]['type' ]).to eql('home')
2766
+ expect(scim_hash['emails'][2]['value']).to eql('home@test.com')
2767
+ end
2768
+ end # "context 'with filter at end of path' do"
2769
+
2770
+ it 'with arrays: replaces whole array' do
2771
+ path = [ 'emails' ]
2772
+ scim_hash = {
2773
+ 'emails' => [
2774
+ {
2775
+ 'type' => 'home',
2776
+ 'value' => 'home@test.com'
2777
+ }
2778
+ ]
2779
+ }.with_indifferent_case_insensitive_access()
2780
+
2781
+ @instance.send(
2782
+ :from_patch_backend!,
2783
+ nature: 'replace',
2784
+ path: path,
2785
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
2786
+ altering_hash: scim_hash,
2787
+ with_attr_map: {
2788
+ emails: [
2789
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2790
+ { match: 'type', with: 'work', using: { value: :work_email } },
2791
+ ]
2792
+ }
2793
+ )
2794
+
2795
+ expect(scim_hash['emails'].size).to eql(1)
2796
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
2797
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
2798
+ end
2799
+ end # context 'when prior value already exists' do
2800
+
2801
+ context 'when value is not present' do
2802
+ it 'simple value: adds' do
2803
+ path = [ 'userName' ]
2804
+ scim_hash = {}.with_indifferent_case_insensitive_access()
2805
+
2806
+ @instance.send(
2807
+ :from_patch_backend!,
2808
+ nature: 'replace',
2809
+ path: path,
2810
+ value: 'foo',
2811
+ altering_hash: scim_hash,
2812
+ with_attr_map: { userName: :user_name }
2813
+ )
2814
+
2815
+ expect(scim_hash['userName']).to eql('foo')
2816
+ end
2817
+
2818
+ it 'nested simple value: adds' do
2819
+ path = [ 'name', 'givenName' ]
2820
+ scim_hash = {}.with_indifferent_case_insensitive_access()
2821
+
2822
+ @instance.send(
2823
+ :from_patch_backend!,
2824
+ nature: 'replace',
2825
+ path: path,
2826
+ value: 'Baz',
2827
+ altering_hash: scim_hash,
2828
+ with_attr_map: { name: { givenName: :first_name, familyName: :last_name } }
2829
+ )
2830
+
2831
+ expect(scim_hash['name']['givenName']).to eql('Baz')
2832
+ end
2833
+
2834
+ context 'with filter mid-path: adds' do
2835
+ it 'by string match' do
2836
+ path = [ 'emails[type eq "work"]', 'value' ]
2837
+ scim_hash = {
2838
+ 'emails' => [
2839
+ {
2840
+ 'type' => 'home',
2841
+ 'value' => 'home@test.com'
2842
+ },
2843
+ {
2844
+ 'type' => 'work'
2845
+ }
2846
+ ]
2847
+ }.with_indifferent_case_insensitive_access()
2848
+
2849
+ @instance.send(
2850
+ :from_patch_backend!,
2851
+ nature: 'replace',
2852
+ path: path,
2853
+ value: 'added@test.com',
2854
+ altering_hash: scim_hash,
2855
+ with_attr_map: {
2856
+ emails: [
2857
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2858
+ { match: 'type', with: 'work', using: { value: :work_email } },
2859
+ ]
2860
+ }
2861
+ )
2862
+
2863
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2864
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
2865
+ end
2866
+
2867
+ it 'by boolean match: adds' do
2868
+ path = [ 'emails[primary eq true]', 'value' ]
2869
+ scim_hash = {
2870
+ 'emails' => [
2871
+ {
2872
+ 'value' => 'home@test.com'
2873
+ },
2874
+ {
2875
+ 'primary' => true
2876
+ }
2877
+ ]
2878
+ }.with_indifferent_case_insensitive_access()
2879
+
2880
+ @instance.send(
2881
+ :from_patch_backend!,
2882
+ nature: 'replace',
2883
+ path: path,
2884
+ value: 'added@test.com',
2885
+ altering_hash: scim_hash,
2886
+ with_attr_map: {
2887
+ emails: [
2888
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2889
+ { match: 'type', with: 'work', using: { value: :work_email } },
2890
+ ]
2891
+ }
2892
+ )
2893
+
2894
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2895
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
2896
+ end
2897
+
2898
+ it 'multiple matches: adds to all' do
2899
+ path = [ 'emails[type eq "work"]', 'value' ]
2900
+ scim_hash = {
2901
+ 'emails' => [
2902
+ {
2903
+ 'type' => 'work'
2904
+ },
2905
+ {
2906
+ 'type' => 'work'
2907
+ }
2908
+ ]
2909
+ }.with_indifferent_case_insensitive_access()
2910
+
2911
+ @instance.send(
2912
+ :from_patch_backend!,
2913
+ nature: 'replace',
2914
+ path: path,
2915
+ value: 'added@test.com',
2916
+ altering_hash: scim_hash,
2917
+ with_attr_map: {
2918
+ emails: [
2919
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2920
+ { match: 'type', with: 'work', using: { value: :work_email } },
2921
+ ]
2922
+ }
2923
+ )
2924
+
2925
+ expect(scim_hash['emails'][0]['value']).to eql('added@test.com')
2926
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
2927
+ end
2928
+ end # "context 'with filter mid-path' do"
2929
+
2930
+ context 'with filter at end of path' do
2931
+ it 'by string match: adds item' do
2932
+ path = [ 'emails[type eq "work"]' ]
2933
+ scim_hash = {}.with_indifferent_case_insensitive_access()
2934
+
2935
+ @instance.send(
2936
+ :from_patch_backend!,
2937
+ nature: 'replace',
2938
+ path: path,
2939
+ value: {'type' => 'work', 'value' => 'work@test.com'},
2940
+ altering_hash: scim_hash,
2941
+ with_attr_map: {
2942
+ emails: [
2943
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2944
+ { match: 'type', with: 'work', using: { value: :work_email } },
2945
+ ]
2946
+ }
2947
+ )
2948
+
2949
+ expect(scim_hash['emails'].size).to eql(1)
2950
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
2951
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
2952
+ end
2953
+
2954
+ it 'by boolean match: adds item' do
2955
+ path = [ 'emails[primary eq true]' ]
2956
+ scim_hash = {
2957
+ 'emails' => [
2958
+ {
2959
+ 'value' => 'home@test.com',
2960
+ 'primary' => false
2961
+ }
2962
+ ]
2963
+ }.with_indifferent_case_insensitive_access()
2964
+
2965
+ @instance.send(
2966
+ :from_patch_backend!,
2967
+ nature: 'replace',
2968
+ path: path,
2969
+ value: {'type' => 'work', 'value' => 'work@test.com'},
2970
+ altering_hash: scim_hash,
2971
+ with_attr_map: {
2972
+ emails: [
2973
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2974
+ { match: 'type', with: 'work', using: { value: :work_email } },
2975
+ ]
2976
+ }
2977
+ )
2978
+
2979
+ expect(scim_hash['emails'].size).to eql(2)
2980
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
2981
+ expect(scim_hash['emails'][1]['type' ]).to eql('work')
2982
+ expect(scim_hash['emails'][1]['value']).to eql('work@test.com')
2983
+ end
2984
+ end # "context 'with filter at end of path' do"
2985
+
2986
+ it 'with arrays: replaces' do
2987
+ path = [ 'emails' ]
2988
+ scim_hash = {}.with_indifferent_case_insensitive_access()
2989
+
2990
+ @instance.send(
2991
+ :from_patch_backend!,
2992
+ nature: 'replace',
2993
+ path: path,
2994
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
2995
+ altering_hash: scim_hash,
2996
+ with_attr_map: {
2997
+ emails: [
2998
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
2999
+ { match: 'type', with: 'work', using: { value: :work_email } },
3000
+ ]
3001
+ }
3002
+ )
3003
+
3004
+ expect(scim_hash['emails'].size).to eql(1)
3005
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
3006
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
3007
+ end
3008
+
3009
+ context 'when prior value already exists, and no path' do
3010
+ it 'simple value: overwrites' do
3011
+ path = [ 'root' ]
3012
+ scim_hash = { 'root' => { 'userName' => 'bar', 'active' => true } }.with_indifferent_case_insensitive_access()
3013
+
3014
+ @instance.send(
3015
+ :from_patch_backend!,
3016
+ nature: 'replace',
3017
+ path: path,
3018
+ value: { 'active' => false }.with_indifferent_case_insensitive_access(),
3019
+ altering_hash: scim_hash,
3020
+ with_attr_map: {
3021
+ userName: :user_name,
3022
+ active: :active
3023
+ }
3024
+ )
3025
+
3026
+ expect(scim_hash['root']['userName']).to eql('bar')
3027
+ expect(scim_hash['root']['active']).to eql(false)
3028
+ end
3029
+ end
3030
+ end # context 'when value is not present' do
3031
+ end # "context 'replace' do"
3032
+
3033
+ # -------------------------------------------------------------------
3034
+ # Internal: #from_patch_backend - some bespoke complex cases
3035
+ # -------------------------------------------------------------------
3036
+ #
3037
+ # I just wanted to be sure...
3038
+ #
3039
+ context 'contrived complexity' do
3040
+ before :each do
3041
+ @contrived_class = Class.new do
3042
+ def self.scim_resource_type
3043
+ return nil
3044
+ end
3045
+
3046
+ def self.scim_attributes_map
3047
+ return {
3048
+ complex: [
3049
+ match: 'type',
3050
+ with: 'type1',
3051
+ using: {
3052
+ data: {
3053
+ nested: [
3054
+ match: 'nature',
3055
+ with: 'nature2',
3056
+ using: {
3057
+ info: {
3058
+ deep: :accessor_method_is_unused_in_this_test
3059
+ }
3060
+ }
3061
+ ]
3062
+ }
3063
+ }
3064
+ ]
3065
+ }
3066
+ end
3067
+
3068
+ def self.scim_mutable_attributes
3069
+ return nil
3070
+ end
3071
+
3072
+ def self.scim_queryable_attributes
3073
+ return nil
3074
+ end
3075
+
3076
+ include Scimitar::Resources::Mixin
3077
+ end
3078
+
3079
+ @original_hash = {
3080
+ 'complex' => [
3081
+ {
3082
+ 'type' => 'type1', # This will match the filter below
3083
+ 'data' => {
3084
+ 'nested' => [
3085
+ {
3086
+ 'nature' => 'nature1', # This will not match
3087
+ 'info' => [
3088
+ { 'deep' => 'nature1deep' }
3089
+ ]
3090
+ },
3091
+ {
3092
+ 'nature' => 'nature2', # This will match the filter below
3093
+ 'info' => [
3094
+ { 'deep' => 'nature2deep1' }
3095
+ ]
3096
+ },
3097
+ {
3098
+ 'nature' => 'nature2', # This will match the filter below
3099
+ 'info' => [
3100
+ { 'deep' => 'nature2deep2' }
3101
+ ]
3102
+ },
3103
+ ]
3104
+ }
3105
+ },
3106
+ {
3107
+ 'type' => 'type1', # This will match the filter below
3108
+ 'data' => {
3109
+ 'nested' => [
3110
+ {
3111
+ 'nature' => 'nature2', # This will match the filter below
3112
+ 'info' => [
3113
+ { 'deep' => 'nature2deep3' }
3114
+ ]
3115
+ }
3116
+ ]
3117
+ }
3118
+ },
3119
+ {
3120
+ 'type' => 'type2', # This will not match
3121
+ 'data' => {
3122
+ 'nested' => [
3123
+ {
3124
+ 'nature' => 'nature2', # This will match the filter below, but is nested inside something that does not match
3125
+ 'info' => [
3126
+ { 'deep' => 'nature2deep3' }
3127
+ ]
3128
+ }
3129
+ ]
3130
+ }
3131
+ }
3132
+ ]
3133
+ }
3134
+ end
3135
+
3136
+ it 'adds across multiple deep matching points' do
3137
+ scim_hash = @original_hash.deep_dup().with_indifferent_case_insensitive_access()
3138
+ contrived_instance = @contrived_class.new
3139
+ contrived_instance.send(
3140
+ :from_patch_backend!,
3141
+ nature: 'add',
3142
+ path: ['complex[type eq "type1"]', 'data', 'nested[nature eq "nature2"]', 'info'],
3143
+ value: [{ 'deeper' => 'addition' }],
3144
+ altering_hash: scim_hash,
3145
+ with_attr_map: @contrived_class.scim_attributes_map()
3146
+ )
3147
+
3148
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
3149
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info').count).to eql(2) # One new item
3150
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info').count).to eql(2) # One new item
3151
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info', 1, 'deeper')).to eql('addition')
3152
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info', 1, 'deeper')).to eql('addition')
3153
+
3154
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info').count).to eql(2) # One new item
3155
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info', 1, 'deeper')).to eql('addition')
3156
+
3157
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
3158
+ end
3159
+
3160
+ it 'replaces across multiple deep matching points' do
3161
+ scim_hash = @original_hash.deep_dup().with_indifferent_case_insensitive_access()
3162
+ contrived_instance = @contrived_class.new
3163
+ contrived_instance.send(
3164
+ :from_patch_backend!,
3165
+ nature: 'replace',
3166
+ path: ['complex[type eq "type1"]', 'data', 'nested[nature eq "nature2"]', 'info'],
3167
+ value: [{ 'deeper' => 'addition' }],
3168
+ altering_hash: scim_hash,
3169
+ with_attr_map: @contrived_class.scim_attributes_map()
3170
+ )
3171
+
3172
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged?
3173
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info').count).to eql(1)
3174
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info').count).to eql(1)
3175
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info', 0, 'deep')).to eql('nature1deep') # Yes, unchanged.
3176
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info', 0, 'deeper')).to eql('addition')
3177
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info', 0, 'deeper')).to eql('addition')
3178
+
3179
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info').count).to eql(1)
3180
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info', 0, 'deeper')).to eql('addition')
3181
+
3182
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
3183
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info', 0, 'deep')).to eql('nature2deep3') # Unchanged
3184
+ end
3185
+
3186
+ it 'removes via clearing to "nil" or empty Array across multiple deep matching points' do
3187
+ scim_hash = @original_hash.deep_dup().with_indifferent_case_insensitive_access()
3188
+ contrived_instance = @contrived_class.new
3189
+ contrived_instance.send(
3190
+ :from_patch_backend!,
3191
+ nature: 'remove',
3192
+ path: ['complex[type eq "type1"]', 'data', 'nested[nature eq "nature2"]', 'info'],
3193
+ value: nil,
3194
+ altering_hash: scim_hash,
3195
+ with_attr_map: @contrived_class.scim_attributes_map()
3196
+ )
3197
+
3198
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
3199
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info')).to eql([])
3200
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'nature')).to be_present
3201
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info')).to eql([])
3202
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'nature')).to be_present
3203
+
3204
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info')).to eql([])
3205
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'nature')).to be_present
3206
+
3207
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
3208
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info', 0, 'deep')).to eql('nature2deep3') # Unchanged
3209
+ end
3210
+ end # "context 'contrived complexity' do"
3211
+
3212
+ # -------------------------------------------------------------------
3213
+ # Internal: #from_patch_backend - error handling
3214
+ # -------------------------------------------------------------------
3215
+ #
3216
+ context 'with bad patches, raises errors' do
3217
+ it 'for unsupported filters' do
3218
+ path = [ 'emails[type ne "work" and value ne "hello@test.com"', 'value' ]
3219
+ scim_hash = {
3220
+ 'emails' => [
3221
+ {
3222
+ 'type' => 'work',
3223
+ 'value' => 'work_1@test.com'
3224
+ },
3225
+ {
3226
+ 'type' => 'work',
3227
+ 'value' => 'work_2@test.com'
3228
+ }
3229
+ ]
3230
+ }.with_indifferent_case_insensitive_access()
3231
+
3232
+ expect do
3233
+ @instance.send(
3234
+ :from_patch_backend!,
3235
+ nature: 'replace',
3236
+ path: path,
3237
+ value: 'ignored',
3238
+ altering_hash: scim_hash,
3239
+ with_attr_map: {
3240
+ emails: [
3241
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
3242
+ { match: 'type', with: 'work', using: { value: :work_email } },
3243
+ ]
3244
+ }
3245
+ )
3246
+ end.to raise_error(Scimitar::ErrorResponse) { |e| expect(e.as_json['scimType']).to eql('invalidSyntax') }
3247
+ end
3248
+
3249
+ it 'when filters are specified for non-array types' do
3250
+ path = [ 'userName[type eq "work"]', 'value' ]
3251
+ scim_hash = {
3252
+ 'userName' => '1234'
3253
+ }.with_indifferent_case_insensitive_access()
3254
+
3255
+ expect do
3256
+ @instance.send(
3257
+ :from_patch_backend!,
3258
+ nature: 'replace',
3259
+ path: path,
3260
+ value: 'ignored',
3261
+ altering_hash: scim_hash,
3262
+ with_attr_map: { userName: :user_name }
3263
+ )
3264
+ end.to raise_error(Scimitar::ErrorResponse) { |e| expect(e.as_json['scimType']).to eql('invalidSyntax') }
3265
+ end
3266
+
3267
+ it 'when a filter tries to match an array which does not contain Hashes' do
3268
+ path = [ 'emails[type eq "work"]', 'value' ]
3269
+ scim_hash = {
3270
+ 'emails' => [
3271
+ 'work_1@test.com',
3272
+ 'work_2@test.com',
3273
+ ]
3274
+ }.with_indifferent_case_insensitive_access()
3275
+
3276
+ expect do
3277
+ @instance.send(
3278
+ :from_patch_backend!,
3279
+ nature: 'replace',
3280
+ path: path,
3281
+ value: 'ignored',
3282
+ altering_hash: scim_hash,
3283
+ with_attr_map: {
3284
+ emails: [
3285
+ { match: 'type', with: 'home', using: { value: :home_email, primary: true } },
3286
+ { match: 'type', with: 'work', using: { value: :work_email } },
3287
+ ]
3288
+ }
3289
+ )
3290
+ end.to raise_error(Scimitar::ErrorResponse) { |e| expect(e.as_json['scimType']).to eql('invalidSyntax') }
3291
+ end
3292
+ end # context 'with bad patches, raises errors' do
3293
+ end # "context '#from_patch_backend!' do"
3294
+ end # "context 'internal unit tests' do"
3295
+
3296
+ # -------------------------------------------------------------------
3297
+ # Public
3298
+ # -------------------------------------------------------------------
3299
+ #
3300
+ context 'public interface' do
3301
+ shared_examples 'a patcher' do | force_upper_case: |
3302
+ it 'gives the user a comprehensible error when operations are missing' do
3303
+ patch = { 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] }
3304
+
3305
+ expect do
3306
+ @instance.from_scim_patch!(patch_hash: patch)
3307
+ end.to raise_error Scimitar::InvalidSyntaxError, "Missing PATCH \"operations\""
3308
+ end
3309
+
3310
+ it 'which updates simple values' do
3311
+ @instance.update!(username: 'foo')
3312
+
3313
+ path = 'userName'
3314
+ path = path.upcase if force_upper_case
3315
+
3316
+ patch = {
3317
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3318
+ 'Operations' => [
3319
+ {
3320
+ 'op' => 'replace',
3321
+ 'path' => path,
3322
+ 'value' => '1234'
3323
+ }
3324
+ ]
3325
+ }
3326
+
3327
+ @instance.from_scim_patch!(patch_hash: patch)
3328
+ expect(@instance.username).to eql('1234')
3329
+ end
3330
+
3331
+ it 'which updates nested values using root syntax' do
3332
+ @instance.update!(first_name: 'Foo', last_name: 'Bar')
3333
+
3334
+ path = 'name.givenName'
3335
+ path = path.upcase if force_upper_case
3336
+
3337
+ patch = {
3338
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3339
+ 'Operations' => [
3340
+ {
3341
+ 'op' => 'replace',
3342
+ 'value' => {
3343
+ path => 'Baz'
3344
+ }
3345
+ }
3346
+ ]
3347
+ }
3348
+
3349
+ @instance.from_scim_patch!(patch_hash: patch)
3350
+ expect(@instance.first_name).to eql('Baz')
3351
+ end
3352
+
3353
+ it 'which updates nested values' do
3354
+ @instance.update!(first_name: 'Foo', last_name: 'Bar')
3355
+
3356
+ path = 'name.givenName'
3357
+ path = path.upcase if force_upper_case
3358
+
3359
+ patch = {
3360
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3361
+ 'Operations' => [
3362
+ {
3363
+ 'op' => 'replace',
3364
+ 'path' => path,
3365
+ 'value' => 'Baz'
3366
+ }
3367
+ ]
3368
+ }
3369
+
3370
+ @instance.from_scim_patch!(patch_hash: patch)
3371
+ expect(@instance.first_name).to eql('Baz')
3372
+ end
3373
+
3374
+ # Note odd ":" separating schema ID from first attribute, although
3375
+ # the nature of JSON rendering / other payloads might lead you to
3376
+ # expect a "." as with any other path component.
3377
+ #
3378
+ # Note the ":" separating the schema ID (URN) from the attribute.
3379
+ # The nature of JSON rendering / other payloads might lead you to
3380
+ # expect a "." as with any complex types, but that's not the case;
3381
+ # see https://tools.ietf.org/html/rfc7644#section-3.10, or
3382
+ # https://tools.ietf.org/html/rfc7644#section-3.5.2 of which in
3383
+ # particular, https://tools.ietf.org/html/rfc7644#page-35.
3384
+ #
3385
+ it 'which updates attributes defined by extension schema' do
3386
+ @instance.update!(department: 'SOMEDPT')
3387
+
3388
+ path = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department'
3389
+ path = path.upcase if force_upper_case
3390
+
3391
+ patch = {
3392
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3393
+ 'Operations' => [
3394
+ {
3395
+ 'op' => 'replace',
3396
+ 'path' => path,
3397
+ 'value' => 'OTHERDPT'
3398
+ }
3399
+ ]
3400
+ }
3401
+
3402
+ @instance.from_scim_patch!(patch_hash: patch)
3403
+ expect(@instance.department).to eql('OTHERDPT')
3404
+ end
3405
+
3406
+ it 'which updates with filter match' do
3407
+ @instance.update!(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
3408
+
3409
+ filter_prefix = 'emails[type'
3410
+ filter_prefix = filter_prefix.upcase if force_upper_case
3411
+
3412
+ patch = {
3413
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3414
+ 'Operations' => [
3415
+ {
3416
+ 'op' => 'replace',
3417
+ 'path' => filter_prefix + ' eq "work"].value',
3418
+ 'value' => 'replaced@test.com'
3419
+ }
3420
+ ]
3421
+ }
3422
+
3423
+ @instance.from_scim_patch!(patch_hash: patch)
3424
+ expect(@instance.work_email_address).to eql('replaced@test.com')
3425
+ expect(@instance.home_email_address).to eql('home@test.com')
3426
+ end
3427
+
3428
+ it 'which appends e-mails' do
3429
+ @instance.update!(work_email_address: 'work@test.com')
3430
+
3431
+ filter_prefix = 'emails[type'
3432
+ filter_prefix = filter_prefix.upcase if force_upper_case
3433
+
3434
+ patch = {
3435
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3436
+ 'Operations' => [
3437
+ {
3438
+ 'op' => 'add',
3439
+ 'path' => filter_prefix + ' eq "home"].value',
3440
+ 'value' => 'home@test.com'
3441
+ }
3442
+ ]
3443
+ }
3444
+
3445
+ @instance.from_scim_patch!(patch_hash: patch)
3446
+ expect(@instance.work_email_address).to eql('work@test.com')
3447
+ expect(@instance.home_email_address).to eql('home@test.com')
3448
+ end
3449
+
3450
+ it 'which removes e-mails' do
3451
+ @instance.update!(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
3452
+
3453
+ filter_prefix = 'emails[type'
3454
+ filter_prefix = filter_prefix.upcase if force_upper_case
3455
+
3456
+ patch = {
3457
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3458
+ 'Operations' => [
3459
+ {
3460
+ 'op' => 'remove',
3461
+ 'path' => filter_prefix + ' eq "home"].value',
3462
+ }
3463
+ ]
3464
+ }
3465
+
3466
+ @instance.from_scim_patch!(patch_hash: patch)
3467
+ expect(@instance.work_email_address).to eql('work@test.com')
3468
+ expect(@instance.home_email_address).to be_nil
3469
+ end
3470
+
3471
+ it 'which can patch the whole object' do
3472
+ @instance.update!(username: 'foo')
3473
+
3474
+ hash = {
3475
+ 'userName' => '1234',
3476
+ 'name' => {
3477
+ 'givenName' => 'Bar'
3478
+ }
3479
+ }
3480
+
3481
+ hash = spec_helper_hupcase(hash) if force_upper_case
3482
+
3483
+ patch = {
3484
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3485
+ 'Operations' => [
3486
+ {
3487
+ 'op' => 'replace',
3488
+ 'value' => hash
3489
+ }
3490
+ ]
3491
+ }
3492
+
3493
+ @instance.from_scim_patch!(patch_hash: patch)
3494
+ expect(@instance.username).to eql('1234')
3495
+ expect(@instance.first_name).to eql('Bar')
3496
+ end
3497
+ end # "shared_examples 'a patcher' do | force_upper_case: |"
3498
+
3499
+ context 'using schema-matched case' do
3500
+ it_behaves_like 'a patcher', force_upper_case: false
3501
+ end # "context 'using schema-matched case' do"
3502
+
3503
+ context 'using upper case' do
3504
+ it_behaves_like 'a patcher', force_upper_case: true
3505
+
3506
+ it 'treats operation types as case-insensitive' do
3507
+ @instance.update!(username: 'foo')
3508
+
3509
+ patch = {
3510
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3511
+ 'Operations' => [
3512
+ {
3513
+ 'op' => 'REPLACE', # Note upper case
3514
+ 'path' => 'userName',
3515
+ 'value' => '1234'
3516
+ }
3517
+ ]
3518
+ }
3519
+
3520
+ @instance.from_scim_patch!(patch_hash: patch)
3521
+ expect(@instance.username).to eql('1234')
3522
+ end
3523
+ end # "context 'using upper case' do"
3524
+
3525
+ context 'with errors' do
3526
+ it 'complains about bad operation types' do
3527
+ patch = {
3528
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3529
+ 'Operations' => [
3530
+ {
3531
+ 'op' => 'invalidop',
3532
+ 'path' => 'userName',
3533
+ 'value' => '1234'
3534
+ }
3535
+ ]
3536
+ }
3537
+
3538
+ expect { @instance.from_scim_patch!(patch_hash: patch) }.to raise_error(Scimitar::ErrorResponse) do |e|
3539
+ expect(e.as_json['scimType']).to eql('invalidSyntax')
3540
+ expect(e.as_json[:detail ]).to include('invalidop')
3541
+ end
3542
+ end
3543
+
3544
+ it 'complains about a missing target for "remove" operations' do
3545
+ patch = {
3546
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
3547
+ 'Operations' => [
3548
+ {
3549
+ 'op' => 'remove'
3550
+ }
3551
+ ]
3552
+ }
3553
+
3554
+ expect { @instance.from_scim_patch!(patch_hash: patch) }.to raise_error(Scimitar::ErrorResponse) do |e|
3555
+ expect(e.as_json['scimType']).to eql('noTarget')
3556
+ end
3557
+ end
3558
+ end # "context 'with errors' do"
3559
+ end # "context 'public interface' do"
3560
+ end # "context '#from_scim_patch!' do"
3561
+ end # "context 'with good class definitons' do"
3562
+ end # "RSpec.describe Scimitar::Resources::Mixin do"