scimitar 2.6.1 → 2.7.1

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.
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