scimitar 1.7.1 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e45655762e444503f3ee367b077dce8b2699752d17e0b49e8b73acd1303e8d1
4
- data.tar.gz: f1a45bbc7655e1050251aed693d9c7e7c4df9f6a8d26c9f1cde6dba9cf2bc18a
3
+ metadata.gz: b879f0ede10fe831a162adc658e6f2642d8c058afb5349ea8b753e2a9cc6c292
4
+ data.tar.gz: 04644e6cb710a444684746d707170802f38b31586a4207016eb482c05e5e063e
5
5
  SHA512:
6
- metadata.gz: 12f7e2e62278accd66eb1c98ca51041eea8a808dbbb6e2f2dd887acbfce2ffbcdb173566b9af3152bbd9d9aa9df629eb6689d5a28ee33aae776502135581672f
7
- data.tar.gz: d282d93b1154cce8e3d868e8e50f3de35be2af9d0c681862da1495436cb5430443576c2dbae1c5296d964ca61193fb30769da11ece3bd72238c0ad121bbee5d2
6
+ metadata.gz: 6bf7d2818647dddc9ef9047b59391486edaba33af74179a0481fd18ab6f4a1e9917e700fe58699e6754ee64105cb747f687ca4514b415c5c246d082f649e7d54
7
+ data.tar.gz: e92aaaaf03874468606a4f673f13b81edc799c724a43df6813b8e6836e06c5e76ba8fe0230fdfbc6fd089b4fc467440bdd35b72a833cb9c52fda8abeeebf4fd1
data/README.md CHANGED
@@ -262,6 +262,45 @@ end
262
262
 
