scimitar 2.6.1 → 2.7.1

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: e763d8f162583b44983db37fe3d8f0e8c9fa4f39f841813e9c3df247ffcd8cf9
4
- data.tar.gz: cfc7680b8f12d928a8975882109027f0c72f13884b438f04b90dcadc8d8a582c
3
+ metadata.gz: be3285b59b4b124288a68cbfcc308caff2c44c788d66ee537e6a1db3780e8633
4
+ data.tar.gz: e04d556655ee3dc440dd5eda89b217c3a5a6d9b96697686c0bb71241268d09d0
5
5
  SHA512:
6
- metadata.gz: 4a4d50b204fa662b9661d258686c22296c07bb88fd5cd2cbebfdbfef3455ca1385877ac66af5c3d6d22de6bfb6b43ae12ce74e21a9b5b279fa640b1042002a0b
7
- data.tar.gz: 795e29ab7736f0e40fd3bc39e055146935427a13a4a2e10cbbee5b9ddd6980c164cc9d187131d43895a602b0c44a2d950e4cb3c87af3ed7f9959bd1f8dfbb6fa
6
+ metadata.gz: 53a364cee0f0ea429b51a521131c186a0b51f16b78dacf89dd62a0eb3565ecd3dc21c22acb2cb453c9ca61d6764bc18ff3a0a4c2f4ef805988e1a5adb3a18cd0
7
+ data.tar.gz: 8e649ec7793576b31acca746025c02f325162c3d7f715a9554d8c6bdfc3b57b6734b571473c84e9d74eda7e2c3d909d576960464d8a00be8f418e9a56510a6dc
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 = '2.6.1'
6
+ VERSION = '2.7.1'
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-16'
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,75 @@ 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
+
515
+ expect(result['scimType']).to eql('invalidValue')
516
+ expect(result['detail']).to include('is reserved')
517
+ end
518
+ end # "context 'with a block' do"
462
519
  end # "context '#create' do"
463
520
 
464
521
  # ===========================================================================
@@ -469,7 +526,11 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
469
526
  attributes = { userName: '4' } # Minimum required by schema
470
527
  attributes = spec_helper_hupcase(attributes) if force_upper_case
471
528
 
529
+ # Prove that certain known pathways are called; can then unit test
530
+ # those if need be and be sure that this covers #replace actions.
531
+ #
472
532
  expect_any_instance_of(MockUsersController).to receive(:replace).once.and_call_original
533
+ expect_any_instance_of(MockUsersController).to receive(:save! ).once.and_call_original
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,14 +30,22 @@ 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
36
37
  config.order = :random
37
- config.fixture_path = "#{::Rails.root}/spec/fixtures"
38
+ config.fixture_paths = ["#{::Rails.root}/spec/fixtures"]
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: 2.6.1
4
+ version: 2.7.1
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-16 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -31,28 +31,28 @@ dependencies:
31
31
  requirements:
32
32
  - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: '13.0'
34
+ version: '13.1'
35
35
  type: :development
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: '13.0'
41
+ version: '13.1'
42
42
  - !ruby/object:Gem::Dependency
43
43
  name: pg
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - "~>"
47
47
  - !ruby/object:Gem::Version
48
- version: '1.4'
48
+ version: '1.5'
49
49
  type: :development
50
50
  prerelease: false
51
51
  version_requirements: !ruby/object:Gem::Requirement
52
52
  requirements:
53
53
  - - "~>"
54
54
  - !ruby/object:Gem::Version
55
- version: '1.4'
55
+ version: '1.5'
56
56
  - !ruby/object:Gem::Dependency
57
57
  name: simplecov-rcov
58
58
  requirement: !ruby/object:Gem::Requirement
@@ -73,28 +73,28 @@ dependencies:
73
73
  requirements:
74
74
  - - "~>"
75
75
  - !ruby/object:Gem::Version
76
- version: '6.5'
76
+ version: '6.6'
77
77
  type: :development
78
78
  prerelease: false
79
79
  version_requirements: !ruby/object:Gem::Requirement
80
80
  requirements:
81
81
  - - "~>"
82
82
  - !ruby/object:Gem::Version
83
- version: '6.5'
83
+ version: '6.6'
84
84
  - !ruby/object:Gem::Dependency
85
85
  name: rspec-rails
86
86
  requirement: !ruby/object:Gem::Requirement
87
87
  requirements:
88
88
  - - "~>"
89
89
  - !ruby/object:Gem::Version
90
- version: '6.0'
90
+ version: '6.1'
91
91
  type: :development
92
92
  prerelease: false
93
93
  version_requirements: !ruby/object:Gem::Requirement
94
94
  requirements:
95
95
  - - "~>"
96
96
  - !ruby/object:Gem::Version
97
- version: '6.0'
97
+ version: '6.1'
98
98
  - !ruby/object:Gem::Dependency
99
99
  name: byebug
100
100
  requirement: !ruby/object:Gem::Requirement
@@ -195,10 +195,14 @@ files:
195
195
  - lib/scimitar.rb
196
196
  - lib/scimitar/engine.rb
197
197
  - lib/scimitar/support/hash_with_indifferent_case_insensitive_access.rb
198
+ - lib/scimitar/support/utilities.rb
198
199
  - lib/scimitar/version.rb
200
+ - spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb
199
201
  - spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb
202
+ - spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb
200
203
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
201
204
  - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
205
+ - spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb
202
206
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
203
207
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
204
208
  - spec/apps/dummy/app/models/mock_group.rb
@@ -247,7 +251,7 @@ metadata:
247
251
  homepage_uri: https://www.ripaglobal.com/
248
252
  source_code_uri: https://github.com/RIPAGlobal/scimitar/
249
253
  bug_tracker_uri: https://github.com/RIPAGlobal/scimitar/issues/
250
- changelog_uri: https://github.com/RIPAGlobal/scimitar/blob/master/CHANGELOG.md
254
+ changelog_uri: https://github.com/RIPAGlobal/scimitar/blob/main/CHANGELOG.md
251
255
  post_install_message:
252
256
  rdoc_options: []
253
257
  require_paths:
@@ -263,14 +267,17 @@ required_rubygems_version: !ruby/object:Gem::Requirement
263
267
  - !ruby/object:Gem::Version
264
268
  version: '0'
265
269
  requirements: []
266
- rubygems_version: 3.4.10
270
+ rubygems_version: 3.5.4
267
271
  signing_key:
268
272
  specification_version: 4
269
273
  summary: SCIM v2 for Rails
270
274
  test_files:
275
+ - spec/apps/dummy/app/controllers/custom_create_mock_users_controller.rb
271
276
  - spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb
277
+ - spec/apps/dummy/app/controllers/custom_replace_mock_users_controller.rb
272
278
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
273
279
  - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
280
+ - spec/apps/dummy/app/controllers/custom_update_mock_users_controller.rb
274
281
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
275
282
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
276
283
  - spec/apps/dummy/app/models/mock_group.rb