scimitar 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (106) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +16 -0
  3. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +180 -0
  4. data/app/controllers/scimitar/application_controller.rb +129 -0
  5. data/app/controllers/scimitar/resource_types_controller.rb +28 -0
  6. data/app/controllers/scimitar/resources_controller.rb +203 -0
  7. data/app/controllers/scimitar/schemas_controller.rb +16 -0
  8. data/app/controllers/scimitar/service_provider_configurations_controller.rb +8 -0
  9. data/app/models/scimitar/authentication_error.rb +9 -0
  10. data/app/models/scimitar/authentication_scheme.rb +18 -0
  11. data/app/models/scimitar/bulk.rb +8 -0
  12. data/app/models/scimitar/complex_types/address.rb +18 -0
  13. data/app/models/scimitar/complex_types/base.rb +41 -0
  14. data/app/models/scimitar/complex_types/email.rb +12 -0
  15. data/app/models/scimitar/complex_types/entitlement.rb +12 -0
  16. data/app/models/scimitar/complex_types/ims.rb +12 -0
  17. data/app/models/scimitar/complex_types/name.rb +12 -0
  18. data/app/models/scimitar/complex_types/phone_number.rb +12 -0
  19. data/app/models/scimitar/complex_types/photo.rb +12 -0
  20. data/app/models/scimitar/complex_types/reference_group.rb +12 -0
  21. data/app/models/scimitar/complex_types/reference_member.rb +12 -0
  22. data/app/models/scimitar/complex_types/role.rb +12 -0
  23. data/app/models/scimitar/complex_types/x509_certificate.rb +12 -0
  24. data/app/models/scimitar/engine_configuration.rb +24 -0
  25. data/app/models/scimitar/error_response.rb +20 -0
  26. data/app/models/scimitar/errors.rb +14 -0
  27. data/app/models/scimitar/filter.rb +11 -0
  28. data/app/models/scimitar/filter_error.rb +22 -0
  29. data/app/models/scimitar/invalid_syntax_error.rb +9 -0
  30. data/app/models/scimitar/lists/count.rb +64 -0
  31. data/app/models/scimitar/lists/query_parser.rb +730 -0
  32. data/app/models/scimitar/meta.rb +7 -0
  33. data/app/models/scimitar/not_found_error.rb +10 -0
  34. data/app/models/scimitar/resource_invalid_error.rb +9 -0
  35. data/app/models/scimitar/resource_type.rb +29 -0
  36. data/app/models/scimitar/resources/base.rb +159 -0
  37. data/app/models/scimitar/resources/group.rb +13 -0
  38. data/app/models/scimitar/resources/mixin.rb +964 -0
  39. data/app/models/scimitar/resources/user.rb +13 -0
  40. data/app/models/scimitar/schema/address.rb +24 -0
  41. data/app/models/scimitar/schema/attribute.rb +123 -0
  42. data/app/models/scimitar/schema/base.rb +86 -0
  43. data/app/models/scimitar/schema/derived_attributes.rb +24 -0
  44. data/app/models/scimitar/schema/email.rb +10 -0
  45. data/app/models/scimitar/schema/entitlement.rb +10 -0
  46. data/app/models/scimitar/schema/group.rb +27 -0
  47. data/app/models/scimitar/schema/ims.rb +10 -0
  48. data/app/models/scimitar/schema/name.rb +20 -0
  49. data/app/models/scimitar/schema/phone_number.rb +10 -0
  50. data/app/models/scimitar/schema/photo.rb +10 -0
  51. data/app/models/scimitar/schema/reference_group.rb +23 -0
  52. data/app/models/scimitar/schema/reference_member.rb +21 -0
  53. data/app/models/scimitar/schema/role.rb +10 -0
  54. data/app/models/scimitar/schema/user.rb +52 -0
  55. data/app/models/scimitar/schema/vdtp.rb +18 -0
  56. data/app/models/scimitar/schema/x509_certificate.rb +22 -0
  57. data/app/models/scimitar/service_provider_configuration.rb +49 -0
  58. data/app/models/scimitar/supportable.rb +14 -0
  59. data/app/views/layouts/scimitar/application.html.erb +14 -0
  60. data/config/initializers/scimitar.rb +82 -0
  61. data/config/routes.rb +6 -0
  62. data/lib/scimitar.rb +23 -0
  63. data/lib/scimitar/engine.rb +63 -0
  64. data/lib/scimitar/version.rb +13 -0
  65. data/spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb +24 -0
  66. data/spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb +30 -0
  67. data/spec/apps/dummy/app/controllers/mock_groups_controller.rb +13 -0
  68. data/spec/apps/dummy/app/controllers/mock_users_controller.rb +13 -0
  69. data/spec/apps/dummy/app/models/mock_group.rb +83 -0
  70. data/spec/apps/dummy/app/models/mock_user.rb +104 -0
  71. data/spec/apps/dummy/config/application.rb +17 -0
  72. data/spec/apps/dummy/config/boot.rb +2 -0
  73. data/spec/apps/dummy/config/environment.rb +2 -0
  74. data/spec/apps/dummy/config/environments/test.rb +15 -0
  75. data/spec/apps/dummy/config/initializers/cookies_serializer.rb +3 -0
  76. data/spec/apps/dummy/config/initializers/scimitar.rb +14 -0
  77. data/spec/apps/dummy/config/initializers/session_store.rb +3 -0
  78. data/spec/apps/dummy/config/routes.rb +24 -0
  79. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +15 -0
  80. data/spec/apps/dummy/db/migrate/20210308020313_create_mock_groups.rb +10 -0
  81. data/spec/apps/dummy/db/migrate/20210308044214_create_join_table_mock_groups_mock_users.rb +8 -0
  82. data/spec/apps/dummy/db/schema.rb +42 -0
  83. data/spec/controllers/scimitar/application_controller_spec.rb +173 -0
  84. data/spec/controllers/scimitar/resource_types_controller_spec.rb +94 -0
  85. data/spec/controllers/scimitar/resources_controller_spec.rb +247 -0
  86. data/spec/controllers/scimitar/schemas_controller_spec.rb +75 -0
  87. data/spec/controllers/scimitar/service_provider_configurations_controller_spec.rb +22 -0
  88. data/spec/models/scimitar/complex_types/address_spec.rb +19 -0
  89. data/spec/models/scimitar/complex_types/email_spec.rb +23 -0
  90. data/spec/models/scimitar/lists/count_spec.rb +147 -0
  91. data/spec/models/scimitar/lists/query_parser_spec.rb +763 -0
  92. data/spec/models/scimitar/resource_type_spec.rb +21 -0
  93. data/spec/models/scimitar/resources/base_spec.rb +289 -0
  94. data/spec/models/scimitar/resources/base_validation_spec.rb +61 -0
  95. data/spec/models/scimitar/resources/mixin_spec.rb +2127 -0
  96. data/spec/models/scimitar/resources/user_spec.rb +55 -0
  97. data/spec/models/scimitar/schema/attribute_spec.rb +80 -0
  98. data/spec/models/scimitar/schema/base_spec.rb +64 -0
  99. data/spec/models/scimitar/schema/group_spec.rb +87 -0
  100. data/spec/models/scimitar/schema/user_spec.rb +710 -0
  101. data/spec/requests/active_record_backed_resources_controller_spec.rb +569 -0
  102. data/spec/requests/application_controller_spec.rb +49 -0
  103. data/spec/requests/controller_configuration_spec.rb +17 -0
  104. data/spec/requests/engine_spec.rb +20 -0
  105. data/spec/spec_helper.rb +66 -0
  106. metadata +315 -0
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Scimitar::ResourceType do
4
+ context '#as_json' do
5
+
6
+ it 'adds the extensionSchemas' do
7
+ resource_type = Scimitar::ResourceType.new(
8
+ endpoint: '/Gaga',
9
+ schema: 'urn:ietf:params:scim:schemas:core:2.0:User',
10
+ schemaExtensions: ['urn:ietf:params:scim:schemas:extension:enterprise:2.0:User']
11
+ )
12
+
13
+ expect(resource_type.as_json['schemaExtensions']).to eql([{
14
+ "schema" => 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
15
+ "required" => false
16
+ }])
17
+
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,289 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Scimitar::Resources::Base do
4
+ context 'basic operation' do
5
+ FirstCustomSchema = Class.new(Scimitar::Schema::Base) do
6
+ def self.id
7
+ 'custom-id'
8
+ end
9
+
10
+ def self.scim_attributes
11
+ [
12
+ Scimitar::Schema::Attribute.new(
13
+ name: 'name', complexType: Scimitar::ComplexTypes::Name, required: false
14
+ ),
15
+ Scimitar::Schema::Attribute.new(
16
+ name: 'names', multiValued: true, complexType: Scimitar::ComplexTypes::Name, required: false
17
+ )
18
+ ]
19
+ end
20
+ end
21
+
22
+ CustomResourse = Class.new(Scimitar::Resources::Base) do
23
+ set_schema FirstCustomSchema
24
+ end
25
+
26
+ context '#initialize' do
27
+ it 'builds the nested type' do
28
+ resource = CustomResourse.new(name: {
29
+ givenName: 'John',
30
+ familyName: 'Smith'
31
+ })
32
+
33
+ expect(resource.name.is_a?(Scimitar::ComplexTypes::Name)).to be(true)
34
+ expect(resource.name.givenName).to eql('John')
35
+ expect(resource.name.familyName).to eql('Smith')
36
+ end
37
+
38
+ it 'builds an array of nested resources' do
39
+ resource = CustomResourse.new(names: [
40
+ {
41
+ givenName: 'John',
42
+ familyName: 'Smith'
43
+ },
44
+ {
45
+ givenName: 'Jane',
46
+ familyName: 'Snow'
47
+ }
48
+ ])
49
+
50
+ expect(resource.names.is_a?(Array)).to be(true)
51
+ expect(resource.names.first.is_a?(Scimitar::ComplexTypes::Name)).to be(true)
52
+ expect(resource.names.first.givenName).to eql('John')
53
+ expect(resource.names.first.familyName).to eql('Smith')
54
+ expect(resource.names.second.is_a?(Scimitar::ComplexTypes::Name)).to be(true)
55
+ expect(resource.names.second.givenName).to eql('Jane')
56
+ expect(resource.names.second.familyName).to eql('Snow')
57
+ expect(resource.valid?).to be(true)
58
+ end
59
+
60
+ it 'builds an array of nested resources which is invalid if the hash does not follow the schema of the complex type' do
61
+ resource = CustomResourse.new(names: [
62
+ {
63
+ givenName: 'John',
64
+ familyName: 123
65
+ }
66
+ ])
67
+
68
+ expect(resource.names.is_a?(Array)).to be(true)
69
+ expect(resource.names.first.is_a?(Scimitar::ComplexTypes::Name)).to be(true)
70
+ expect(resource.names.first.givenName).to eql('John')
71
+ expect(resource.names.first.familyName).to eql(123)
72
+ expect(resource.valid?).to be(false)
73
+ end
74
+ end # "context '#initialize' do"
75
+
76
+ context '#as_json' do
77
+ it 'renders the json with the resourceType' do
78
+ resource = CustomResourse.new(name: {
79
+ givenName: 'John',
80
+ familyName: 'Smith'
81
+ })
82
+
83
+ result = resource.as_json
84
+ expect(result['schemas']).to eql(['custom-id'])
85
+ expect(result['meta']['resourceType']).to eql('CustomResourse')
86
+ expect(result['errors']).to be_nil
87
+ end
88
+ end # "context '#as_json' do"
89
+
90
+ context '.find_attribute' do
91
+ it 'finds in complex type' do
92
+ found = CustomResourse.find_attribute('name', 'givenName')
93
+ expect(found).to be_present
94
+ expect(found.name).to eql('givenName')
95
+ expect(found.type).to eql('string')
96
+ end
97
+
98
+ it 'finds in multi-value type, without index' do
99
+ found = CustomResourse.find_attribute('names', 'givenName')
100
+ expect(found).to be_present
101
+ expect(found.name).to eql('givenName')
102
+ expect(found.type).to eql('string')
103
+ end
104
+
105
+ it 'finds in multi-value type, ignoring index' do
106
+ found = CustomResourse.find_attribute('names', 42, 'givenName')
107
+ expect(found).to be_present
108
+ expect(found.name).to eql('givenName')
109
+ expect(found.type).to eql('string')
110
+ end
111
+ end # "context '.find_attribute' do"
112
+ end # "context 'basic operation' do"
113
+
114
+ context 'dynamic setters based on schema' do
115
+ SecondCustomSchema = Class.new(Scimitar::Schema::Base) do
116
+ def self.scim_attributes
117
+ [
118
+ Scimitar::Schema::Attribute.new(name: 'customField', type: 'string', required: false),
119
+ Scimitar::Schema::Attribute.new(name: 'anotherCustomField', type: 'boolean', required: false),
120
+ Scimitar::Schema::Attribute.new(name: 'name', complexType: Scimitar::ComplexTypes::Name, required: false)
121
+ ]
122
+ end
123
+ end
124
+
125
+ CustomNameType = Class.new(Scimitar::ComplexTypes::Base) do
126
+ set_schema Scimitar::Schema::Name
127
+ end
128
+
129
+ it 'defines a setter for an attribute in the schema' do
130
+ described_class.set_schema SecondCustomSchema
131
+ resource = described_class.new(customField: '100',
132
+ anotherCustomField: true)
133
+ expect(resource.customField).to eql('100')
134
+ expect(resource.anotherCustomField).to eql(true)
135
+ expect(resource.valid?).to be(true)
136
+ end
137
+
138
+ it 'defines a setter for an attribute in the schema' do
139
+ described_class.set_schema SecondCustomSchema
140
+ resource = described_class.new(anotherCustomField: false)
141
+ expect(resource.anotherCustomField).to eql(false)
142
+ expect(resource.valid?).to be(true)
143
+ end
144
+
145
+ it 'validates that the provided attributes match their schema' do
146
+ described_class.set_schema SecondCustomSchema
147
+ resource = described_class.new(
148
+ name: Scimitar::ComplexTypes::Name.new(
149
+ givenName: 'John',
150
+ familyName: 'Smith'
151
+ ))
152
+ expect(resource.valid?).to be(true)
153
+ end
154
+
155
+ it 'validates that nested types' do
156
+ described_class.set_schema SecondCustomSchema
157
+ resource = described_class.new(
158
+ name: Scimitar::ComplexTypes::Name.new(
159
+ givenName: 100,
160
+ familyName: 'Smith'
161
+ ))
162
+ expect(resource.valid?).to be(false)
163
+ end
164
+
165
+ it 'allows custom complex types as long as the schema matches' do
166
+ described_class.set_schema SecondCustomSchema
167
+ resource = described_class.new(
168
+ name: CustomNameType.new(
169
+ givenName: 'John',
170
+ familyName: 'Smith'
171
+ ))
172
+ expect(resource.valid?).to be(true)
173
+ end
174
+
175
+ it 'doesn\'t accept email for a name' do
176
+ described_class.set_schema SecondCustomSchema
177
+ resource = described_class.new(
178
+ name: Scimitar::ComplexTypes::Email.new(
179
+ value: 'john@smith.com',
180
+ primary: true
181
+ ))
182
+ expect(resource.valid?).to be(false)
183
+ end
184
+
185
+ it 'doesn\'t accept a complex type for a string' do
186
+ described_class.set_schema SecondCustomSchema
187
+ resource = described_class.new(
188
+ customField: Scimitar::ComplexTypes::Email.new(
189
+ value: 'john@smith.com',
190
+ primary: true
191
+ ))
192
+ expect(resource.valid?).to be(false)
193
+ end
194
+
195
+ it 'doesn\'t accept a string for a boolean' do
196
+ described_class.set_schema SecondCustomSchema
197
+ resource = described_class.new(anotherCustomField: 'value')
198
+ expect(resource.valid?).to be(false)
199
+ end
200
+ end # "context 'dynamic setters based on schema' do"
201
+
202
+ context 'schema extension' do
203
+ ThirdCustomSchema = Class.new(Scimitar::Schema::Base) do
204
+ def self.id
205
+ 'custom-id'
206
+ end
207
+
208
+ def self.scim_attributes
209
+ [ Scimitar::Schema::Attribute.new(name: 'name', type: 'string') ]
210
+ end
211
+ end
212
+
213
+ ExtensionSchema = Class.new(Scimitar::Schema::Base) do
214
+ def self.id
215
+ 'extension-id'
216
+ end
217
+
218
+ def self.scim_attributes
219
+ [ Scimitar::Schema::Attribute.new(name: 'relationship', type: 'string', required: true) ]
220
+ end
221
+ end
222
+
223
+ let(:resource_class) {
224
+ Class.new(Scimitar::Resources::Base) do
225
+ set_schema ThirdCustomSchema
226
+ extend_schema ExtensionSchema
227
+
228
+ def self.endpoint
229
+ '/gaga'
230
+ end
231
+
232
+ def self.resource_type_id
233
+ 'CustomResource'
234
+ end
235
+ end
236
+ }
237
+
238
+ context '#initialize' do
239
+ it 'allows setting extension attributes' do
240
+ resource = resource_class.new('extension-id' => {relationship: 'GAGA'})
241
+ expect(resource.relationship).to eql('GAGA')
242
+ end
243
+ end # "context '#initialize' do"
244
+
245
+ context '#as_json' do
246
+ it 'namespaces the extension attributes' do
247
+ resource = resource_class.new(relationship: 'GAGA')
248
+ hash = resource.as_json
249
+ expect(hash["schemas"]).to eql(['custom-id', 'extension-id'])
250
+ expect(hash["extension-id"]).to eql("relationship" => 'GAGA')
251
+ end
252
+ end # "context '#as_json' do"
253
+
254
+ context '.resource_type' do
255
+ it 'appends the extension schemas' do
256
+ resource_type = resource_class.resource_type('http://gaga')
257
+ expect(resource_type.meta.location).to eql('http://gaga')
258
+ expect(resource_type.schemaExtensions.count).to eql(1)
259
+ end
260
+
261
+ context 'validation' do
262
+ it 'validates into custom schema' do
263
+ resource = resource_class.new('extension-id' => {})
264
+ expect(resource.valid?).to eql(false)
265
+
266
+ resource = resource_class.new('extension-id' => {relationship: 'GAGA'})
267
+ expect(resource.relationship).to eql('GAGA')
268
+ expect(resource.valid?).to eql(true)
269
+ end
270
+ end # context 'validation'
271
+ end # "context '.resource_type' do"
272
+
273
+ context '.find_attribute' do
274
+ it 'finds in first schema' do
275
+ found = resource_class().find_attribute('name') # Defined in ThirdCustomSchema
276
+ expect(found).to be_present
277
+ expect(found.name).to eql('name')
278
+ expect(found.type).to eql('string')
279
+ end
280
+
281
+ it 'finds across schemas' do
282
+ found = resource_class().find_attribute('relationship') # Defined in ExtensionSchema
283
+ expect(found).to be_present
284
+ expect(found.name).to eql('relationship')
285
+ expect(found.type).to eql('string')
286
+ end
287
+ end # "context '.find_attribute' do"
288
+ end # "context 'schema extension' do"
289
+ end
@@ -0,0 +1,61 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Scimitar::Resources::Base do
4
+
5
+ context '#valid?' do
6
+ MyCustomSchema = Class.new(Scimitar::Schema::Base) do
7
+ def self.id
8
+ 'custom-id'
9
+ end
10
+
11
+ def self.scim_attributes
12
+ [
13
+ Scimitar::Schema::Attribute.new(
14
+ name: 'userName', type: 'string', required: false
15
+ ),
16
+ Scimitar::Schema::Attribute.new(
17
+ name: 'enforce', type: 'boolean', required: true
18
+ ),
19
+ Scimitar::Schema::Attribute.new(
20
+ name: 'complexName', complexType: Scimitar::ComplexTypes::Name, required: false
21
+ ),
22
+ Scimitar::Schema::Attribute.new(
23
+ name: 'complexNames', complexType: Scimitar::ComplexTypes::Name, multiValued:true, required: false
24
+ )
25
+ ]
26
+ end
27
+ end
28
+
29
+ MyCustomResource = Class.new(Scimitar::Resources::Base) do
30
+ set_schema MyCustomSchema
31
+ end
32
+
33
+ it 'adds validation errors to the resource for simple attributes' do
34
+ resource = MyCustomResource.new(userName: 10)
35
+ expect(resource.valid?).to be(false)
36
+ expect(resource.errors.full_messages).to match_array(['Username has the wrong type. It has to be a(n) string.', 'Enforce is required'])
37
+ end
38
+
39
+ it 'adds validation errors to the resource for the complex attribute when the value does not match the schema' do
40
+ resource = MyCustomResource.new(complexName: 10, enforce: false)
41
+ expect(resource.valid?).to be(false)
42
+ expect(resource.errors.full_messages).to match_array(['Complexname has to follow the complexType format.'])
43
+ end
44
+
45
+ it 'adds validation errors to the resource from what the complex type schema returns' do
46
+ resource = MyCustomResource.new(complexName: { givenName: 10 }, enforce: false)
47
+ expect(resource.valid?).to be(false)
48
+ expect(resource.errors.full_messages).to match_array(["Complexname familyname is required", "Complexname givenname has the wrong type. It has to be a(n) string."])
49
+ end
50
+
51
+ it 'adds validation errors to the resource from what the complex type schema returns when it is multi-valued' do
52
+ resource = MyCustomResource.new(complexNames: [
53
+ "Jane Austen",
54
+ { givenName: 'Jane', familyName: true }
55
+ ],
56
+ enforce: false)
57
+ expect(resource.valid?).to be(false)
58
+ expect(resource.errors.full_messages).to match_array(["Complexnames has to follow the complexType format.", "Complexnames familyname has the wrong type. It has to be a(n) string."])
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,2127 @@
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
+ # ===========================================================================
85
+ # Errant class definitions
86
+ # ===========================================================================
87
+
88
+ context 'with bad class definitions' do
89
+ it 'complains about missing mandatory methods' do
90
+ mandatory_class_methods = %w{
91
+ scim_resource_type
92
+ scim_attributes_map
93
+ scim_mutable_attributes
94
+ scim_queryable_attributes
95
+ }
96
+
97
+ mandatory_class_methods.each do | required_class_method |
98
+
99
+ # E.g. "You must define ::scim_resource_type in #<Class:...>"
100
+ #
101
+ expect {
102
+ klass = Class.new(BasicObject) do
103
+ fewer_class_methods = mandatory_class_methods - [required_class_method]
104
+ fewer_class_methods.each do | method_to_define |
105
+ define_singleton_method(method_to_define) do
106
+ puts 'I am defined'
107
+ end
108
+ end
109
+
110
+ include Scimitar::Resources::Mixin
111
+ end
112
+ }.to raise_error(RuntimeError, /#{Regexp.escape(required_class_method)}/)
113
+ end
114
+ end
115
+ end # "context 'with bad class definitions' do"
116
+
117
+ # ===========================================================================
118
+ # Correct class definitions
119
+ # ===========================================================================
120
+
121
+ context 'with good class definitons' do
122
+
123
+ require_relative '../../../apps/dummy/app/models/mock_user.rb'
124
+ require_relative '../../../apps/dummy/app/models/mock_group.rb'
125
+
126
+ # =========================================================================
127
+ # Support methods
128
+ # =========================================================================
129
+
130
+ context '#scim_queryable_attributes' do
131
+ it 'exposes queryable attributes as an instance method' do
132
+ instance_result = MockUser.new.scim_queryable_attributes()
133
+ class_result = MockUser.scim_queryable_attributes()
134
+
135
+ expect(instance_result).to match_array(class_result)
136
+ end
137
+ end # "context '#scim_queryable_attributes' do"
138
+
139
+ context '#scim_mutable_attributes' do
140
+ it 'self-compiles mutable attributes and exposes them as an instance method' do
141
+ readwrite_attrs = MockUser::READWRITE_ATTRS.map(&:to_sym)
142
+ readwrite_attrs.delete(:id) # Should never be offered as writable in SCIM
143
+
144
+ result = MockUser.new.scim_mutable_attributes()
145
+ expect(result).to match_array(readwrite_attrs)
146
+ end
147
+
148
+ it 'includes read-write dynamic list attributes' do
149
+ readwrite_attrs = MockGroup::READWRITE_ATTRS.map(&:to_sym)
150
+ readwrite_attrs.delete(:id) # Should never be offered as writable in SCIM
151
+
152
+ result = MockGroup.new.scim_mutable_attributes()
153
+ expect(result).to match_array(readwrite_attrs)
154
+ end
155
+ end # "context '#scim_mutable_attributes' do"
156
+
157
+ # =========================================================================
158
+ # #to_scim
159
+ # =========================================================================
160
+
161
+ context '#to_scim' do
162
+ it 'compiles instance attribute values into a SCIM representation' do
163
+ instance = MockUser.new
164
+ instance.id = 42
165
+ instance.scim_uid = 'AA02984'
166
+ instance.username = 'foo'
167
+ instance.first_name = 'Foo'
168
+ instance.last_name = 'Bar'
169
+ instance.work_email_address = 'foo.bar@test.com'
170
+ instance.home_email_address = nil
171
+ instance.work_phone_number = '+642201234567'
172
+
173
+ g1 = MockGroup.create!(display_name: 'Group 1')
174
+ g2 = MockGroup.create!(display_name: 'Group 2')
175
+ g3 = MockGroup.create!(display_name: 'Group 3')
176
+
177
+ g1.mock_users << instance
178
+ g3.mock_users << instance
179
+
180
+ scim = instance.to_scim(location: 'https://test.com/mock_users/42')
181
+ json = scim.to_json()
182
+ hash = JSON.parse(json)
183
+
184
+ expect(hash).to eql({
185
+ 'userName' => 'foo',
186
+ 'name' => {'givenName'=>'Foo', 'familyName'=>'Bar'},
187
+ 'active' => true,
188
+ 'emails' => [{'type'=>'work', 'primary'=>true, 'value'=>'foo.bar@test.com'}, {"primary"=>false, "type"=>"home", "value"=>nil}],
189
+ 'phoneNumbers'=> [{'type'=>'work', 'primary'=>false, 'value'=>'+642201234567'}],
190
+ 'id' => '42', # Note, String
191
+ 'externalId' => 'AA02984',
192
+ 'groups' => [{'display'=>g1.display_name, 'value'=>g1.id.to_s}, {'display'=>g3.display_name, 'value'=>g3.id.to_s}],
193
+ 'meta' => {'location'=>'https://test.com/mock_users/42', 'resourceType'=>'User'},
194
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User']
195
+ })
196
+ end
197
+
198
+ context 'with optional timestamps' do
199
+ context 'creation only' do
200
+ class CreationOnlyTest < MockUser
201
+ attr_accessor :created_at
202
+
203
+ def self.scim_timestamps_map
204
+ { created: :created_at }
205
+ end
206
+ end
207
+
208
+ it 'renders the creation date/time' do
209
+ instance = CreationOnlyTest.new
210
+ instance.created_at = Time.now
211
+
212
+ scim = instance.to_scim(location: 'https://test.com/mock_users/42')
213
+ json = scim.to_json()
214
+ hash = JSON.parse(json)
215
+
216
+ expect(hash['meta']).to eql({
217
+ 'created' => instance.created_at.iso8601(0),
218
+ 'location' => 'https://test.com/mock_users/42',
219
+ 'resourceType' => 'User'
220
+ })
221
+ end
222
+ end # "context 'creation only' do"
223
+
224
+ context 'update only' do
225
+ class UpdateOnlyTest < MockUser
226
+ attr_accessor :updated_at
227
+
228
+ def self.scim_timestamps_map
229
+ { lastModified: :updated_at }
230
+ end
231
+ end
232
+
233
+ it 'renders the modification date/time' do
234
+ instance = UpdateOnlyTest.new
235
+ instance.updated_at = Time.now
236
+
237
+ scim = instance.to_scim(location: 'https://test.com/mock_users/42')
238
+ json = scim.to_json()
239
+ hash = JSON.parse(json)
240
+
241
+ expect(hash['meta']).to eql({
242
+ 'lastModified' => instance.updated_at.iso8601(0),
243
+ 'location' => 'https://test.com/mock_users/42',
244
+ 'resourceType' => 'User'
245
+ })
246
+ end
247
+ end # "context 'update only' do"
248
+
249
+ context 'create and update' do
250
+ class CreateAndUpdateTest < MockUser
251
+ attr_accessor :created_at, :updated_at
252
+
253
+ def self.scim_timestamps_map
254
+ {
255
+ created: :created_at,
256
+ lastModified: :updated_at
257
+ }
258
+ end
259
+ end
260
+
261
+ it 'renders the creation and modification date/times' do
262
+ instance = CreateAndUpdateTest.new
263
+ instance.created_at = Time.now - 1.month
264
+ instance.updated_at = Time.now
265
+
266
+ scim = instance.to_scim(location: 'https://test.com/mock_users/42')
267
+ json = scim.to_json()
268
+ hash = JSON.parse(json)
269
+
270
+ expect(hash['meta']).to eql({
271
+ 'created' => instance.created_at.iso8601(0),
272
+ 'lastModified' => instance.updated_at.iso8601(0),
273
+ 'location' => 'https://test.com/mock_users/42',
274
+ 'resourceType' => 'User'
275
+ })
276
+ end
277
+ end # "context 'create and update' do"
278
+ end # "context 'with optional timestamps' do"
279
+
280
+ context 'with arrays' do
281
+ context 'using static mappings' do
282
+ it 'converts to a SCIM representation' do
283
+ instance = StaticMapTest.new(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
284
+ scim = instance.to_scim(location: 'https://test.com/static_map_test')
285
+ json = scim.to_json()
286
+ hash = JSON.parse(json)
287
+
288
+ expect(hash).to eql({
289
+ 'emails' => [
290
+ {'type'=>'work', 'primary'=>false, 'value'=>'work@test.com'},
291
+ {'type'=>'home', 'value'=>'home@test.com'},
292
+ ],
293
+
294
+ 'meta' => {'location'=>'https://test.com/static_map_test', 'resourceType'=>'User'},
295
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User']
296
+ })
297
+ end
298
+ end # "context 'using static mappings' do"
299
+
300
+ context 'using dynamic lists' do
301
+ it 'converts to a SCIM representation' do
302
+ group = Struct.new(:id, :full_name, keyword_init: true)
303
+ groups = [
304
+ group.new(id: 1, full_name: 'Group 1'),
305
+ group.new(id: 2, full_name: 'Group 2'),
306
+ group.new(id: 3, full_name: 'Group 3'),
307
+ ]
308
+
309
+ instance = DynamicMapTest.new(groups: groups)
310
+ scim = instance.to_scim(location: 'https://test.com/dynamic_map_test')
311
+ json = scim.to_json()
312
+ hash = JSON.parse(json)
313
+
314
+ expect(hash).to eql({
315
+ 'groups' => [
316
+ {'display'=>'Group 1', 'value'=>'1'},
317
+ {'display'=>'Group 2', 'value'=>'2'},
318
+ {'display'=>'Group 3', 'value'=>'3'},
319
+ ],
320
+
321
+ 'meta' => {'location'=>'https://test.com/dynamic_map_test', 'resourceType'=>'User'},
322
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User']
323
+ })
324
+ end
325
+ end # "context 'using dynamic lists' do"
326
+ end # "context 'with arrays' do"
327
+
328
+ context 'with bad definitions' do
329
+ it 'complains about non-Hash entries in mapping Arrays' do
330
+ expect(StaticMapTest).to receive(:scim_attributes_map).and_return({
331
+ emails: [
332
+ 'this is not Hash'
333
+ ]
334
+ })
335
+
336
+ instance = StaticMapTest.new(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
337
+
338
+ expect do
339
+ scim = instance.to_scim(location: 'https://test.com/static_map_test')
340
+ end.to raise_error(RuntimeError) { |e| expect(e.message).to include('Array contains someting other than mapping Hash(es)') }
341
+ end
342
+
343
+ it 'complains about bad Hash entries in mapping Arrays' do
344
+ expect(StaticMapTest).to receive(:scim_attributes_map).and_return({
345
+ emails: [
346
+ {
347
+ this_is_not: :a_valid_entry
348
+ }
349
+ ]
350
+ })
351
+
352
+ instance = StaticMapTest.new(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
353
+
354
+ expect do
355
+ scim = instance.to_scim(location: 'https://test.com/static_map_test')
356
+ end.to raise_error(RuntimeError) { |e| expect(e.message).to include('Mapping Hash inside Array does not contain supported data') }
357
+ end
358
+ end # "context 'with bad definitions' do"
359
+ end # "context '#to_scim' do"
360
+
361
+ # =========================================================================
362
+ # #from_scim!
363
+ # =========================================================================
364
+
365
+ context '#from_scim!' do
366
+ context 'writes instance attribute values from a SCIM representation' do
367
+ it 'ignoring read-only lists' do
368
+ hash = {
369
+ 'userName' => 'foo',
370
+ 'name' => {'givenName'=>'Foo', 'familyName'=>'Bar'},
371
+ 'active' => true,
372
+ 'emails' => [{'type'=>'work', 'primary'=>true, 'value'=>'foo.bar@test.com'}],
373
+ 'phoneNumbers'=> [{'type'=>'work', 'primary'=>false, 'value'=>'+642201234567' }],
374
+ 'groups' => [{'type'=>'Group', 'value'=>'1'}, {'type'=>'Group', 'value'=>'2'}],
375
+ 'id' => '42', # Note, String
376
+ 'externalId' => 'AA02984',
377
+ 'meta' => {'location'=>'https://test.com/mock_users/42', 'resourceType'=>'User'},
378
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:User']
379
+ }
380
+
381
+ instance = MockUser.new
382
+ instance.home_email_address = 'home@test.com' # Should be cleared as no home e-mail specified in SCIM hash above
383
+ instance.from_scim!(scim_hash: hash)
384
+
385
+ expect(instance.scim_uid ).to eql('AA02984')
386
+ expect(instance.username ).to eql('foo')
387
+ expect(instance.first_name ).to eql('Foo')
388
+ expect(instance.last_name ).to eql('Bar')
389
+ expect(instance.work_email_address).to eql('foo.bar@test.com')
390
+ expect(instance.home_email_address).to be_nil
391
+ expect(instance.work_phone_number ).to eql('+642201234567')
392
+ end
393
+
394
+ it 'honouring read-write lists' do
395
+ g1 = MockGroup.create!(display_name: 'Nested group')
396
+
397
+ u1 = MockUser.create!(username: '1', first_name: 'Member 1')
398
+ u2 = MockUser.create!(username: '2', first_name: 'Member 2')
399
+ u3 = MockUser.create!(username: '3', first_name: 'Member 3')
400
+
401
+ hash = {
402
+ 'displayName' => 'Foo Group',
403
+ 'members' => [
404
+ {'type'=>'Group', 'value'=>g1.id.to_s},
405
+ {'type'=>'User', 'value'=>u1.id.to_s},
406
+ {'type'=>'User', 'value'=>u3.id.to_s}
407
+ ],
408
+ 'externalId' => 'GG01536',
409
+ 'meta' => {'location'=>'https://test.com/mock_groups/1', 'resourceType'=>'Group'},
410
+ 'schemas' => ['urn:ietf:params:scim:schemas:core:2.0:Group']
411
+ }
412
+
413
+ instance = MockGroup.new
414
+ instance.from_scim!(scim_hash: hash)
415
+
416
+ expect(instance.scim_uid ).to eql('GG01536')
417
+ expect(instance.display_name ).to eql('Foo Group')
418
+ expect(instance.mock_users ).to match_array([u1, u3])
419
+ expect(instance.child_mock_groups).to match_array([g1])
420
+
421
+ instance.save!
422
+ expect(g1.reload.parent_id).to eql(instance.id)
423
+ end
424
+
425
+ it 'handles missing inbound lists' do
426
+ hash = {
427
+ 'displayName' => 'Foo Group'
428
+ }
429
+
430
+ instance = MockGroup.new
431
+ instance.from_scim!(scim_hash: hash)
432
+
433
+ expect(instance.display_name ).to eql('Foo Group')
434
+ expect(instance.mock_users ).to be_empty
435
+ expect(instance.child_mock_groups).to be_empty
436
+ end
437
+ end # "context 'writes instance attribute values from a SCIM representation' do"
438
+
439
+ it 'clears things not present in input' do
440
+ instance = MockUser.new
441
+ instance.id = 42
442
+ instance.scim_uid = 'AA02984'
443
+ instance.username = 'foo'
444
+ instance.first_name = 'Foo'
445
+ instance.last_name = 'Bar'
446
+ instance.work_email_address = 'work@test.com'
447
+ instance.home_email_address = 'home@test.com'
448
+ instance.work_phone_number = '+642201234567'
449
+
450
+ instance.from_scim!(scim_hash: {})
451
+
452
+ expect(instance.id ).to eql(42)
453
+ expect(instance.scim_uid ).to be_nil
454
+ expect(instance.username ).to be_nil
455
+ expect(instance.first_name ).to be_nil
456
+ expect(instance.last_name ).to be_nil
457
+ expect(instance.work_email_address).to be_nil
458
+ expect(instance.home_email_address).to be_nil
459
+ expect(instance.work_phone_number ).to be_nil
460
+ end
461
+ end # "context '#from_scim!' do"
462
+
463
+ # =========================================================================
464
+ # #from_scim_patch!
465
+ # =========================================================================
466
+
467
+ context '#from_scim_patch!' do
468
+ before :each do
469
+ @instance = MockUser.new
470
+ end
471
+
472
+ # -------------------------------------------------------------------
473
+ # Internal
474
+ # -------------------------------------------------------------------
475
+ #
476
+ # PATCH is so enormously complex that we do lots of unit tests on private
477
+ # methods before even bothering with the higher level "unit" (more like
478
+ # integration!) tests on #from_scim_patch! itself.
479
+ #
480
+ # These were used during development to debug the implementation.
481
+ #
482
+ context 'internal unit tests' do
483
+
484
+ # ---------------------------------------------------------------------
485
+ # Internal: #extract_filter_from
486
+ # ---------------------------------------------------------------------
487
+ #
488
+ context '#extract_filter_from' do
489
+ it 'handles normal path components' do
490
+ path_component, filter = @instance.send(:extract_filter_from, path_component: 'emails')
491
+
492
+ expect(path_component).to eql('emails')
493
+ expect(filter ).to be_nil
494
+ end
495
+
496
+ it 'handles path components with filter strings' do
497
+ path_component, filter = @instance.send(:extract_filter_from, path_component: 'addresses[type eq "work"]')
498
+
499
+ expect(path_component).to eql('addresses')
500
+ expect(filter ).to eql('type eq "work"')
501
+ end
502
+ end # "context '#extract_filter_from' do"
503
+
504
+ # ---------------------------------------------------------------------
505
+ # Internal: #all_matching_filter
506
+ # ---------------------------------------------------------------------
507
+ #
508
+ context '#all_matching_filter' do
509
+ it 'complains about unsupported operators' do
510
+ expect do
511
+ @instance.send(:all_matching_filter, filter: 'type ne "work"', within_array: []) do
512
+ fail # Block should never be called!
513
+ end
514
+ end.to raise_error(RuntimeError)
515
+ end
516
+
517
+ it 'complaints about unsupported multiple operators' do
518
+ expect do
519
+ @instance.send(:all_matching_filter, filter: 'type eq "work" and primary eq true', within_array: []) do
520
+ fail # Block should never be called!
521
+ end
522
+ end.to raise_error(RuntimeError)
523
+ end
524
+
525
+ it 'calls block with matches' do
526
+ array = [
527
+ {
528
+ 'type' => 'work',
529
+ 'value' => 'work_1@test.com'
530
+ },
531
+ {
532
+ 'type' => 'home',
533
+ 'value' => 'home@test.com'
534
+ },
535
+ {
536
+ 'type' => 'work',
537
+ 'value' => 'work_2@test.com'
538
+ }
539
+ ]
540
+
541
+ unhandled = ['work_1@test.com', 'work_2@test.com']
542
+
543
+ @instance.send(:all_matching_filter, filter: 'type eq "work"', within_array: array) do |matched_hash, index|
544
+ expect(array[index]).to eql(matched_hash)
545
+
546
+ expect(matched_hash['type']).to eql('work')
547
+ expect(matched_hash).to have_key('value')
548
+
549
+ unhandled.delete(matched_hash['value'])
550
+ end
551
+
552
+ expect(unhandled).to be_empty
553
+ end
554
+
555
+ it 'handles edge cases' do
556
+ array = [
557
+ {
558
+ 'type' => '"work',
559
+ 'value' => 'work_leading_dquote@test.com'
560
+ },
561
+ {
562
+ 'type' => true,
563
+ 'value' => 'boolean@test.com'
564
+ },
565
+ {
566
+ 'type' => 'work"',
567
+ 'value' => 'work_trailing_dquote@test.com'
568
+ }
569
+ ]
570
+
571
+ call_count = 0
572
+
573
+ @instance.send(:all_matching_filter, filter: 'type eq "work', within_array: array) do |matched_hash, index|
574
+ call_count += 1
575
+ expect(matched_hash['value']).to eql('work_leading_dquote@test.com')
576
+ end
577
+
578
+ @instance.send(:all_matching_filter, filter: 'type eq work"', within_array: array) do |matched_hash, index|
579
+ call_count += 1
580
+ expect(matched_hash['value']).to eql('work_trailing_dquote@test.com')
581
+ end
582
+
583
+ @instance.send(:all_matching_filter, filter: 'type eq true', within_array: array) do |matched_hash, index|
584
+ call_count += 1
585
+ expect(matched_hash['value']).to eql('boolean@test.com')
586
+ end
587
+
588
+ expect(call_count).to eql(3)
589
+ end
590
+ end # "context '#all_matching_filter' do"
591
+
592
+ # ---------------------------------------------------------------------
593
+ # Internal: #from_patch_backend
594
+ # ---------------------------------------------------------------------
595
+ #
596
+ context '#from_patch_backend!' do
597
+
598
+ # -------------------------------------------------------------------
599
+ # Internal: #from_patch_backend - add
600
+ # -------------------------------------------------------------------
601
+ #
602
+ # Except for filter and array behaviour at the leaf of the path,
603
+ # "add" and "replace" are pretty much identical.
604
+ #
605
+ context 'add' do
606
+ context 'when prior value already exists' do
607
+ it 'simple value: overwrites' do
608
+ path = [ 'userName' ]
609
+ scim_hash = { 'userName' => 'bar' }
610
+
611
+ @instance.send(
612
+ :from_patch_backend!,
613
+ nature: 'add',
614
+ path: path,
615
+ value: 'foo',
616
+ altering_hash: scim_hash
617
+ )
618
+
619
+ expect(scim_hash['userName']).to eql('foo')
620
+ end
621
+
622
+ it 'nested simple value: overwrites' do
623
+ path = [ 'name', 'givenName' ]
624
+ scim_hash = { 'name' => { 'givenName' => 'Foo', 'familyName' => 'Bar' } }
625
+
626
+ @instance.send(
627
+ :from_patch_backend!,
628
+ nature: 'add',
629
+ path: path,
630
+ value: 'Baz',
631
+ altering_hash: scim_hash
632
+ )
633
+
634
+ expect(scim_hash['name']['givenName' ]).to eql('Baz')
635
+ expect(scim_hash['name']['familyName']).to eql('Bar')
636
+ end
637
+
638
+ # For 'add', filter at end-of-path is nonsensical and not
639
+ # supported by spec or Scimitar; we only test mid-path filters.
640
+ #
641
+ context 'with filter mid-path' do
642
+ it 'by string match: overwrites' do
643
+ path = [ 'emails[type eq "work"]', 'value' ]
644
+ scim_hash = {
645
+ 'emails' => [
646
+ {
647
+ 'type' => 'home',
648
+ 'value' => 'home@test.com'
649
+ },
650
+ {
651
+ 'type' => 'work',
652
+ 'value' => 'work@test.com'
653
+ }
654
+ ]
655
+ }
656
+
657
+ @instance.send(
658
+ :from_patch_backend!,
659
+ nature: 'add',
660
+ path: path,
661
+ value: 'added_over_origina@test.com',
662
+ altering_hash: scim_hash
663
+ )
664
+
665
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
666
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_origina@test.com')
667
+ end
668
+
669
+ it 'by boolean match: overwrites' do
670
+ path = [ 'emails[primary eq true]', 'value' ]
671
+ scim_hash = {
672
+ 'emails' => [
673
+ {
674
+ 'value' => 'home@test.com'
675
+ },
676
+ {
677
+ 'value' => 'work@test.com',
678
+ 'primary' => true
679
+ }
680
+ ]
681
+ }
682
+
683
+ @instance.send(
684
+ :from_patch_backend!,
685
+ nature: 'add',
686
+ path: path,
687
+ value: 'added_over_origina@test.com',
688
+ altering_hash: scim_hash
689
+ )
690
+
691
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
692
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_origina@test.com')
693
+ end
694
+
695
+ it 'multiple matches: overwrites all' do
696
+ path = [ 'emails[type eq "work"]', 'value' ]
697
+ scim_hash = {
698
+ 'emails' => [
699
+ {
700
+ 'type' => 'work',
701
+ 'value' => 'work_1@test.com'
702
+ },
703
+ {
704
+ 'type' => 'work',
705
+ 'value' => 'work_2@test.com'
706
+ }
707
+ ]
708
+ }
709
+
710
+ @instance.send(
711
+ :from_patch_backend!,
712
+ nature: 'add',
713
+ path: path,
714
+ value: 'added_over_origina@test.com',
715
+ altering_hash: scim_hash
716
+ )
717
+
718
+ expect(scim_hash['emails'][0]['value']).to eql('added_over_origina@test.com')
719
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_origina@test.com')
720
+ end
721
+ end # "context 'with filter mid-path' do"
722
+
723
+ it 'with arrays: appends' do
724
+ path = [ 'emails' ]
725
+ scim_hash = {
726
+ 'emails' => [
727
+ {
728
+ 'type' => 'home',
729
+ 'value' => 'home@test.com'
730
+ }
731
+ ]
732
+ }
733
+
734
+ @instance.send(
735
+ :from_patch_backend!,
736
+ nature: 'add',
737
+ path: path,
738
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
739
+ altering_hash: scim_hash
740
+ )
741
+
742
+ expect(scim_hash['emails'].size).to eql(2)
743
+ expect(scim_hash['emails'][1]['type' ]).to eql('work')
744
+ expect(scim_hash['emails'][1]['value']).to eql('work@test.com')
745
+ end
746
+
747
+ context 'with complex value addition' do
748
+ it 'adds to arrays' do
749
+ scim_hash = {
750
+ 'root' => {
751
+ 'members' => [
752
+ {'value' => '1'},
753
+ {'value' => '2'}
754
+ ]
755
+ }
756
+ }
757
+
758
+ # Example seen at:
759
+ #
760
+ # https://docs.databricks.com/dev-tools/api/latest/scim/scim-groups.html
761
+ #
762
+ # The core of it is:
763
+ #
764
+ # "Operations":[
765
+ # {
766
+ # "op":"add",
767
+ # "value":{
768
+ # "members":[
769
+ # {
770
+ # "value":"<user-id>"
771
+ # }
772
+ # ]
773
+ # }
774
+ # }
775
+ # ]
776
+ #
777
+ # ...so the path is missing ("root"), but the value is
778
+ # complex and includes implied paths within. We expect to
779
+ # have the given value Hash added to the members Array,
780
+ # rather than having e.g. members replaced by this.
781
+ #
782
+ @instance.send(
783
+ :from_patch_backend!,
784
+ nature: 'add',
785
+ path: ['root'],
786
+ value: {'members' => [{'value' => '3'}]},
787
+ altering_hash: scim_hash
788
+ )
789
+
790
+ expect(scim_hash['root']['members']).to match_array([{'value' => '1'}, {'value' => '2'}, {'value' => '3'}])
791
+ end
792
+ end # "context 'with complex value addition' do"
793
+ end # context 'when prior value already exists' do
794
+
795
+ context 'when value is not present' do
796
+ it 'simple value: adds' do
797
+ path = [ 'userName' ]
798
+ scim_hash = {}
799
+
800
+ @instance.send(
801
+ :from_patch_backend!,
802
+ nature: 'add',
803
+ path: path,
804
+ value: 'foo',
805
+ altering_hash: scim_hash
806
+ )
807
+
808
+ expect(scim_hash['userName']).to eql('foo')
809
+ end
810
+
811
+ it 'nested simple value: adds' do
812
+ path = [ 'name', 'givenName' ]
813
+ scim_hash = {}
814
+
815
+ @instance.send(
816
+ :from_patch_backend!,
817
+ nature: 'add',
818
+ path: path,
819
+ value: 'Baz',
820
+ altering_hash: scim_hash
821
+ )
822
+
823
+ expect(scim_hash['name']['givenName']).to eql('Baz')
824
+ end
825
+
826
+ context 'with filter mid-path: adds' do
827
+ it 'by string match' do
828
+ path = [ 'emails[type eq "work"]', 'value' ]
829
+ scim_hash = {
830
+ 'emails' => [
831
+ {
832
+ 'type' => 'home',
833
+ 'value' => 'home@test.com'
834
+ },
835
+ {
836
+ 'type' => 'work'
837
+ }
838
+ ]
839
+ }
840
+
841
+ @instance.send(
842
+ :from_patch_backend!,
843
+ nature: 'add',
844
+ path: path,
845
+ value: 'added@test.com',
846
+ altering_hash: scim_hash
847
+ )
848
+
849
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
850
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
851
+ end
852
+
853
+ it 'by boolean match: adds' do
854
+ path = [ 'emails[primary eq true]', 'value' ]
855
+ scim_hash = {
856
+ 'emails' => [
857
+ {
858
+ 'value' => 'home@test.com'
859
+ },
860
+ {
861
+ 'primary' => true
862
+ }
863
+ ]
864
+ }
865
+
866
+ @instance.send(
867
+ :from_patch_backend!,
868
+ nature: 'add',
869
+ path: path,
870
+ value: 'added@test.com',
871
+ altering_hash: scim_hash
872
+ )
873
+
874
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
875
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
876
+ end
877
+
878
+ it 'with no match: still adds' do
879
+ path = [ 'emails[type eq "work"]', 'value' ]
880
+ scim_hash = {}
881
+
882
+ @instance.send(
883
+ :from_patch_backend!,
884
+ nature: 'add',
885
+ path: path,
886
+ value: 'added@test.com',
887
+ altering_hash: scim_hash
888
+ )
889
+
890
+ expect(scim_hash['emails'][0]['value']).to eql('added@test.com')
891
+ end
892
+
893
+ it 'multiple matches: adds to all' do
894
+ path = [ 'emails[type eq "work"]', 'value' ]
895
+ scim_hash = {
896
+ 'emails' => [
897
+ {
898
+ 'type' => 'work'
899
+ },
900
+ {
901
+ 'type' => 'work'
902
+ }
903
+ ]
904
+ }
905
+
906
+ @instance.send(
907
+ :from_patch_backend!,
908
+ nature: 'add',
909
+ path: path,
910
+ value: 'added@test.com',
911
+ altering_hash: scim_hash
912
+ )
913
+
914
+ expect(scim_hash['emails'][0]['value']).to eql('added@test.com')
915
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
916
+ end
917
+ end # "context 'with filter mid-path' do"
918
+
919
+ it 'with arrays: appends' do
920
+ path = [ 'emails' ]
921
+ scim_hash = {}
922
+
923
+ @instance.send(
924
+ :from_patch_backend!,
925
+ nature: 'add',
926
+ path: path,
927
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
928
+ altering_hash: scim_hash
929
+ )
930
+
931
+ expect(scim_hash['emails'].size).to eql(1)
932
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
933
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
934
+ end
935
+ end # context 'when value is not present' do
936
+ end # "context 'add' do"
937
+
938
+ # -------------------------------------------------------------------
939
+ # Internal: #from_patch_backend - remove
940
+ # -------------------------------------------------------------------
941
+ #
942
+ context 'remove' do
943
+ context 'when prior value already exists' do
944
+ it 'simple value: removes' do
945
+ path = [ 'userName' ]
946
+ scim_hash = { 'userName' => 'bar' }
947
+
948
+ @instance.send(
949
+ :from_patch_backend!,
950
+ nature: 'remove',
951
+ path: path,
952
+ value: nil,
953
+ altering_hash: scim_hash
954
+ )
955
+
956
+ expect(scim_hash).to be_empty
957
+ end
958
+
959
+ it 'nested simple value: removes' do
960
+ path = [ 'name', 'givenName' ]
961
+ scim_hash = { 'name' => { 'givenName' => 'Foo', 'familyName' => 'Bar' } }
962
+
963
+ @instance.send(
964
+ :from_patch_backend!,
965
+ nature: 'remove',
966
+ path: path,
967
+ value: nil,
968
+ altering_hash: scim_hash
969
+ )
970
+
971
+ expect(scim_hash['name']).to_not have_key('givenName')
972
+ expect(scim_hash['name']['familyName']).to eql('Bar')
973
+ end
974
+
975
+ context 'with filter mid-path' do
976
+ it 'by string match: removes' do
977
+ path = [ 'emails[type eq "work"]', 'value' ]
978
+ scim_hash = {
979
+ 'emails' => [
980
+ {
981
+ 'type' => 'home',
982
+ 'value' => 'home@test.com'
983
+ },
984
+ {
985
+ 'type' => 'work',
986
+ 'value' => 'work@test.com'
987
+ }
988
+ ]
989
+ }
990
+
991
+ @instance.send(
992
+ :from_patch_backend!,
993
+ nature: 'remove',
994
+ path: path,
995
+ value: nil,
996
+ altering_hash: scim_hash
997
+ )
998
+
999
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1000
+ expect(scim_hash['emails'][1]).to_not have_key('value')
1001
+ end
1002
+
1003
+ it 'by boolean match: removes' do
1004
+ path = [ 'emails[primary eq true]', 'value' ]
1005
+ scim_hash = {
1006
+ 'emails' => [
1007
+ {
1008
+ 'value' => 'home@test.com'
1009
+ },
1010
+ {
1011
+ 'value' => 'work@test.com',
1012
+ 'primary' => true
1013
+ }
1014
+ ]
1015
+ }
1016
+
1017
+ @instance.send(
1018
+ :from_patch_backend!,
1019
+ nature: 'remove',
1020
+ path: path,
1021
+ value: nil,
1022
+ altering_hash: scim_hash
1023
+ )
1024
+
1025
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1026
+ expect(scim_hash['emails'][1]).to_not have_key('value')
1027
+ end
1028
+
1029
+ it 'multiple matches: removes all' do
1030
+ path = [ 'emails[type eq "work"]', 'value' ]
1031
+ scim_hash = {
1032
+ 'emails' => [
1033
+ {
1034
+ 'type' => 'work',
1035
+ 'value' => 'work_1@test.com'
1036
+ },
1037
+ {
1038
+ 'type' => 'work',
1039
+ 'value' => 'work_2@test.com'
1040
+ }
1041
+ ]
1042
+ }
1043
+
1044
+ @instance.send(
1045
+ :from_patch_backend!,
1046
+ nature: 'remove',
1047
+ path: path,
1048
+ value: nil,
1049
+ altering_hash: scim_hash
1050
+ )
1051
+
1052
+ expect(scim_hash['emails'][0]).to_not have_key('value')
1053
+ expect(scim_hash['emails'][1]).to_not have_key('value')
1054
+ end
1055
+ end # "context 'with filter mid-path' do"
1056
+
1057
+ context 'with filter at end of path' do
1058
+ it 'by string match: removes entire matching array entry' do
1059
+ path = [ 'emails[type eq "work"]' ]
1060
+ scim_hash = {
1061
+ 'emails' => [
1062
+ {
1063
+ 'type' => 'home',
1064
+ 'value' => 'home@test.com'
1065
+ },
1066
+ {
1067
+ 'type' => 'work',
1068
+ 'value' => 'work@test.com'
1069
+ }
1070
+ ]
1071
+ }
1072
+
1073
+ @instance.send(
1074
+ :from_patch_backend!,
1075
+ nature: 'remove',
1076
+ path: path,
1077
+ value: nil,
1078
+ altering_hash: scim_hash
1079
+ )
1080
+
1081
+ expect(scim_hash['emails'].size).to eql(1)
1082
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1083
+ end
1084
+
1085
+ it 'by boolean match: removes entire matching array entry' do
1086
+ path = [ 'emails[primary eq true]' ]
1087
+ scim_hash = {
1088
+ 'emails' => [
1089
+ {
1090
+ 'value' => 'home@test.com'
1091
+ },
1092
+ {
1093
+ 'value' => 'work@test.com',
1094
+ 'primary' => true
1095
+ }
1096
+ ]
1097
+ }
1098
+
1099
+ @instance.send(
1100
+ :from_patch_backend!,
1101
+ nature: 'remove',
1102
+ path: path,
1103
+ value: nil,
1104
+ altering_hash: scim_hash
1105
+ )
1106
+
1107
+ expect(scim_hash['emails'].size).to eql(1)
1108
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1109
+ end
1110
+
1111
+ it 'multiple matches: removes all matching array entries' do
1112
+ path = [ 'emails[type eq "work"]' ]
1113
+ scim_hash = {
1114
+ 'emails' => [
1115
+ {
1116
+ 'type' => 'work',
1117
+ 'value' => 'work_1@test.com'
1118
+ },
1119
+ {
1120
+ 'type' => 'work',
1121
+ 'value' => 'work_2@test.com'
1122
+ },
1123
+ {
1124
+ 'type' => 'home',
1125
+ 'value' => 'home@test.com'
1126
+ },
1127
+ ]
1128
+ }
1129
+
1130
+ @instance.send(
1131
+ :from_patch_backend!,
1132
+ nature: 'remove',
1133
+ path: path,
1134
+ value: nil,
1135
+ altering_hash: scim_hash
1136
+ )
1137
+
1138
+ expect(scim_hash['emails'].size).to eql(1)
1139
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1140
+ end
1141
+ end # "context 'with filter at end of path' do"
1142
+
1143
+ it 'whole array: removes' do
1144
+ path = [ 'emails' ]
1145
+ scim_hash = {
1146
+ 'emails' => [
1147
+ {
1148
+ 'type' => 'home',
1149
+ 'value' => 'home@test.com'
1150
+ }
1151
+ ]
1152
+ }
1153
+
1154
+ @instance.send(
1155
+ :from_patch_backend!,
1156
+ nature: 'remove',
1157
+ path: path,
1158
+ value: nil,
1159
+ altering_hash: scim_hash
1160
+ )
1161
+
1162
+ expect(scim_hash).to_not have_key('emails')
1163
+ end
1164
+ end # context 'when prior value already exists' do
1165
+
1166
+ context 'when value is not present' do
1167
+ it 'simple value: does nothing' do
1168
+ path = [ 'userName' ]
1169
+ scim_hash = {}
1170
+
1171
+ @instance.send(
1172
+ :from_patch_backend!,
1173
+ nature: 'remove',
1174
+ path: path,
1175
+ value: nil,
1176
+ altering_hash: scim_hash
1177
+ )
1178
+
1179
+ expect(scim_hash).to be_empty
1180
+ end
1181
+
1182
+ it 'nested simple value: does nothing' do
1183
+ path = [ 'name', 'givenName' ]
1184
+ scim_hash = { 'name' => {'familyName' => 'Bar' } }
1185
+
1186
+ @instance.send(
1187
+ :from_patch_backend!,
1188
+ nature: 'remove',
1189
+ path: path,
1190
+ value: nil,
1191
+ altering_hash: scim_hash
1192
+ )
1193
+
1194
+ expect(scim_hash['name']).to_not have_key('givenName')
1195
+ expect(scim_hash['name']['familyName']).to eql('Bar')
1196
+ end
1197
+
1198
+ context 'with filter mid-path' do
1199
+ it 'by string match: does nothing' do
1200
+ path = [ 'emails[type eq "work"]', 'value' ]
1201
+ scim_hash = {
1202
+ 'emails' => [
1203
+ {
1204
+ 'type' => 'home',
1205
+ 'value' => 'home@test.com'
1206
+ }
1207
+ ]
1208
+ }
1209
+
1210
+ @instance.send(
1211
+ :from_patch_backend!,
1212
+ nature: 'remove',
1213
+ path: path,
1214
+ value: nil,
1215
+ altering_hash: scim_hash
1216
+ )
1217
+
1218
+ expect(scim_hash['emails'].size).to eql(1)
1219
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1220
+ end
1221
+
1222
+ it 'by boolean match: does nothing' do
1223
+ path = [ 'emails[primary eq true]', 'value' ]
1224
+ scim_hash = {
1225
+ 'emails' => [
1226
+ {
1227
+ 'value' => 'home@test.com'
1228
+ }
1229
+ ]
1230
+ }
1231
+
1232
+ @instance.send(
1233
+ :from_patch_backend!,
1234
+ nature: 'remove',
1235
+ path: path,
1236
+ value: nil,
1237
+ altering_hash: scim_hash
1238
+ )
1239
+
1240
+ expect(scim_hash['emails'].size).to eql(1)
1241
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1242
+ end
1243
+
1244
+ it 'multiple matches: does nothing' do
1245
+ path = [ 'emails[type eq "work"]', 'value' ]
1246
+ scim_hash = {
1247
+ 'emails' => [
1248
+ {
1249
+ 'type' => 'home',
1250
+ 'value' => 'home@test.com'
1251
+ }
1252
+ ]
1253
+ }
1254
+
1255
+ @instance.send(
1256
+ :from_patch_backend!,
1257
+ nature: 'remove',
1258
+ path: path,
1259
+ value: nil,
1260
+ altering_hash: scim_hash
1261
+ )
1262
+
1263
+ expect(scim_hash['emails'].size).to eql(1)
1264
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1265
+ end
1266
+ end # "context 'with filter mid-path' do"
1267
+
1268
+ context 'with filter at end of path' do
1269
+ it 'by string match: does nothing' do
1270
+ path = [ 'emails[type eq "work"]' ]
1271
+ scim_hash = {}
1272
+
1273
+ @instance.send(
1274
+ :from_patch_backend!,
1275
+ nature: 'remove',
1276
+ path: path,
1277
+ value: nil,
1278
+ altering_hash: scim_hash
1279
+ )
1280
+
1281
+ expect(scim_hash).to be_empty
1282
+ end
1283
+
1284
+ it 'by boolean match: does nothing' do
1285
+ path = [ 'emails[primary eq true]' ]
1286
+ scim_hash = {
1287
+ 'emails' => [
1288
+ {
1289
+ 'value' => 'home@test.com',
1290
+ 'primary' => false
1291
+ }
1292
+ ]
1293
+ }
1294
+
1295
+ @instance.send(
1296
+ :from_patch_backend!,
1297
+ nature: 'remove',
1298
+ path: path,
1299
+ value: nil,
1300
+ altering_hash: scim_hash
1301
+ )
1302
+
1303
+ expect(scim_hash['emails'].size).to eql(1)
1304
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1305
+ end
1306
+ end # "context 'with filter at end of path' do"
1307
+
1308
+ it 'remove whole array: does nothing' do
1309
+ path = [ 'emails' ]
1310
+ scim_hash = {}
1311
+
1312
+ @instance.send(
1313
+ :from_patch_backend!,
1314
+ nature: 'remove',
1315
+ path: path,
1316
+ value: nil,
1317
+ altering_hash: scim_hash
1318
+ )
1319
+
1320
+ expect(scim_hash).to_not have_key('emails')
1321
+ end
1322
+ end # context 'when value is not present' do
1323
+ end # "context 'remove' do"
1324
+
1325
+ # -------------------------------------------------------------------
1326
+ # Internal: #from_patch_backend - replace
1327
+ # -------------------------------------------------------------------
1328
+ #
1329
+ # Except for filter and array behaviour at the leaf of the path,
1330
+ # "add" and "replace" are pretty much identical.
1331
+ #
1332
+ context 'replace' do
1333
+ context 'when prior value already exists' do
1334
+ it 'simple value: overwrites' do
1335
+ path = [ 'userName' ]
1336
+ scim_hash = { 'userName' => 'bar' }
1337
+
1338
+ @instance.send(
1339
+ :from_patch_backend!,
1340
+ nature: 'replace',
1341
+ path: path,
1342
+ value: 'foo',
1343
+ altering_hash: scim_hash
1344
+ )
1345
+
1346
+ expect(scim_hash['userName']).to eql('foo')
1347
+ end
1348
+
1349
+ it 'nested simple value: overwrites' do
1350
+ path = [ 'name', 'givenName' ]
1351
+ scim_hash = { 'name' => { 'givenName' => 'Foo', 'familyName' => 'Bar' } }
1352
+
1353
+ @instance.send(
1354
+ :from_patch_backend!,
1355
+ nature: 'replace',
1356
+ path: path,
1357
+ value: 'Baz',
1358
+ altering_hash: scim_hash
1359
+ )
1360
+
1361
+ expect(scim_hash['name']['givenName' ]).to eql('Baz')
1362
+ expect(scim_hash['name']['familyName']).to eql('Bar')
1363
+ end
1364
+
1365
+ context 'with filter mid-path' do
1366
+ it 'by string match: overwrites' do
1367
+ path = [ 'emails[type eq "work"]', 'value' ]
1368
+ scim_hash = {
1369
+ 'emails' => [
1370
+ {
1371
+ 'type' => 'home',
1372
+ 'value' => 'home@test.com'
1373
+ },
1374
+ {
1375
+ 'type' => 'work',
1376
+ 'value' => 'work@test.com'
1377
+ }
1378
+ ]
1379
+ }
1380
+
1381
+ @instance.send(
1382
+ :from_patch_backend!,
1383
+ nature: 'replace',
1384
+ path: path,
1385
+ value: 'added_over_origina@test.com',
1386
+ altering_hash: scim_hash
1387
+ )
1388
+
1389
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1390
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_origina@test.com')
1391
+ end
1392
+
1393
+ it 'by boolean match: overwrites' do
1394
+ path = [ 'emails[primary eq true]', 'value' ]
1395
+ scim_hash = {
1396
+ 'emails' => [
1397
+ {
1398
+ 'value' => 'home@test.com'
1399
+ },
1400
+ {
1401
+ 'value' => 'work@test.com',
1402
+ 'primary' => true
1403
+ }
1404
+ ]
1405
+ }
1406
+
1407
+ @instance.send(
1408
+ :from_patch_backend!,
1409
+ nature: 'replace',
1410
+ path: path,
1411
+ value: 'added_over_origina@test.com',
1412
+ altering_hash: scim_hash
1413
+ )
1414
+
1415
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1416
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_origina@test.com')
1417
+ end
1418
+
1419
+ it 'multiple matches: overwrites all' do
1420
+ path = [ 'emails[type eq "work"]', 'value' ]
1421
+ scim_hash = {
1422
+ 'emails' => [
1423
+ {
1424
+ 'type' => 'work',
1425
+ 'value' => 'work_1@test.com'
1426
+ },
1427
+ {
1428
+ 'type' => 'work',
1429
+ 'value' => 'work_2@test.com'
1430
+ }
1431
+ ]
1432
+ }
1433
+
1434
+ @instance.send(
1435
+ :from_patch_backend!,
1436
+ nature: 'replace',
1437
+ path: path,
1438
+ value: 'added_over_origina@test.com',
1439
+ altering_hash: scim_hash
1440
+ )
1441
+
1442
+ expect(scim_hash['emails'][0]['value']).to eql('added_over_origina@test.com')
1443
+ expect(scim_hash['emails'][1]['value']).to eql('added_over_origina@test.com')
1444
+ end
1445
+ end # "context 'with filter mid-path' do"
1446
+
1447
+ context 'with filter at end of path' do
1448
+ it 'by string match: replaces matching array entry' do
1449
+ path = [ 'emails[type eq "work"]' ]
1450
+ scim_hash = {
1451
+ 'emails' => [
1452
+ {
1453
+ 'type' => 'holiday',
1454
+ 'value' => 'holiday@test.com'
1455
+ },
1456
+ {
1457
+ 'type' => 'work',
1458
+ 'value' => 'work@test.com'
1459
+ }
1460
+ ]
1461
+ }
1462
+
1463
+ @instance.send(
1464
+ :from_patch_backend!,
1465
+ nature: 'replace',
1466
+ path: path,
1467
+ value: {'type' => 'home', 'primary' => true, 'value' => 'home@test.com'},
1468
+ altering_hash: scim_hash
1469
+ )
1470
+
1471
+ expect(scim_hash['emails'].size).to eql(2)
1472
+ expect(scim_hash['emails'][0]['type' ]).to eql('holiday') # unchanged
1473
+ expect(scim_hash['emails'][1]['type' ]).to eql('home') # "work" became "home"
1474
+ expect(scim_hash['emails'][1]['primary']).to eql(true)
1475
+ expect(scim_hash['emails'][1]['value' ]).to eql('home@test.com')
1476
+ end
1477
+
1478
+ it 'multiple matches: replaces all matching array entries' do
1479
+ path = [ 'emails[type eq "work"]' ]
1480
+ scim_hash = {
1481
+ 'emails' => [
1482
+ {
1483
+ 'type' => 'work',
1484
+ 'value' => 'work_1@test.com'
1485
+ },
1486
+ {
1487
+ 'type' => 'work',
1488
+ 'value' => 'work_2@test.com'
1489
+ },
1490
+ {
1491
+ 'type' => 'home',
1492
+ 'value' => 'home@test.com'
1493
+ },
1494
+ ]
1495
+ }
1496
+
1497
+ @instance.send(
1498
+ :from_patch_backend!,
1499
+ nature: 'replace',
1500
+ path: path,
1501
+ value: {'type' => 'workinate', 'value' => 'replaced@test.com'},
1502
+ altering_hash: scim_hash
1503
+ )
1504
+
1505
+ expect(scim_hash['emails'].size).to eql(3)
1506
+ expect(scim_hash['emails'][0]['type' ]).to eql('workinate')
1507
+ expect(scim_hash['emails'][0]['value']).to eql('replaced@test.com')
1508
+ expect(scim_hash['emails'][1]['type' ]).to eql('workinate')
1509
+ expect(scim_hash['emails'][1]['value']).to eql('replaced@test.com')
1510
+ expect(scim_hash['emails'][2]['type' ]).to eql('home')
1511
+ expect(scim_hash['emails'][2]['value']).to eql('home@test.com')
1512
+ end
1513
+ end # "context 'with filter at end of path' do"
1514
+
1515
+ it 'with arrays: replaces whole array' do
1516
+ path = [ 'emails' ]
1517
+ scim_hash = {
1518
+ 'emails' => [
1519
+ {
1520
+ 'type' => 'home',
1521
+ 'value' => 'home@test.com'
1522
+ }
1523
+ ]
1524
+ }
1525
+
1526
+ @instance.send(
1527
+ :from_patch_backend!,
1528
+ nature: 'replace',
1529
+ path: path,
1530
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
1531
+ altering_hash: scim_hash
1532
+ )
1533
+
1534
+ expect(scim_hash['emails'].size).to eql(1)
1535
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
1536
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
1537
+ end
1538
+ end # context 'when prior value already exists' do
1539
+
1540
+ context 'when value is not present' do
1541
+ it 'simple value: adds' do
1542
+ path = [ 'userName' ]
1543
+ scim_hash = {}
1544
+
1545
+ @instance.send(
1546
+ :from_patch_backend!,
1547
+ nature: 'replace',
1548
+ path: path,
1549
+ value: 'foo',
1550
+ altering_hash: scim_hash
1551
+ )
1552
+
1553
+ expect(scim_hash['userName']).to eql('foo')
1554
+ end
1555
+
1556
+ it 'nested simple value: adds' do
1557
+ path = [ 'name', 'givenName' ]
1558
+ scim_hash = {}
1559
+
1560
+ @instance.send(
1561
+ :from_patch_backend!,
1562
+ nature: 'replace',
1563
+ path: path,
1564
+ value: 'Baz',
1565
+ altering_hash: scim_hash
1566
+ )
1567
+
1568
+ expect(scim_hash['name']['givenName']).to eql('Baz')
1569
+ end
1570
+
1571
+ context 'with filter mid-path: adds' do
1572
+ it 'by string match' do
1573
+ path = [ 'emails[type eq "work"]', 'value' ]
1574
+ scim_hash = {
1575
+ 'emails' => [
1576
+ {
1577
+ 'type' => 'home',
1578
+ 'value' => 'home@test.com'
1579
+ },
1580
+ {
1581
+ 'type' => 'work'
1582
+ }
1583
+ ]
1584
+ }
1585
+
1586
+ @instance.send(
1587
+ :from_patch_backend!,
1588
+ nature: 'replace',
1589
+ path: path,
1590
+ value: 'added@test.com',
1591
+ altering_hash: scim_hash
1592
+ )
1593
+
1594
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1595
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
1596
+ end
1597
+
1598
+ it 'by boolean match: adds' do
1599
+ path = [ 'emails[primary eq true]', 'value' ]
1600
+ scim_hash = {
1601
+ 'emails' => [
1602
+ {
1603
+ 'value' => 'home@test.com'
1604
+ },
1605
+ {
1606
+ 'primary' => true
1607
+ }
1608
+ ]
1609
+ }
1610
+
1611
+ @instance.send(
1612
+ :from_patch_backend!,
1613
+ nature: 'replace',
1614
+ path: path,
1615
+ value: 'added@test.com',
1616
+ altering_hash: scim_hash
1617
+ )
1618
+
1619
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1620
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
1621
+ end
1622
+
1623
+ it 'multiple matches: adds to all' do
1624
+ path = [ 'emails[type eq "work"]', 'value' ]
1625
+ scim_hash = {
1626
+ 'emails' => [
1627
+ {
1628
+ 'type' => 'work'
1629
+ },
1630
+ {
1631
+ 'type' => 'work'
1632
+ }
1633
+ ]
1634
+ }
1635
+
1636
+ @instance.send(
1637
+ :from_patch_backend!,
1638
+ nature: 'replace',
1639
+ path: path,
1640
+ value: 'added@test.com',
1641
+ altering_hash: scim_hash
1642
+ )
1643
+
1644
+ expect(scim_hash['emails'][0]['value']).to eql('added@test.com')
1645
+ expect(scim_hash['emails'][1]['value']).to eql('added@test.com')
1646
+ end
1647
+ end # "context 'with filter mid-path' do"
1648
+
1649
+ context 'with filter at end of path' do
1650
+ it 'by string match: adds item' do
1651
+ path = [ 'emails[type eq "work"]' ]
1652
+ scim_hash = {}
1653
+
1654
+ @instance.send(
1655
+ :from_patch_backend!,
1656
+ nature: 'replace',
1657
+ path: path,
1658
+ value: {'type' => 'work', 'value' => 'work@test.com'},
1659
+ altering_hash: scim_hash
1660
+ )
1661
+
1662
+ expect(scim_hash['emails'].size).to eql(1)
1663
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
1664
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
1665
+ end
1666
+
1667
+ it 'by boolean match: adds item' do
1668
+ path = [ 'emails[primary eq true]' ]
1669
+ scim_hash = {
1670
+ 'emails' => [
1671
+ {
1672
+ 'value' => 'home@test.com',
1673
+ 'primary' => false
1674
+ }
1675
+ ]
1676
+ }
1677
+
1678
+ @instance.send(
1679
+ :from_patch_backend!,
1680
+ nature: 'replace',
1681
+ path: path,
1682
+ value: {'type' => 'work', 'value' => 'work@test.com'},
1683
+ altering_hash: scim_hash
1684
+ )
1685
+
1686
+ expect(scim_hash['emails'].size).to eql(2)
1687
+ expect(scim_hash['emails'][0]['value']).to eql('home@test.com')
1688
+ expect(scim_hash['emails'][1]['type' ]).to eql('work')
1689
+ expect(scim_hash['emails'][1]['value']).to eql('work@test.com')
1690
+ end
1691
+ end # "context 'with filter at end of path' do"
1692
+
1693
+ it 'with arrays: replaces' do
1694
+ path = [ 'emails' ]
1695
+ scim_hash = {}
1696
+
1697
+ @instance.send(
1698
+ :from_patch_backend!,
1699
+ nature: 'replace',
1700
+ path: path,
1701
+ value: [ { 'type' => 'work', 'value' => 'work@test.com' } ], # NOTE - to-add value is an Array (and must be)
1702
+ altering_hash: scim_hash
1703
+ )
1704
+
1705
+ expect(scim_hash['emails'].size).to eql(1)
1706
+ expect(scim_hash['emails'][0]['type' ]).to eql('work')
1707
+ expect(scim_hash['emails'][0]['value']).to eql('work@test.com')
1708
+ end
1709
+ end # context 'when value is not present' do
1710
+ end # "context 'replace' do"
1711
+
1712
+ # -------------------------------------------------------------------
1713
+ # Internal: #from_patch_backend - some bespoke complex cases
1714
+ # -------------------------------------------------------------------
1715
+ #
1716
+ # I just wanted to be sure...
1717
+ #
1718
+ context 'contrived complexity' do
1719
+ before :each do
1720
+ @contrived_class = Class.new do
1721
+ def self.scim_resource_type
1722
+ return nil
1723
+ end
1724
+
1725
+ def self.scim_attributes_map
1726
+ return {
1727
+ complex: [
1728
+ match: 'type',
1729
+ with: 'type1',
1730
+ using: {
1731
+ data: {
1732
+ nested: [
1733
+ match: 'nature',
1734
+ with: 'nature2',
1735
+ using: {
1736
+ info: {
1737
+ deep: :accessor_method_is_unused_in_this_test
1738
+ }
1739
+ }
1740
+ ]
1741
+ }
1742
+ }
1743
+ ]
1744
+ }
1745
+ end
1746
+
1747
+ def self.scim_mutable_attributes
1748
+ return nil
1749
+ end
1750
+
1751
+ def self.scim_queryable_attributes
1752
+ return nil
1753
+ end
1754
+
1755
+ include Scimitar::Resources::Mixin
1756
+ end
1757
+
1758
+ @original_hash = {
1759
+ 'complex' => [
1760
+ {
1761
+ 'type' => 'type1', # This will match the filter below
1762
+ 'data' => {
1763
+ 'nested' => [
1764
+ {
1765
+ 'nature' => 'nature1', # This will not match
1766
+ 'info' => [
1767
+ { 'deep' => 'nature1deep' }
1768
+ ]
1769
+ },
1770
+ {
1771
+ 'nature' => 'nature2', # This will match the filter below
1772
+ 'info' => [
1773
+ { 'deep' => 'nature2deep1' }
1774
+ ]
1775
+ },
1776
+ {
1777
+ 'nature' => 'nature2', # This will match the filter below
1778
+ 'info' => [
1779
+ { 'deep' => 'nature2deep2' }
1780
+ ]
1781
+ },
1782
+ ]
1783
+ }
1784
+ },
1785
+ {
1786
+ 'type' => 'type1', # This will match the filter below
1787
+ 'data' => {
1788
+ 'nested' => [
1789
+ {
1790
+ 'nature' => 'nature2', # This will match the filter below
1791
+ 'info' => [
1792
+ { 'deep' => 'nature2deep3' }
1793
+ ]
1794
+ }
1795
+ ]
1796
+ }
1797
+ },
1798
+ {
1799
+ 'type' => 'type2', # This will not match
1800
+ 'data' => {
1801
+ 'nested' => [
1802
+ {
1803
+ 'nature' => 'nature2', # This will match the filter below, but is nested inside something that does not match
1804
+ 'info' => [
1805
+ { 'deep' => 'nature2deep3' }
1806
+ ]
1807
+ }
1808
+ ]
1809
+ }
1810
+ }
1811
+ ]
1812
+ }
1813
+ end
1814
+
1815
+ it 'adds across multiple deep matching points' do
1816
+ scim_hash = @original_hash.deep_dup()
1817
+ contrived_instance = @contrived_class.new
1818
+ contrived_instance.send(
1819
+ :from_patch_backend!,
1820
+ nature: 'add',
1821
+ path: ['complex[type eq "type1"]', 'data', 'nested[nature eq "nature2"]', 'info'],
1822
+ value: [{ 'deeper' => 'addition' }],
1823
+ altering_hash: scim_hash
1824
+ )
1825
+
1826
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
1827
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info').count).to eql(2) # One new item
1828
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info').count).to eql(2) # One new item
1829
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info', 1, 'deeper')).to eql('addition')
1830
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info', 1, 'deeper')).to eql('addition')
1831
+
1832
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info').count).to eql(2) # One new item
1833
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info', 1, 'deeper')).to eql('addition')
1834
+
1835
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
1836
+ end
1837
+
1838
+ it 'replaces across multiple deep matching points' do
1839
+ scim_hash = @original_hash.deep_dup()
1840
+ contrived_instance = @contrived_class.new
1841
+ contrived_instance.send(
1842
+ :from_patch_backend!,
1843
+ nature: 'replace',
1844
+ path: ['complex[type eq "type1"]', 'data', 'nested[nature eq "nature2"]', 'info'],
1845
+ value: [{ 'deeper' => 'addition' }],
1846
+ altering_hash: scim_hash
1847
+ )
1848
+
1849
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged?
1850
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info').count).to eql(1)
1851
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info').count).to eql(1)
1852
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info', 0, 'deep')).to eql('nature1deep') # Yes, unchanged.
1853
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info', 0, 'deeper')).to eql('addition')
1854
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'info', 0, 'deeper')).to eql('addition')
1855
+
1856
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info').count).to eql(1)
1857
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info', 0, 'deeper')).to eql('addition')
1858
+
1859
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
1860
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info', 0, 'deep')).to eql('nature2deep3') # Unchanged
1861
+ end
1862
+
1863
+ it 'removes across multiple deep matching points' do
1864
+ scim_hash = @original_hash.deep_dup()
1865
+ contrived_instance = @contrived_class.new
1866
+ contrived_instance.send(
1867
+ :from_patch_backend!,
1868
+ nature: 'remove',
1869
+ path: ['complex[type eq "type1"]', 'data', 'nested[nature eq "nature2"]', 'info'],
1870
+ value: [{ 'deeper' => 'addition' }],
1871
+ altering_hash: scim_hash
1872
+ )
1873
+
1874
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
1875
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info')).to be_nil
1876
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'nature')).to be_present
1877
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 1, 'info')).to be_nil
1878
+ expect(scim_hash.dig('complex', 0, 'data', 'nested', 2, 'nature')).to be_present
1879
+
1880
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'info')).to be_nil
1881
+ expect(scim_hash.dig('complex', 1, 'data', 'nested', 0, 'nature')).to be_present
1882
+
1883
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info').count).to eql(1) # Unchanged
1884
+ expect(scim_hash.dig('complex', 2, 'data', 'nested', 0, 'info', 0, 'deep')).to eql('nature2deep3') # Unchanged
1885
+ end
1886
+ end # "context 'contrived complexity' do"
1887
+
1888
+ # -------------------------------------------------------------------
1889
+ # Internal: #from_patch_backend - error handling
1890
+ # -------------------------------------------------------------------
1891
+ #
1892
+ context 'with bad patches, raises errors' do
1893
+ it 'for unsupported filters' do
1894
+ path = [ 'emails[type ne "work" and value ne "hello@test.com"', 'value' ]
1895
+ scim_hash = {
1896
+ 'emails' => [
1897
+ {
1898
+ 'type' => 'work',
1899
+ 'value' => 'work_1@test.com'
1900
+ },
1901
+ {
1902
+ 'type' => 'work',
1903
+ 'value' => 'work_2@test.com'
1904
+ }
1905
+ ]
1906
+ }
1907
+
1908
+ expect do
1909
+ @instance.send(
1910
+ :from_patch_backend!,
1911
+ nature: 'replace',
1912
+ path: path,
1913
+ value: 'ignored',
1914
+ altering_hash: scim_hash
1915
+ )
1916
+ end.to raise_error(Scimitar::ErrorResponse) { |e| expect(e.as_json['scimType']).to eql('invalidSyntax') }
1917
+ end
1918
+
1919
+ it 'when filters are specified for non-array types' do
1920
+ path = [ 'userName[type eq "work"]', 'value' ]
1921
+ scim_hash = {
1922
+ 'userName' => '1234'
1923
+ }
1924
+
1925
+ expect do
1926
+ @instance.send(
1927
+ :from_patch_backend!,
1928
+ nature: 'replace',
1929
+ path: path,
1930
+ value: 'ignored',
1931
+ altering_hash: scim_hash
1932
+ )
1933
+ end.to raise_error(Scimitar::ErrorResponse) { |e| expect(e.as_json['scimType']).to eql('invalidSyntax') }
1934
+ end
1935
+
1936
+ it 'when a filter tries to match an array which does not contain Hashes' do
1937
+ path = [ 'emails[type eq "work"]', 'value' ]
1938
+ scim_hash = {
1939
+ 'emails' => [
1940
+ 'work_1@test.com',
1941
+ 'work_2@test.com',
1942
+ ]
1943
+ }
1944
+
1945
+ expect do
1946
+ @instance.send(
1947
+ :from_patch_backend!,
1948
+ nature: 'replace',
1949
+ path: path,
1950
+ value: 'ignored',
1951
+ altering_hash: scim_hash
1952
+ )
1953
+ end.to raise_error(Scimitar::ErrorResponse) { |e| expect(e.as_json['scimType']).to eql('invalidSyntax') }
1954
+ end
1955
+ end # context 'with bad patches, raises errors' do
1956
+ end # "context '#from_patch_backend!' do"
1957
+ end # "context 'internal unit tests' do"
1958
+
1959
+ # -------------------------------------------------------------------
1960
+ # Public
1961
+ # -------------------------------------------------------------------
1962
+ #
1963
+ context 'public interface' do
1964
+ it 'updates simple values' do
1965
+ @instance.update!(username: 'foo')
1966
+
1967
+ patch = {
1968
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
1969
+ 'Operations' => [
1970
+ {
1971
+ 'op' => 'replace',
1972
+ 'path' => 'userName',
1973
+ 'value' => '1234'
1974
+ }
1975
+ ]
1976
+ }
1977
+
1978
+ @instance.from_scim_patch!(patch_hash: patch)
1979
+ expect(@instance.username).to eql('1234')
1980
+ end
1981
+
1982
+ it 'updates nested values' do
1983
+ @instance.update!(first_name: 'Foo', last_name: 'Bar')
1984
+
1985
+ patch = {
1986
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
1987
+ 'Operations' => [
1988
+ {
1989
+ 'op' => 'replace',
1990
+ 'path' => 'name.givenName',
1991
+ 'value' => 'Baz'
1992
+ }
1993
+ ]
1994
+ }
1995
+
1996
+ @instance.from_scim_patch!(patch_hash: patch)
1997
+ expect(@instance.first_name).to eql('Baz')
1998
+ end
1999
+
2000
+ it 'updates with filter match' do
2001
+ @instance.update!(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
2002
+
2003
+ patch = {
2004
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2005
+ 'Operations' => [
2006
+ {
2007
+ 'op' => 'replace',
2008
+ 'path' => 'emails[type eq "work"].value',
2009
+ 'value' => 'replaced@test.com'
2010
+ }
2011
+ ]
2012
+ }
2013
+
2014
+ @instance.from_scim_patch!(patch_hash: patch)
2015
+ expect(@instance.work_email_address).to eql('replaced@test.com')
2016
+ expect(@instance.home_email_address).to eql('home@test.com')
2017
+ end
2018
+
2019
+ it 'appends e-mails' do
2020
+ @instance.update!(work_email_address: 'work@test.com')
2021
+
2022
+ patch = {
2023
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2024
+ 'Operations' => [
2025
+ {
2026
+ 'op' => 'add',
2027
+ 'path' => 'emails[type eq "home"].value',
2028
+ 'value' => 'home@test.com'
2029
+ }
2030
+ ]
2031
+ }
2032
+
2033
+ @instance.from_scim_patch!(patch_hash: patch)
2034
+ expect(@instance.work_email_address).to eql('work@test.com')
2035
+ expect(@instance.home_email_address).to eql('home@test.com')
2036
+ end
2037
+
2038
+ it 'removes e-mails' do
2039
+ @instance.update!(work_email_address: 'work@test.com', home_email_address: 'home@test.com')
2040
+
2041
+ patch = {
2042
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2043
+ 'Operations' => [
2044
+ {
2045
+ 'op' => 'remove',
2046
+ 'path' => 'emails[type eq "home"]'
2047
+ }
2048
+ ]
2049
+ }
2050
+
2051
+ @instance.from_scim_patch!(patch_hash: patch)
2052
+ expect(@instance.work_email_address).to eql('work@test.com')
2053
+ expect(@instance.home_email_address).to be_nil
2054
+ end
2055
+
2056
+ it 'can patch the whole object' do
2057
+ @instance.update!(username: 'foo')
2058
+
2059
+ patch = {
2060
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2061
+ 'Operations' => [
2062
+ {
2063
+ 'op' => 'replace',
2064
+ 'value' => {'userName' => '1234', 'name' => {'givenName' => 'Bar'}}
2065
+ }
2066
+ ]
2067
+ }
2068
+
2069
+ @instance.from_scim_patch!(patch_hash: patch)
2070
+ expect(@instance.username).to eql('1234')
2071
+ expect(@instance.first_name).to eql('Bar')
2072
+ end
2073
+
2074
+ it 'treats operation types as case-insensitive' do
2075
+ @instance.update!(username: 'foo')
2076
+
2077
+ patch = {
2078
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2079
+ 'Operations' => [
2080
+ {
2081
+ 'op' => 'REPLACE', # Note upper case
2082
+ 'path' => 'userName',
2083
+ 'value' => '1234'
2084
+ }
2085
+ ]
2086
+ }
2087
+
2088
+ @instance.from_scim_patch!(patch_hash: patch)
2089
+ expect(@instance.username).to eql('1234')
2090
+ end
2091
+
2092
+ it 'complains about bad operation types' do
2093
+ patch = {
2094
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2095
+ 'Operations' => [
2096
+ {
2097
+ 'op' => 'invalidop',
2098
+ 'path' => 'userName',
2099
+ 'value' => '1234'
2100
+ }
2101
+ ]
2102
+ }
2103
+
2104
+ expect { @instance.from_scim_patch!(patch_hash: patch) }.to raise_error(Scimitar::ErrorResponse) do |e|
2105
+ expect(e.as_json['scimType']).to eql('invalidSyntax')
2106
+ expect(e.as_json[:detail ]).to include('invalidop')
2107
+ end
2108
+ end
2109
+
2110
+ it 'complains about a missing target for "remove" operations' do
2111
+ patch = {
2112
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2113
+ 'Operations' => [
2114
+ {
2115
+ 'op' => 'remove'
2116
+ }
2117
+ ]
2118
+ }
2119
+
2120
+ expect { @instance.from_scim_patch!(patch_hash: patch) }.to raise_error(Scimitar::ErrorResponse) do |e|
2121
+ expect(e.as_json['scimType']).to eql('noTarget')
2122
+ end
2123
+ end
2124
+ end # "context 'public interface' do"
2125
+ end # "context '#from_scim_patch!' do"
2126
+ end # "context 'with good class definitons' do"
2127
+ end # "RSpec.describe Scimitar::Resources::Mixin do"