263
263
  All data-layer actions are taken via `#find` or `#save!`, with exceptions such as `ActiveRecord::RecordNotFound`, `ActiveRecord::RecordInvalid` or generalised SCIM exceptions handled by various superclasses. For a real Rails example of this, see the [test suite's controllers](https://github.com/RIPAGlobal/scimitar/tree/main/spec/apps/dummy/app/controllers) which are invoked via its [routing declarations](https://github.com/RIPAGlobal/scimitar/blob/main/spec/apps/dummy/config/routes.rb).
264
264
 
265
+ ##### Overriding controller methods
266
+
267
+ You can overwrite write-based controller methods `#create`, `#update`, `#replace` and `#destroy` in your controller subclass, should you wish, wherein a call to `super` is passed a block. The block is invoked with the instance of a new unsaved record for `#create`, the updated record that needs to have its changes saved for `#update` and `#replace` and the record that should be destroyed for `#destroy`. This allows you to do things like applying business logic, default values, extra request-derived data and so-forth before then calling `record.save!`, or using some different method other than `record.destroy!` to discard a record (e.g. you might be using soft-delete, or want to skip all callbacks for some reason via `record.delete`).
268
+
269
+ * The `#destroy` method just calls `record.destroy!` unless a block is given, with nothing much else to say about it.
270
+
271
+ * The other methods all establish a database transaction and call through to the _controller's_ protected `#save!` method, passing it the record; it is _this_ method which then either calls `record.save!` or invokes a block. Using the exception-throwing versions of persistence methods is recommended, as there is exception handling within the controller's implementation which rescues things like `ActiveRecord::RecordInvalid` and builds an appropriate SCIM error response when they occur. You can change the list of exceptions handled in this way by overriding protected method `#scimitar_rescuable_exceptions'.
272
+
273
+ * If you want to override saving behaviour for both new and modified records, overriding `#save!` in your controller subclass, rather than overriding all of `#create`, `#update` and `#replace`, is likely to be the better choice.
274
+
275
+ * For more information, see the [RDoc output for `Scimitar::ActiveRecordBackedResourcesController`](https://www.rubydoc.info/github/RIPAGlobal/scimitar/main/Scimitar/ActiveRecordBackedResourcesController).
276
+
277
+ Example:
278
+
279
+ ```ruby
280
+ module Scim
281
+ class UsersController < Scimitar::ActiveRecordBackedResourcesController
282
+
283
+ # Create all new records with some special internal field set to a value
284
+ # determined by a bespoke-to-your-application mechanism.
285
+ #
286
+ def create
287
+ super do | user |
288
+ user.some_special_on_creation_field = method_that_calculates_value()
289
+ user.save!
290
+ end
291
+ end
292
+
293
+ # Use #discard! rather than #destroy! as an example of soft-delete via the
294
+ # 'discard' gem - https://rubygems.org/gems/discard.
295
+ #
296
+ def destroy
297
+ super do | user |
298
+ user.discard!
299
+ end
300
+ end
301
+ end
302
+ ```
303
+
265
304
  #### Queries & Optimisations
266
305
 
267
306
  The scope can be optimised to eager load the data exposed by the SCIM interface, i.e.:
@@ -60,12 +60,20 @@ module Scimitar
60
60
 
61
61
  # POST (create)
62
62
  #
63
- def create
63
+ # Calls #save! on the new record if no block is given, else invokes the
64
+ # block, passing it the new ActiveRecord model instance to be saved. It
65
+ # is up to the block to make any further changes and persist the record.
66
+ #
67
+ # Blocks are invoked from within a wrapping database transaction.
68
+ # ActiveRecord::RecordInvalid exceptions are handled for you, rendering
69
+ # an appropriate SCIM error.
70
+ #
71
+ def create(&block)
64
72
  super do |scim_resource|
65
73
  self.storage_class().transaction do
66
74
  record = self.storage_class().new
67
75
  record.from_scim!(scim_hash: scim_resource.as_json())
68
- self.save!(record)
76
+ self.save!(record, &block)
69
77
  record_to_scim(record)
70
78
  end
71
79
  end
@@ -73,12 +81,16 @@ module Scimitar
73
81
 
74
82
  # PUT (replace)
75
83
  #
76
- def replace
84
+ # Calls #save! on the updated record if no block is given, else invokes the
85
+ # block, passing the updated record which the block must persist, with the
86
+ # same rules as for #create.
87
+ #
88
+ def replace(&block)
77
89
  super do |record_id, scim_resource|
78
90
  self.storage_class().transaction do
79
91
  record = self.find_record(record_id)
80
92
  record.from_scim!(scim_hash: scim_resource.as_json())
81
- self.save!(record)
93
+ self.save!(record, &block)
82
94
  record_to_scim(record)
83
95
  end
84
96
  end
@@ -86,12 +98,16 @@ module Scimitar
86
98
 
87
99
  # PATCH (update)
88
100
  #
89
- def update
101
+ # Calls #save! on the updated record if no block is given, else invokes the
102
+ # block, passing the updated record which the block must persist, with the
103
+ # same rules as for #create.
104
+ #
105
+ def update(&block)
90
106
  super do |record_id, patch_hash|
91
107
  self.storage_class().transaction do
92
108
  record = self.find_record(record_id)
93
109
  record.from_scim_patch!(patch_hash: patch_hash)
94
- self.save!(record)
110
+ self.save!(record, &block)
95
111
  record_to_scim(record)
96
112
  end
97
113
  end
@@ -134,6 +150,17 @@ module Scimitar
134
150
  raise NotImplementedError
135
151
  end
136
152
 
153
+ # Return an Array of exceptions that #save! can rescue and handle with a
154
+ # SCIM error automatically.
155
+ #
156
+ def scimitar_rescuable_exceptions
157
+ [
158
+ ActiveRecord::RecordInvalid,
159
+ ActiveRecord::RecordNotSaved,
160
+ ActiveRecord::RecordNotUnique,
161
+ ]
162
+ end
163
+
137
164
  # Find a record by ID. Subclasses can override this if they need special
138
165
  # lookup behaviour.
139
166
  #
@@ -173,17 +200,25 @@ module Scimitar
173
200
  else
174
201
  record.save!
175
202
  end
176
- rescue ActiveRecord::RecordInvalid => exception
177
- handle_invalid_record(exception.record)
203
+ rescue *self.scimitar_rescuable_exceptions() => exception
204
+ handle_on_save_exception(record, exception)
178
205
  end
179
206
 
180
- # Deal with validation errors by responding with an appropriate SCIM
181
- # error.
207
+ # Deal with exceptions related to errors upon saving, by responding with
208
+ # an appropriate SCIM error. This is most effective if the record has
209
+ # validation errors defined, but falls back to the provided exception's
210
+ # message otherwise.
182
211
  #
183
- # +record+:: The record with validation errors.
212
+ # +record+:: The record that provoked the exception. Mandatory.
213
+ # +exception+:: The exception that was raised. If omitted, a default of
214
+ # 'Unknown', in English with no I18n, is used.
184
215
  #
185
- def handle_invalid_record(record)
186
- joined_errors = record.errors.full_messages.join('; ')
216
+ def handle_on_save_exception(record, exception = RuntimeError.new('Unknown'))
217
+ details = if record.errors.present?
218
+ record.errors.full_messages.join('; ')
219
+ else
220
+ exception.message
221
+ end
187
222
 
188
223
  # https://tools.ietf.org/html/rfc7644#page-12
189
224
  #
@@ -193,14 +228,14 @@ module Scimitar
193
228
  # status code 409 (Conflict) with a "scimType" error code of
194
229
  # "uniqueness"
195
230
  #
196
- if record.errors.any? { | e | e.type == :taken }
231
+ if exception.is_a?(ActiveRecord::RecordNotUnique) || record.errors.any? { | e | e.type == :taken }
197
232
  raise Scimitar::ErrorResponse.new(
198
233
  status: 409,
199
234
  scimType: 'uniqueness',
200
- detail: joined_errors
235
+ detail: "Operation failed due to a uniqueness constraint: #{details}"
201
236
  )
202
237
  else
203
- raise Scimitar::ResourceInvalidError.new(joined_errors)
238
+ raise Scimitar::ResourceInvalidError.new(details)
204
239
  end
205
240
  end
206
241
 
@@ -139,11 +139,15 @@ module Scimitar
139
139
 
140
140
  def authenticated?
141
141
  result = if Scimitar.engine_configuration.basic_authenticator.present?
142
- authenticate_with_http_basic(&Scimitar.engine_configuration.basic_authenticator)
142
+ authenticate_with_http_basic do |username, password|
143
+ instance_exec(username, password, &Scimitar.engine_configuration.basic_authenticator)
144
+ end
143
145
  end
144
146
 
145
147
  result ||= if Scimitar.engine_configuration.token_authenticator.present?
146
- authenticate_with_http_token(&Scimitar.engine_configuration.token_authenticator)
148
+ authenticate_with_http_token do |token, options|
149
+ instance_exec(token, options, &Scimitar.engine_configuration.token_authenticator)
150
+ end
147
151
  end
148
152
 
149
153
  return result
@@ -7,12 +7,6 @@ module Scimitar
7
7
  #
8
8
  class Address < Base
9
9
  set_schema Scimitar::Schema::Address
10
-
11
- # Returns the JSON representation of an Address.
12
- #
13
- def as_json(options = {})
14
- {'type' => 'work'}.merge(super(options))
15
- end
16
10
  end
17
11
  end
18
12
  end
@@ -2,7 +2,7 @@ module Scimitar
2
2
  class ResourceInvalidError < ErrorResponse
3
3
 
4
4
  def initialize(error_message)
5
- super(status: 400, scimType: 'invalidValue', detail:"Operation failed since record has become invalid: #{error_message}")
5
+ super(status: 400, scimType: 'invalidValue', detail: "Operation failed since record has become invalid: #{error_message}")
6
6
  end
7
7
 
8
8
  end
@@ -952,7 +952,10 @@ module Scimitar
952
952
 
953
953
  when 'replace'
954
954
  if path_component == 'root'
955
- altering_hash[path_component].merge!(value)
955
+ dot_pathed_value = value.inject({}) do |hash, (k, v)|
956
+ hash.deep_merge!(::Scimitar::Support::Utilities.dot_path(k.split('.'), v))
957
+ end
958
+ altering_hash[path_component].deep_merge!(dot_pathed_value)
956
959
  else
957
960
  altering_hash[path_component] = value
958
961
  end
@@ -0,0 +1,51 @@
1
+ module Scimitar
2
+
3
+ # Namespace containing various chunks of Scimitar support code that don't
4
+ # logically fit into other areas.
5
+ #
6
+ module Support
7
+
8
+ # A namespace that contains various stand-alone utility methods which act
9
+ # as helpers for other parts of the code base, without risking namespace
10
+ # pollution by e.g. being part of a module loaded into a client class.
11
+ #
12
+ module Utilities
13
+
14
+ # Takes an array of components that usually come from a dotted path such
15
+ # as <tt>foo.bar.baz</tt>, along with a value that is found at the end of
16
+ # that path, then converts it into a nested Hash with each level of the
17
+ # Hash corresponding to a step along the path.
18
+ #
19
+ # This was written to help with edge case SCIM uses where (most often, at
20
+ # least) inbound calls use a dotted notation where nested values are more
21
+ # commonly accepted; converting to nesting makes it easier for subsequent
22
+ # processing code, which needs only handle nested Hash data.
23
+ #
24
+ # As an example, passing:
25
+ #
26
+ # ['foo', 'bar', 'baz'], 'value'
27
+ #
28
+ # ...yields:
29
+ #
30
+ # {'foo' => {'bar' => {'baz' => 'value'}}}
31
+ #
32
+ # Parameters:
33
+ #
34
+ # +array+:: Array containing path components, usually acquired from a
35
+ # string with dot separators and a call to String#split.
36
+ #
37
+ # +value+:: The value found at the path indicated by +array+.
38
+ #
39
+ # If +array+ is empty, +value+ is returned directly, with no nesting
40
+ # Hash wrapping it.
41
+ #
42
+ def self.dot_path(array, value)
43
+ return value if array.empty?
44
+
45
+ {}.tap do | hash |
46
+ hash[array.shift()] = self.dot_path(array, value)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,11 +3,11 @@ module Scimitar
3
3
  # Gem version. If this changes, be sure to re-run "bundle install" or
4
4
  # "bundle update".
5
5
  #
6
- VERSION = '1.7.1'
6
+ VERSION = '1.8.0'
7
7
 
8
8
  # Date for VERSION. If this changes, be sure to re-run "bundle install"
9
9
  # or "bundle update".
10
10
  #
11
- DATE = '2023-11-15'
11
+ DATE = '2024-01-15'
12
12
 
13
13
  end
data/lib/scimitar.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'scimitar/version'
2
2
  require 'scimitar/support/hash_with_indifferent_case_insensitive_access'
3
+ require 'scimitar/support/utilities'
3
4
  require 'scimitar/engine'
4
5
 
5
6
  module Scimitar
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'create' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomCreateMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def create
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'replace' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomReplaceMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def replace
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end
@@ -0,0 +1,25 @@
1
+ # For tests only - uses custom 'update' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#create.
3
+ #
4
+ class CustomUpdateMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ OVERRIDDEN_NAME = SecureRandom.uuid
7
+
8
+ def update
9
+ super do | resource |
10
+ resource.first_name = OVERRIDDEN_NAME
11
+ resource.save!
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def storage_class
18
+ MockUser
19
+ end
20
+
21
+ def storage_scope
22
+ MockUser.all
23
+ end
24
+
25
+ end
@@ -17,10 +17,22 @@ Rails.application.routes.draw do
17
17
  get 'Groups/:id', to: 'mock_groups#show'
18
18
  patch 'Groups/:id', to: 'mock_groups#update'
19
19
 
20
- # For testing blocks passed to ActiveRecordBackedResourcesController#destroy
20
+ # For testing blocks passed to ActiveRecordBackedResourcesController#create,
21
+ # #update, #replace and #destroy.
21
22
  #
23
+ post 'CustomCreateUsers', to: 'custom_create_mock_users#create'
24
+ patch 'CustomUpdateUsers/:id', to: 'custom_update_mock_users#update'
25
+ put 'CustomReplaceUsers/:id', to: 'custom_replace_mock_users#replace'
22
26
  delete 'CustomDestroyUsers/:id', to: 'custom_destroy_mock_users#destroy'
23
27
 
28
+ # Needed because the auto-render of most of the above includes a 'url_for'
29
+ # call for a 'show' action, so we must include routes (implemented in the
30
+ # base class) for the "show" endpoint.
31
+ #
32
+ get 'CustomCreateUsers/:id', to: 'custom_create_mock_users#show'
33
+ get 'CustomUpdateUsers/:id', to: 'custom_update_mock_users#show'
34
+ get 'CustomReplaceUsers/:id', to: 'custom_replace_mock_users#show'
35
+
24
36
  # For testing blocks passed to ActiveRecordBackedResourcesController#save!
25
37
  #
26
38
  post 'CustomSaveUsers', to: 'custom_save_mock_users#create'
@@ -107,6 +107,60 @@ RSpec.describe Scimitar::ApplicationController do
107
107
  end
108
108
  end
109
109
 
110
+ context 'authenticator evaluated within controller context' do
111
+
112
+ # Define a controller with a custom instance method 'valid_token'.
113
+ #
114
+ controller do
115
+ def index
116
+ render json: { 'message' => 'cool, cool!' }, format: :scim
117
+ end
118
+
119
+ def valid_token
120
+ 'B'
121
+ end
122
+ end
123
+
124
+ # Call the above controller method from the token authenticator Proc,
125
+ # proving that it was executed in the controller's context.
126
+ #
127
+ before do
128
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new(
129
+ token_authenticator: Proc.new do | token, options |
130
+ token == self.valid_token()
131
+ end
132
+ )
133
+ end
134
+
135
+ it 'renders success when valid creds are given' do
136
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer B'
137
+
138
+ get :index, params: { format: :scim }
139
+ expect(response).to be_ok
140
+ expect(JSON.parse(response.body)).to eql({ 'message' => 'cool, cool!' })
141
+ expect(response.headers['WWW-Authenticate']).to eql('Bearer')
142
+ end
143
+
144
+ it 'renders failure with bad token' do
145
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer Invalid'
146
+
147
+ get :index, params: { format: :scim }
148
+ expect(response).not_to be_ok
149
+ end
150
+
151
+ it 'renders failure with blank token' do
152
+ request.env['HTTP_AUTHORIZATION'] = 'Bearer'
153
+
154
+ get :index, params: { format: :scim }
155
+ expect(response).not_to be_ok
156
+ end
157
+
158
+ it 'renders failure with missing header' do
159
+ get :index, params: { format: :scim }
160
+ expect(response).not_to be_ok
161
+ end
162
+ end
163
+
110
164
  context 'authenticated' do
111
165
  controller do
112
166
  rescue_from StandardError, with: :handle_resource_not_found
@@ -2,8 +2,8 @@ require 'spec_helper'
2
2
 
3
3
  RSpec.describe Scimitar::ComplexTypes::Address do
4
4
  context '#as_json' do
5
- it 'assumes a type of "work" as a default' do
6
- expect(described_class.new.as_json).to eq('type' => 'work')
5
+ it 'assumes no defaults' do
6
+ expect(described_class.new.as_json).to eq({})
7
7
  end
8
8
 
9
9
  it 'allows a custom address type' do
@@ -11,9 +11,8 @@ RSpec.describe Scimitar::ComplexTypes::Address do
11
11
  end
12
12
 
13
13
  it 'shows the set address' do
14
- expect(described_class.new(country: 'NZ').as_json).to eq('type' => 'work', 'country' => 'NZ')
14
+ expect(described_class.new(country: 'NZ').as_json).to eq('country' => 'NZ')
15
15
  end
16
16
  end
17
17
 
18
18
  end
19
-
@@ -2717,6 +2717,28 @@ RSpec.describe Scimitar::Resources::Mixin do
2717
2717
  expect(@instance.username).to eql('1234')
2718
2718
  end
2719
2719
 
2720
+ it 'which updates nested values using root syntax' do
2721
+ @instance.update!(first_name: 'Foo', last_name: 'Bar')
2722
+
2723
+ path = 'name.givenName'
2724
+ path = path.upcase if force_upper_case
2725
+
2726
+ patch = {
2727
+ 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
2728
+ 'Operations' => [
2729
+ {
2730
+ 'op' => 'replace',
2731
+ 'value' => {
2732
+ path => 'Baz'
2733
+ }
2734
+ }
2735
+ ]
2736
+ }
2737
+
2738
+ @instance.from_scim_patch!(patch_hash: patch)
2739
+ expect(@instance.first_name).to eql('Baz')
2740
+ end
2741
+
2720
2742
  it 'which updates nested values' do
2721
2743
  @instance.update!(first_name: 'Foo', last_name: 'Bar')
2722
2744
 
@@ -13,9 +13,9 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
13
13
  lmt = Time.parse("2023-01-09 14:25:00 +1300")
14
14
  ids = 3.times.map { SecureRandom.uuid }.sort()
15
15
 
16
- @u1 = MockUser.create(primary_key: ids.shift(), username: '1', first_name: 'Foo', last_name: 'Ark', home_email_address: 'home_1@test.com', scim_uid: '001', created_at: lmt, updated_at: lmt + 1)
17
- @u2 = MockUser.create(primary_key: ids.shift(), username: '2', first_name: 'Foo', last_name: 'Bar', home_email_address: 'home_2@test.com', scim_uid: '002', created_at: lmt, updated_at: lmt + 2)
18
- @u3 = MockUser.create(primary_key: ids.shift(), username: '3', first_name: 'Foo', home_email_address: 'home_3@test.com', scim_uid: '003', created_at: lmt, updated_at: lmt + 3)
16
+ @u1 = MockUser.create!(primary_key: ids.shift(), username: '1', first_name: 'Foo', last_name: 'Ark', home_email_address: 'home_1@test.com', scim_uid: '001', created_at: lmt, updated_at: lmt + 1)
17
+ @u2 = MockUser.create!(primary_key: ids.shift(), username: '2', first_name: 'Foo', last_name: 'Bar', home_email_address: 'home_2@test.com', scim_uid: '002', created_at: lmt, updated_at: lmt + 2)
18
+ @u3 = MockUser.create!(primary_key: ids.shift(), username: '3', first_name: 'Foo', home_email_address: 'home_3@test.com', scim_uid: '003', created_at: lmt, updated_at: lmt + 3)
19
19
 
20
20
  @g1 = MockGroup.create!(display_name: 'Group 1')
21
21
  @g2 = MockGroup.create!(display_name: 'Group 2')
@@ -315,7 +315,12 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
315
315
  attributes = { userName: '4' } # Minimum required by schema
316
316
  attributes = spec_helper_hupcase(attributes) if force_upper_case
317
317
 
318
+ # Prove that certain known pathways are called; can then unit test
319
+ # those if need be and be sure that this covers #create actions.
320
+ #
318
321
  expect_any_instance_of(MockUsersController).to receive(:create).once.and_call_original
322
+ expect_any_instance_of(MockUsersController).to receive(:save! ).once.and_call_original
323
+
319
324
  expect {
320
325
  post "/Users", params: attributes.merge(format: :scim)
321
326
  }.to change { MockUser.count }.by(1)
@@ -442,23 +447,74 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
442
447
  expect(result['detail']).to include('is reserved')
443
448
  end
444
449
 
445
- it 'invokes a block if given one' do
446
- mock_before = MockUser.all.to_a
447
- attributes = { userName: '5' } # Minimum required by schema
450
+ context 'with a block' do
451
+ it 'invokes the block' do
452
+ mock_before = MockUser.all.to_a
448
453
 
449
- expect_any_instance_of(CustomSaveMockUsersController).to receive(:create).once.and_call_original
450
- expect {
451
- post "/CustomSaveUsers", params: attributes.merge(format: :scim)
452
- }.to change { MockUser.count }.by(1)
454
+ expect_any_instance_of(CustomCreateMockUsersController).to receive(:create).once.and_call_original
455
+ expect {
456
+ post "/CustomCreateUsers", params: {
457
+ format: :scim,
458
+ userName: '4' # Minimum required by schema
459
+ }
460
+ }.to change { MockUser.count }.by(1)
453
461
 
454
- mock_after = MockUser.all.to_a
455
- new_mock = (mock_after - mock_before).first
462
+ mock_after = MockUser.all.to_a
463
+ new_mock = (mock_after - mock_before).first
456
464
 
457
- expect(response.status ).to eql(201)
458
- expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
465
+ expect(response.status ).to eql(201)
466
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
459
467
 
460
- expect(new_mock.username).to eql(CustomSaveMockUsersController::CUSTOM_SAVE_BLOCK_USERNAME_INDICATOR)
461
- end
468
+ result = JSON.parse(response.body)
469
+
470
+ expect(result['id']).to eql(new_mock.id.to_s)
471
+ expect(result['meta']['resourceType']).to eql('User')
472
+ expect(new_mock.first_name).to eql(CustomCreateMockUsersController::OVERRIDDEN_NAME)
473
+ end
474
+
475
+ it 'returns 409 for duplicates (by Rails validation)' do
476
+ existing_user = MockUser.create!(
477
+ username: '4',
478
+ first_name: 'Will Be Overridden',
479
+ last_name: 'Baz',
480
+ home_email_address: 'random@test.com',
481
+ scim_uid: '999'
482
+ )
483
+
484
+ expect_any_instance_of(CustomCreateMockUsersController).to receive(:create).once.and_call_original
485
+ expect {
486
+ post "/CustomCreateUsers", params: {
487
+ format: :scim,
488
+ userName: '4' # Already exists
489
+ }
490
+ }.to_not change { MockUser.count }
491
+
492
+ expect(response.status ).to eql(409)
493
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
494
+
495
+ result = JSON.parse(response.body)
496
+
497
+ expect(result['scimType']).to eql('uniqueness')
498
+ expect(result['detail']).to include('already been taken')
499
+ end
500
+
501
+ it 'notes Rails validation failures' do
502
+ expect_any_instance_of(CustomCreateMockUsersController).to receive(:create).once.and_call_original
503
+ expect {
504
+ post "/CustomCreateUsers", params: {
505
+ format: :scim,
506
+ userName: MockUser::INVALID_USERNAME
507
+ }
508
+ }.to_not change { MockUser.count }
509
+
510
+ expect(response.status ).to eql(400)
511
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
512
+
513
+ result = JSON.parse(response.body)
514
+ expect(result['scimType']).to eql('invalidValue')
515
+ expect(result['detail']).to include('is reserved')
516
+ end
517
+ end # "context 'with a block' do"
462
518
  end # "context '#create' do"
463
519
 
464
520
  # ===========================================================================
@@ -469,7 +525,12 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
469
525
  attributes = { userName: '4' } # Minimum required by schema
470
526
  attributes = spec_helper_hupcase(attributes) if force_upper_case
471
527
 
528
+ # Prove that certain known pathways are called; can then unit test
529
+ # those if need be and be sure that this covers #replace actions.
530
+ #
472
531
  expect_any_instance_of(MockUsersController).to receive(:replace).once.and_call_original
532
+ expect_any_instance_of(MockUsersController).to receive(:save! ).once.and_call_original
533
+
473
534
  expect {
474
535
  put "/Users/#{@u2.primary_key}", params: attributes.merge(format: :scim)
475
536
  }.to_not change { MockUser.count }
@@ -525,7 +586,7 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
525
586
 
526
587
  it 'notes Rails validation failures' do
527
588
  expect {
528
- post "/Users", params: {
589
+ put "/Users/#{@u2.primary_key}", params: {
529
590
  format: :scim,
530
591
  userName: MockUser::INVALID_USERNAME
531
592
  }
@@ -562,6 +623,58 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
562
623
 
563
624
  expect(result['status']).to eql('404')
564
625
  end
626
+
627
+ context 'with a block' do
628
+ it 'invokes the block' do
629
+ attributes = { userName: '4' } # Minimum required by schema
630
+
631
+ expect_any_instance_of(CustomReplaceMockUsersController).to receive(:replace).once.and_call_original
632
+ expect {
633
+ put "/CustomReplaceUsers/#{@u2.primary_key}", params: {
634
+ format: :scim,
635
+ userName: '4'
636
+ }
637
+ }.to_not change { MockUser.count }
638
+
639
+ expect(response.status ).to eql(200)
640
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
641
+
642
+ result = JSON.parse(response.body)
643
+
644
+ expect(result['id']).to eql(@u2.primary_key.to_s)
645
+ expect(result['meta']['resourceType']).to eql('User')
646
+
647
+ @u2.reload
648
+
649
+ expect(@u2.username ).to eql('4')
650
+ expect(@u2.first_name).to eql(CustomReplaceMockUsersController::OVERRIDDEN_NAME)
651
+ end
652
+
653
+ it 'notes Rails validation failures' do
654
+ expect_any_instance_of(CustomReplaceMockUsersController).to receive(:replace).once.and_call_original
655
+ expect {
656
+ put "/CustomReplaceUsers/#{@u2.primary_key}", params: {
657
+ format: :scim,
658
+ userName: MockUser::INVALID_USERNAME
659
+ }
660
+ }.to_not change { MockUser.count }
661
+
662
+ expect(response.status ).to eql(400)
663
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
664
+
665
+ result = JSON.parse(response.body)
666
+
667
+ expect(result['scimType']).to eql('invalidValue')
668
+ expect(result['detail']).to include('is reserved')
669
+
670
+ @u2.reload
671
+
672
+ expect(@u2.username).to eql('2')
673
+ expect(@u2.first_name).to eql('Foo')
674
+ expect(@u2.last_name).to eql('Bar')
675
+ expect(@u2.home_email_address).to eql('home_2@test.com')
676
+ end
677
+ end # "context 'with a block' do"
565
678
  end # "context '#replace' do"
566
679
 
567
680
  # ===========================================================================
@@ -586,7 +699,12 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
586
699
 
587
700
  payload = spec_helper_hupcase(payload) if force_upper_case
588
701
 
702
+ # Prove that certain known pathways are called; can then unit test
703
+ # those if need be and be sure that this covers #update actions.
704
+ #
589
705
  expect_any_instance_of(MockUsersController).to receive(:update).once.and_call_original
706
+ expect_any_instance_of(MockUsersController).to receive(:save! ).once.and_call_original
707
+
590
708
  expect {
591
709
  patch "/Users/#{@u2.primary_key}", params: payload.merge(format: :scim)
592
710
  }.to_not change { MockUser.count }
@@ -916,12 +1034,178 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
916
1034
  it_behaves_like 'a user remover'
917
1035
  end # context 'and using a Salesforce variant payload' do
918
1036
  end # "context 'when removing users from groups' do"
1037
+
1038
+ context 'with a block' do
1039
+ it 'invokes the block' do
1040
+ payload = {
1041
+ format: :scim,
1042
+ Operations: [
1043
+ {
1044
+ op: 'add',
1045
+ path: 'userName',
1046
+ value: '4'
1047
+ },
1048
+ {
1049
+ op: 'replace',
1050
+ path: 'emails[type eq "work"]',
1051
+ value: { type: 'work', value: 'work_4@test.com' }
1052
+ }
1053
+ ]
1054
+ }
1055
+
1056
+ expect_any_instance_of(CustomUpdateMockUsersController).to receive(:update).once.and_call_original
1057
+ expect {
1058
+ patch "/CustomUpdateUsers/#{@u2.primary_key}", params: payload
1059
+ }.to_not change { MockUser.count }
1060
+
1061
+ expect(response.status ).to eql(200)
1062
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
1063
+
1064
+ result = JSON.parse(response.body)
1065
+
1066
+ expect(result['id']).to eql(@u2.primary_key.to_s)
1067
+ expect(result['meta']['resourceType']).to eql('User')
1068
+
1069
+ @u2.reload
1070
+
1071
+ expect(@u2.username ).to eql('4')
1072
+ expect(@u2.first_name ).to eql(CustomUpdateMockUsersController::OVERRIDDEN_NAME)
1073
+ expect(@u2.work_email_address).to eql('work_4@test.com')
1074
+ end
1075
+
1076
+ it 'notes Rails validation failures' do
1077
+ expect_any_instance_of(CustomUpdateMockUsersController).to receive(:update).once.and_call_original
1078
+ expect {
1079
+ patch "/CustomUpdateUsers/#{@u2.primary_key}", params: {
1080
+ format: :scim,
1081
+ Operations: [
1082
+ {
1083
+ op: 'add',
1084
+ path: 'userName',
1085
+ value: MockUser::INVALID_USERNAME
1086
+ }
1087
+ ]
1088
+ }
1089
+ }.to_not change { MockUser.count }
1090
+
1091
+ expect(response.status ).to eql(400)
1092
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
1093
+
1094
+ result = JSON.parse(response.body)
1095
+
1096
+ expect(result['scimType']).to eql('invalidValue')
1097
+ expect(result['detail']).to include('is reserved')
1098
+
1099
+ @u2.reload
1100
+
1101
+ expect(@u2.username).to eql('2')
1102
+ expect(@u2.first_name).to eql('Foo')
1103
+ expect(@u2.last_name).to eql('Bar')
1104
+ expect(@u2.home_email_address).to eql('home_2@test.com')
1105
+ end
1106
+ end # "context 'with a block' do"
919
1107
  end # "context '#update' do"
920
1108
 
1109
+ # ===========================================================================
1110
+ # In-passing parts of tests above show that #create, #replace and #update all
1111
+ # route through #save!, so now add some unit tests for that and for exception
1112
+ # handling overrides invoked via #save!.
1113
+ # ===========================================================================
1114
+
1115
+ context 'overriding #save!' do
1116
+ it 'invokes a block if given one' do
1117
+ mock_before = MockUser.all.to_a
1118
+ attributes = { userName: '5' } # Minimum required by schema
1119
+
1120
+ expect_any_instance_of(CustomSaveMockUsersController).to receive(:create).once.and_call_original
1121
+ expect {
1122
+ post "/CustomSaveUsers", params: attributes.merge(format: :scim)
1123
+ }.to change { MockUser.count }.by(1)
1124
+
1125
+ mock_after = MockUser.all.to_a
1126
+ new_mock = (mock_after - mock_before).first
1127
+
1128
+ expect(response.status ).to eql(201)
1129
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
1130
+
1131
+ expect(new_mock.username).to eql(CustomSaveMockUsersController::CUSTOM_SAVE_BLOCK_USERNAME_INDICATOR)
1132
+ end
1133
+ end # "context 'overriding #save!' do
1134
+
1135
+ context 'custom on-save exceptions' do
1136
+ MockUsersController.new.send(:scimitar_rescuable_exceptions).each do | exception_class |
1137
+ it "handles out-of-box exception #{exception_class}" do
1138
+ expect_any_instance_of(MockUsersController).to receive(:create).once.and_call_original
1139
+ expect_any_instance_of(MockUsersController).to receive(:save! ).once.and_call_original
1140
+
1141
+ expect_any_instance_of(MockUser).to receive(:save!).once { raise exception_class }
1142
+
1143
+ expect {
1144
+ post "/Users", params: { format: :scim, userName: SecureRandom.uuid }
1145
+ }.to_not change { MockUser.count }
1146
+
1147
+ expected_status, expected_prefix = if exception_class == ActiveRecord::RecordNotUnique
1148
+ [409, 'Operation failed due to a uniqueness constraint: ']
1149
+ else
1150
+ [400, 'Operation failed since record has become invalid: ']
1151
+ end
1152
+
1153
+ expect(response.status ).to eql(expected_status)
1154
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
1155
+
1156
+ result = JSON.parse(response.body)
1157
+
1158
+ # Check basic SCIM error rendering - good enough given other tests
1159
+ # elsewhere. Exact message varies by exception.
1160
+ #
1161
+ expect(result['detail']).to start_with(expected_prefix)
1162
+ end
1163
+ end
1164
+
1165
+ it 'handles custom exceptions' do
1166
+ exception_class = RuntimeError # (for testing only; usually, this would provoke a 500 response)
1167
+
1168
+ expect_any_instance_of(MockUsersController).to receive(:create).once.and_call_original
1169
+ expect_any_instance_of(MockUsersController).to receive(:save! ).once.and_call_original
1170
+
1171
+ expect_any_instance_of(MockUsersController).to receive(:scimitar_rescuable_exceptions).once { [ exception_class ] }
1172
+ expect_any_instance_of(MockUser ).to receive(:save! ).once { raise exception_class }
1173
+
1174
+ expect {
1175
+ post "/Users", params: { format: :scim, userName: SecureRandom.uuid }
1176
+ }.to_not change { MockUser.count }
1177
+
1178
+ expect(response.status ).to eql(400)
1179
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
1180
+
1181
+ result = JSON.parse(response.body)
1182
+
1183
+ expect(result['detail']).to start_with('Operation failed since record has become invalid: ')
1184
+ end
1185
+
1186
+ it 'reports other exceptions as 500s' do
1187
+ expect_any_instance_of(MockUsersController).to receive(:create).once.and_call_original
1188
+ expect_any_instance_of(MockUsersController).to receive(:save! ).once.and_call_original
1189
+
1190
+ expect_any_instance_of(MockUser).to receive(:save!).once { raise RuntimeError }
1191
+
1192
+ expect {
1193
+ post "/Users", params: { format: :scim, userName: SecureRandom.uuid }
1194
+ }.to_not change { MockUser.count }
1195
+
1196
+ expect(response.status ).to eql(500)
1197
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
1198
+
1199
+ result = JSON.parse(response.body)
1200
+
1201
+ expect(result['detail']).to eql('RuntimeError')
1202
+ end
1203
+ end
1204
+
921
1205
  # ===========================================================================
922
1206
 
923
1207
  context '#destroy' do
924
- it 'deletes an item if given no blok' do
1208
+ it 'deletes an item if given no block' do
925
1209
  expect_any_instance_of(MockUsersController).to receive(:destroy).once.and_call_original
926
1210
  expect_any_instance_of(MockUser).to receive(:destroy!).once.and_call_original
927
1211
  expect {
data/spec/spec_helper.rb CHANGED
@@ -30,6 +30,7 @@ RSpec.configure do | config |
30
30
  config.disable_monkey_patching!
31
31
  config.infer_spec_type_from_file_location!
32
32
  config.filter_rails_from_backtrace!
33
+ config.raise_errors_for_deprecations!
33
34
 
34
35
  config.color = true
35
36
  config.tty = true
@@ -38,6 +39,13 @@ RSpec.configure do | config |
38
39
  config.use_transactional_fixtures = true
39
40
 
40
41
  Kernel.srand config.seed
42
+
43
+ config.around :each do | example |
44
+ original_engine_configuration = Scimitar.instance_variable_get('@engine_configuration')
45
+ example.run()
46
+ ensure
47
+ Scimitar.instance_variable_set('@engine_configuration', original_engine_configuration)
48
+ end
41
49
  end
42
50
 
43
51
  # ============================================================================
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scimitar
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RIPA Global
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-11-15 00:00:00.000000000 Z
12
+ date: 2024-01-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -25,62 +25,76 @@ dependencies:
25
25
  - - "~>"
26
26
  - !ruby/object:Gem::Version
27
27
  version: '6.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: nokogiri
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - '='
33
+ - !ruby/object:Gem::Version
34
+ version: 1.15.5
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '='
40
+ - !ruby/object:Gem::Version
41
+ version: 1.15.5
28
42
  - !ruby/object:Gem::Dependency
29
43
  name: rake
30
44
  requirement: !ruby/object:Gem::Requirement
31
45
  requirements:
32
46
  - - "~>"
33
47
  - !ruby/object:Gem::Version
34
- version: '13.0'
48
+ version: '13.1'
35
49
  type: :development
36
50
  prerelease: false
37
51
  version_requirements: !ruby/object:Gem::Requirement
38
52
  requirements:
39
53
  - - "~>"
40
54
  - !ruby/object:Gem::Version
41
- version: '13.0'
55
+ version: '13.1'
42
56
  - !ruby/object:Gem::Dependency
43
57
  name: pg
44
58
  requirement: !ruby/object:Gem::Requirement
45
59
  requirements:
46
60
  - - "~>"
47
61
  - !ruby/object:Gem::Version
48
- version: '1.2'
62
+ version: '1.5'
49
63
  type: :development
50
64
  prerelease: false
51
65
  version_requirements: !ruby/object:Gem::Requirement
52
66
  requirements:
53
67
  - - "~>"
54
68
  - !ruby/object:Gem::Version
55
- version: '1.2'
69
+ version: '1.5'
56
70
  - !ruby/object:Gem::Dependency
57
71
  name: simplecov-rcov
58
72
  requirement: !ruby/object:Gem::Requirement
59
73
  requirements:
60
74
  - - "~>"
61
75
  - !ruby/object:Gem::Version
62
- version: '0.2'
76
+ version: '0.3'
63
77
  type: :development
64
78
  prerelease: false
65
79
  version_requirements: !ruby/object:Gem::Requirement
66
80
  requirements:
67
81
  - - "~>"
68
82
  - !ruby/object:Gem::Version
69
- version: '0.2'
83
+ version: '0.3'
70
84
  - !ruby/object:Gem::Dependency
71
85
  name: rdoc
72
86
  requirement: !ruby/object:Gem::Requirement
73
87
  requirements:
74
88
  - - "~>"
75
89
  - !ruby/object:Gem::Version
76
- version: '6.3'
90
+ version: '6.6'
77
91
  type: :development
78
92
  prerelease: false
79
93
  version_requirements: !ruby/object:Gem::Requirement
80
94
  requirements:
81
95
  - - "~>"
82
96
  - !ruby/object:Gem::Version
83
- version: '6.3'
97
+ version: '6.6'
84
98
  - !ruby/object:Gem::Dependency
85
99
  name: rspec-rails
86
100
  requirement: !ruby/object:Gem::Requirement
@@ -195,10 +209,14 @@ files:
195
209
  - lib/scimitar.rb
196
210
  - lib/scimitar/engine.rb
197
211
  - lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb
212
+ - lib/scimitar/support/utilities.rb
198
213
  - lib/scimitar/version.rb
214
+ - spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb
199
215
  - spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb
216
+ - spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb
200
217
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
201
218
  - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
219
+ - spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb
202
220
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
203
221
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
204
222
  - spec/apps/dummy/app/models/mock_group.rb
@@ -263,14 +281,17 @@ required_rubygems_version: !ruby/object:Gem::Requirement
263
281
  - !ruby/object:Gem::Version
264
282
  version: '0'
265
283
  requirements: []
266
- rubygems_version: 3.4.10
284
+ rubygems_version: 3.5.4
267
285
  signing_key:
268
286
  specification_version: 4
269
287
  summary: SCIM v2 for Rails
270
288
  test_files:
289
+ - spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb
271
290
  - spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb
291
+ - spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb
272
292
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
273
293
  - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
294
+ - spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb
274
295
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
275
296
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
276
297
  - spec/apps/dummy/app/models/mock_group.rb