scimitar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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"