scimitar 1.5.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/scimitar/active_record_backed_resources_controller.rb +22 -4
  3. data/app/controllers/scimitar/application_controller.rb +0 -1
  4. data/app/controllers/scimitar/schemas_controller.rb +5 -0
  5. data/app/models/scimitar/errors.rb +1 -1
  6. data/app/models/scimitar/lists/query_parser.rb +11 -6
  7. data/app/models/scimitar/resources/base.rb +13 -3
  8. data/app/models/scimitar/resources/mixin.rb +24 -9
  9. data/app/models/scimitar/schema/attribute.rb +15 -6
  10. data/app/models/scimitar/schema/base.rb +4 -2
  11. data/config/initializers/scimitar.rb +8 -3
  12. data/lib/scimitar/version.rb +2 -2
  13. data/lib/scimitar.rb +0 -12
  14. data/spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb +24 -0
  15. data/spec/apps/dummy/app/models/mock_user.rb +18 -1
  16. data/spec/apps/dummy/config/initializers/scimitar.rb +8 -0
  17. data/spec/apps/dummy/config/routes.rb +11 -6
  18. data/spec/apps/dummy/db/migrate/20210304014602_create_mock_users.rb +1 -0
  19. data/spec/apps/dummy/db/schema.rb +1 -0
  20. data/spec/controllers/scimitar/resource_types_controller_spec.rb +2 -2
  21. data/spec/controllers/scimitar/schemas_controller_spec.rb +8 -0
  22. data/spec/models/scimitar/lists/query_parser_spec.rb +68 -1
  23. data/spec/models/scimitar/resources/base_spec.rb +48 -5
  24. data/spec/models/scimitar/resources/mixin_spec.rb +12 -1
  25. data/spec/models/scimitar/resources/user_spec.rb +4 -4
  26. data/spec/models/scimitar/schema/attribute_spec.rb +22 -0
  27. data/spec/requests/active_record_backed_resources_controller_spec.rb +18 -4
  28. data/spec/requests/application_controller_spec.rb +1 -2
  29. metadata +5 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d57cfdaba9d48c6c193fb74baafc7c6e26c004a5a634460bc2f9ec94ad0440e
4
- data.tar.gz: 8ff5ffbabe01c86822bd2c1dc85c535bf95b197cbc41920a995aa1801d239f32
3
+ metadata.gz: 415331a6848887b5279a7f2c36ba5406989d490104a9f14735baf270bbb4f40a
4
+ data.tar.gz: 0ae6da1d6530f5fa4e5bce62f7e4188439281d768ada5f94534c19b3da8f8db9
5
5
  SHA512:
6
- metadata.gz: 91f6cba011c909de21f7c391dbfc5b18a30b7ef9cf81d9f43842452fa021484f55623034be386af6242e9a1a82efa9ef9436d8b5c17248bf0b9384587fecb50b
7
- data.tar.gz: e72e91fa8c3dd85df64b806dd3e6cb2b4d2460b33cb7d3c241bc64e8557adbccff9e3071fd18fab676ffdcd2a8c7f375512f85919e6ba10601021d127097bef0
6
+ metadata.gz: a62b9ccb023fb73f16a6b98a6a457caa9e3b2fcd72d420b992c2798705d604c91a013e4606a7f9be6da3fa2ea4ce03a5b7292f181ef8ad3d794ec6e8beb9ef3b
7
+ data.tar.gz: 80ad36659de5ddd2e9b8b7f8fc3e74c0a663b0a00b01a812edf641352b9b9ee37e8cf010cc86233789a1488df34dd0c79c493aebadb3f06b315ae26a5c03e1ff
@@ -153,7 +153,13 @@ module Scimitar
153
153
  # Save a record, dealing with validation exceptions by raising SCIM
154
154
  # errors.
155
155
  #
156
- # +record+:: ActiveRecord subclass to save (via #save!).
156
+ # +record+:: ActiveRecord subclass to save.
157
+ #
158
+ # If you just let this superclass handle things, it'll call the standard
159
+ # +#save!+ method on the record. If you pass a block, then this block is
160
+ # invoked and passed the ActiveRecord model instance to be saved. You can
161
+ # then do things like calling a different method, using a service object
162
+ # of some kind, perform audit-related operations and so-on.
157
163
  #
158
164
  # The return value is not used internally, making life easier for
159
165
  # overriding subclasses to "do the right thing" / avoid mistakes (instead
@@ -161,10 +167,22 @@ module Scimitar
161
167
  # and relying upon this to generate correct response payloads - an early
162
168
  # version of the gem did this and it caused a confusing subclass bug).
163
169
  #
164
- def save!(record)
165
- record.save!
166
-
170
+ def save!(record, &block)
171
+ if block_given?
172
+ yield(record)
173
+ else
174
+ record.save!
175
+ end
167
176
  rescue ActiveRecord::RecordInvalid => exception
177
+ handle_invalid_record(exception.record)
178
+ end
179
+
180
+ # Deal with validation errors by responding with an appropriate SCIM
181
+ # error.
182
+ #
183
+ # +record+:: The record with validation errors.
184
+ #
185
+ def handle_invalid_record(record)
168
186
  joined_errors = record.errors.full_messages.join('; ')
169
187
 
170
188
  # https://tools.ietf.org/html/rfc7644#page-12
@@ -25,7 +25,6 @@ module Scimitar
25
25
  #
26
26
  # ...to "globally" invoke this handler if you wish.
27
27
  #
28
- #
29
28
  # +exception+:: Exception instance, used for a configured error reporter
30
29
  # via #handle_scim_error (if present).
31
30
  #
@@ -4,6 +4,11 @@ module Scimitar
4
4
  class SchemasController < ApplicationController
5
5
  def index
6
6
  schemas = Scimitar::Engine.schemas
7
+
8
+ schemas.each do |schema|
9
+ schema.meta.location = scim_schemas_url(name: schema.id)
10
+ end
11
+
7
12
  schemas_by_id = schemas.reduce({}) do |hash, schema|
8
13
  hash[schema.id] = schema
9
14
  hash
@@ -1,6 +1,6 @@
1
1
  module Scimitar
2
2
  module Errors
3
- def add_errors_from_hash(errors_hash, prefix: nil)
3
+ def add_errors_from_hash(errors_hash:, prefix: nil)
4
4
  errors_hash.each_pair do |key, value|
5
5
  new_key = prefix.nil? ? key : "#{prefix}.#{key}".to_sym
6
6
  if value.is_a?(Array)
@@ -192,7 +192,7 @@ module Scimitar
192
192
 
193
193
  ast.push(self.start_group? ? self.parse_group() : self.pop())
194
194
 
195
- unless ! ast.last.is_a?(String) || UNARY_OPERATORS.include?(ast.last.downcase)
195
+ if ast.last.is_a?(String) && !UNARY_OPERATORS.include?(ast.last.downcase) || ast.last.is_a?(Array)
196
196
  expect_op ^= true
197
197
  end
198
198
  end
@@ -601,9 +601,15 @@ module Scimitar
601
601
  column_names = self.activerecord_columns(scim_attribute)
602
602
  value = self.activerecord_parameter(scim_parameter)
603
603
  value_for_like = self.sql_modified_value(scim_operator, value)
604
- all_supported = column_names.all? { | column_name | base_scope.model.column_names.include?(column_name.to_s) }
604
+ arel_columns = column_names.map do |column|
605
+ if base_scope.model.column_names.include?(column.to_s)
606
+ arel_table[column]
607
+ elsif column.is_a?(Arel::Attribute)
608
+ column
609
+ end
610
+ end
605
611
 
606
- raise Scimitar::FilterError unless all_supported
612
+ raise Scimitar::FilterError unless arel_columns.all?
607
613
 
608
614
  unless case_sensitive
609
615
  lc_scim_attribute = scim_attribute.downcase()
@@ -615,8 +621,7 @@ module Scimitar
615
621
  )
616
622
  end
617
623
 
618
- column_names.each.with_index do | column_name, index |
619
- arel_column = arel_table[column_name]
624
+ arel_columns.each.with_index do | arel_column, index |
620
625
  arel_operation = case scim_operator
621
626
  when 'eq'
622
627
  if case_sensitive
@@ -641,7 +646,7 @@ module Scimitar
641
646
  when 'co', 'sw', 'ew'
642
647
  arel_column.matches(value_for_like, nil, case_sensitive)
643
648
  when 'pr'
644
- arel_table.grouping(arel_column.not_eq_all(['', nil]))
649
+ arel_column.relation.grouping(arel_column.not_eq_all(['', nil]))
645
650
  else
646
651
  raise Scimitar::FilterError.new("Unsupported operator: '#{scim_operator}'")
647
652
  end
@@ -112,7 +112,7 @@ module Scimitar
112
112
  end
113
113
 
114
114
  def self.complex_scim_attributes
115
- schema.scim_attributes.select(&:complexType).group_by(&:name)
115
+ schemas.flat_map(&:scim_attributes).select(&:complexType).group_by(&:name)
116
116
  end
117
117
 
118
118
  def complex_type_from_hash(scim_attribute, attr_value)
@@ -139,13 +139,23 @@ module Scimitar
139
139
 
140
140
  def as_json(options = {})
141
141
  self.meta = Meta.new unless self.meta && self.meta.is_a?(Meta)
142
- meta.resourceType = self.class.resource_type_id
143
- original_hash = super(options).except('errors')
142
+ self.meta.resourceType = self.class.resource_type_id
143
+
144
+ non_returnable_attributes = self.class
145
+ .schemas
146
+ .flat_map(&:scim_attributes)
147
+ .filter_map { |attribute| attribute.name if attribute.returned == 'never' }
148
+
149
+ non_returnable_attributes << 'errors'
150
+
151
+ original_hash = super(options).except(*non_returnable_attributes)
144
152
  original_hash.merge!('schemas' => self.class.schemas.map(&:id))
153
+
145
154
  self.class.extended_schemas.each do |extension_schema|
146
155
  extension_attributes = extension_schema.scim_attributes.map(&:name)
147
156
  original_hash.merge!(extension_schema.id => original_hash.extract!(*extension_attributes))
148
157
  end
158
+
149
159
  original_hash
150
160
  end
151
161
 
@@ -220,13 +220,8 @@ module Scimitar
220
220
  # allow for different client searching "styles", given ambiguities in RFC
221
221
  # 7644 filter examples).
222
222
  #
223
- # Each value is a Hash with Symbol keys ':column', naming just one simple
224
- # column for a mapping; ':columns', with an Array of column names that you
225
- # want to map using 'OR' for a single search on the corresponding SCIM
226
- # attribute; or ':ignore' with value 'true', which means that a fitler on
227
- # the matching attribute is ignored rather than resulting in an "invalid
228
- # filter" exception - beware possibilities for surprised clients getting a
229
- # broader result set than expected. Example:
223
+ # Each value is a hash of queryable SCIM attribute options, described
224
+ # below - for example:
230
225
  #
231
226
  # def self.scim_queryable_attributes
232
227
  # return {
@@ -234,10 +229,27 @@ module Scimitar
234
229
  # 'name.familyName' => { column: :last_name },
235
230
  # 'emails' => { columns: [ :work_email_address, :home_email_address ] },
236
231
  # 'emails.value' => { columns: [ :work_email_address, :home_email_address ] },
237
- # 'emails.type' => { ignore: true }
232
+ # 'emails.type' => { ignore: true },
233
+ # 'groups.value' => { column: Group.arel_table[:id] }
238
234
  # }
239
235
  # end
240
236
  #
237
+ # Column references can be either a Symbol representing a column within
238
+ # the resource model table, or an <tt>Arel::Attribute</tt> instance via
239
+ # e.g. <tt>MyModel.arel_table[:my_column]</tt>.
240
+ #
241
+ # === Queryable SCIM attribute options
242
+ #
243
+ # +:column+:: Just one simple column for a mapping.
244
+ #
245
+ # +:columns+:: An Array of columns that you want to map using 'OR' for a
246
+ # single search of the corresponding entity.
247
+ #
248
+ # +:ignore+:: When set to +true+, the matching attribute is ignored rather
249
+ # than resulting in an "invalid filter" exception. Beware
250
+ # possibilities for surprised clients getting a broader result
251
+ # set than expected, since a constraint may have been ignored.
252
+ #
241
253
  # Filtering is currently limited and searching within e.g. arrays of data
242
254
  # is not supported; only simple top-level keys can be mapped.
243
255
  #
@@ -406,8 +418,11 @@ module Scimitar
406
418
  def from_scim_patch!(patch_hash:)
407
419
  frozen_ci_patch_hash = patch_hash.with_indifferent_case_insensitive_access().freeze()
408
420
  ci_scim_hash = self.to_scim(location: '(unused)').as_json().with_indifferent_case_insensitive_access()
421
+ operations = frozen_ci_patch_hash['operations']
422
+
423
+ raise Scimitar::InvalidSyntaxError.new("Missing PATCH \"operations\"") unless operations
409
424
 
410
- frozen_ci_patch_hash['operations'].each do |operation|
425
+ operations.each do |operation|
411
426
  nature = operation['op' ]&.downcase
412
427
  path_str = operation['path' ]
413
428
  value = operation['value']
@@ -88,19 +88,28 @@ module Scimitar
88
88
  end
89
89
  value.class.schema.valid?(value)
90
90
  return true if value.errors.empty?
91
- add_errors_from_hash(value.errors.to_hash, prefix: self.name)
91
+ add_errors_from_hash(errors_hash: value.errors.to_hash, prefix: self.name)
92
92
  false
93
93
  end
94
94
 
95
95
  def valid_simple_type?(value)
96
- valid = (type == 'string' && value.is_a?(String)) ||
97
- (type == 'boolean' && (value.is_a?(TrueClass) || value.is_a?(FalseClass))) ||
98
- (type == 'integer' && (value.is_a?(Integer))) ||
99
- (type == 'dateTime' && valid_date_time?(value))
100
- errors.add(self.name, "has the wrong type. It has to be a(n) #{self.type}.") unless valid
96
+ if multiValued
97
+ valid = value.is_a?(Array) && value.all? { |v| simple_type?(v) }
98
+ errors.add(self.name, "or one of its elements has the wrong type. It has to be an array of #{self.type}s.") unless valid
99
+ else
100
+ valid = simple_type?(value)
101
+ errors.add(self.name, "has the wrong type. It has to be a(n) #{self.type}.") unless valid
102
+ end
101
103
  valid
102
104
  end
103
105
 
106
+ def simple_type?(value)
107
+ (type == 'string' && value.is_a?(String)) ||
108
+ (type == 'boolean' && (value.is_a?(TrueClass) || value.is_a?(FalseClass))) ||
109
+ (type == 'integer' && (value.is_a?(Integer))) ||
110
+ (type == 'dateTime' && valid_date_time?(value))
111
+ end
112
+
104
113
  def valid_date_time?(value)
105
114
  !!Time.iso8601(value)
106
115
  rescue ArgumentError
@@ -13,7 +13,7 @@ module Scimitar
13
13
 
14
14
  # Converts the schema to its json representation that will be returned by /SCHEMAS end-point of a SCIM service provider.
15
15
  def as_json(options = {})
16
- @meta.location = Scimitar::Engine.routes.url_helpers.scim_schemas_path(name: id)
16
+ @meta.location ||= Scimitar::Engine.routes.url_helpers.scim_schemas_path(name: id)
17
17
  original = super
18
18
  original.merge('attributes' => original.delete('scim_attributes'))
19
19
  end
@@ -26,7 +26,9 @@ module Scimitar
26
26
  #
27
27
  def self.valid?(resource)
28
28
  cloned_scim_attributes.each do |scim_attribute|
29
- resource.add_errors_from_hash(scim_attribute.errors.to_hash) unless scim_attribute.valid?(resource.send(scim_attribute.name))
29
+ unless scim_attribute.valid?(resource.send(scim_attribute.name))
30
+ resource.add_errors_from_hash(errors_hash: scim_attribute.errors.to_hash)
31
+ end
30
32
  end
31
33
  end
32
34
 
@@ -38,9 +38,10 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
38
38
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
39
39
 
40
40
  # If you have filters you want to run for any Scimitar action/route, you
41
- # can define them here. For example, you might use a before-action to set
42
- # up some multi-tenancy related state, or skip Rails CSRF token
43
- # verification. For example:
41
+ # can define them here. You can also override any shared controller methods
42
+ # here. For example, you might use a before-action to set up some
43
+ # multi-tenancy related state, skip Rails CSRF token verification, or
44
+ # customise how Scimitar generates URLs:
44
45
  #
45
46
  # application_controller_mixin: Module.new do
46
47
  # def self.included(base)
@@ -54,6 +55,10 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
54
55
  # prepend_before_action :setup_some_kind_of_multi_tenancy_data
55
56
  # end
56
57
  # end
58
+ #
59
+ # def scim_schemas_url(options)
60
+ # super(custom_param: 'value', **options)
61
+ # end
57
62
  # end, # ...other configuration entries might follow...
58
63
 
59
64
  # If you want to support username/password authentication:
@@ -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.5.3'
6
+ VERSION = '1.7.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-09-16'
11
+ DATE = '2023-11-15'
12
12
 
13
13
  end
data/lib/scimitar.rb CHANGED
@@ -25,16 +25,4 @@ module Scimitar
25
25
  @engine_configuration ||= EngineConfiguration.new
26
26
  @engine_configuration
27
27
  end
28
-
29
- # Set in a "Rails.application.config.to_prepare" block by Scimitar itself to
30
- # establish default values. Older Scimitar client applications might not use
31
- # that wrapper; we don't want to overwrite settings they configured, but we
32
- # *do* want to let them overwrite the defaults. Thus, '||=" is used here but
33
- # not in ::service_provider_configuration=.
34
- #
35
- # Client applications should not call this method themselves.
36
- #
37
- def self.default_service_provider_configuration(default_configuration)
38
- @service_provider_configuration ||= custom_configuration
39
- end
40
28
  end
@@ -0,0 +1,24 @@
1
+ # For tests only - uses custom 'save!' implementation which passes a block to
2
+ # Scimitar::ActiveRecordBackedResourcesController#save!.
3
+ #
4
+ class CustomSaveMockUsersController < Scimitar::ActiveRecordBackedResourcesController
5
+
6
+ CUSTOM_SAVE_BLOCK_USERNAME_INDICATOR = 'Custom save-block invoked'
7
+
8
+ protected
9
+
10
+ def save!(_record)
11
+ super do | record |
12
+ record.update!(username: CUSTOM_SAVE_BLOCK_USERNAME_INDICATOR)
13
+ end
14
+ end
15
+
16
+ def storage_class
17
+ MockUser
18
+ end
19
+
20
+ def storage_scope
21
+ MockUser.all
22
+ end
23
+
24
+ end
@@ -1,5 +1,7 @@
1
1
  class MockUser < ActiveRecord::Base
2
2
 
3
+ self.primary_key = :primary_key
4
+
3
5
  # ===========================================================================
4
6
  # TEST ATTRIBUTES - see db/migrate/20210304014602_create_mock_users.rb etc.
5
7
  # ===========================================================================
@@ -8,6 +10,7 @@ class MockUser < ActiveRecord::Base
8
10
  primary_key
9
11
  scim_uid
10
12
  username
13
+ password
11
14
  first_name
12
15
  last_name
13
16
  work_email_address
@@ -15,6 +18,7 @@ class MockUser < ActiveRecord::Base
15
18
  work_phone_number
16
19
  organization
17
20
  department
21
+ mock_groups
18
22
  }
19
23
 
20
24
  has_and_belongs_to_many :mock_groups
@@ -43,6 +47,7 @@ class MockUser < ActiveRecord::Base
43
47
  id: :primary_key,
44
48
  externalId: :scim_uid,
45
49
  userName: :username,
50
+ password: :password,
46
51
  name: {
47
52
  givenName: :first_name,
48
53
  familyName: :last_name
@@ -90,7 +95,17 @@ class MockUser < ActiveRecord::Base
90
95
  # "spec/apps/dummy/config/initializers/scimitar.rb".
91
96
  #
92
97
  organization: :organization,
93
- department: :department
98
+ department: :department,
99
+ userGroups: [
100
+ {
101
+ list: :mock_groups,
102
+ find_with: ->(value) { MockGroup.find(value["value"]) },
103
+ using: {
104
+ value: :id,
105
+ display: :display_name
106
+ }
107
+ }
108
+ ]
94
109
  }
95
110
  end
96
111
 
@@ -105,6 +120,8 @@ class MockUser < ActiveRecord::Base
105
120
  'meta.lastModified' => { column: :updated_at },
106
121
  'name.givenName' => { column: :first_name },
107
122
  'name.familyName' => { column: :last_name },
123
+ 'groups' => { column: MockGroup.arel_table[:id] },
124
+ 'groups.value' => { column: MockGroup.arel_table[:id] },
108
125
  'emails' => { columns: [ :work_email_address, :home_email_address ] },
109
126
  'emails.value' => { columns: [ :work_email_address, :home_email_address ] },
110
127
  'emails.type' => { ignore: true } # We can't filter on that; it'll just search all e-mails
@@ -26,6 +26,14 @@ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
26
26
  before_action :test_hook
27
27
  end
28
28
  end
29
+
30
+ def scim_schemas_url(options)
31
+ super(test: 1, **options)
32
+ end
33
+
34
+ def scim_resource_type_url(options)
35
+ super(test: 1, **options)
36
+ end
29
37
  end
30
38
 
31
39
  })
@@ -6,12 +6,12 @@
6
6
  Rails.application.routes.draw do
7
7
  mount Scimitar::Engine, at: '/'
8
8
 
9
- get 'Users', to: 'mock_users#index'
10
- get 'Users/:id', to: 'mock_users#show'
11
- post 'Users', to: 'mock_users#create'
12
- put 'Users/:id', to: 'mock_users#replace'
13
- patch 'Users/:id', to: 'mock_users#update'
14
- delete 'Users/:id', to: 'mock_users#destroy'
9
+ get 'Users', to: 'mock_users#index'
10
+ get 'Users/:id', to: 'mock_users#show'
11
+ post 'Users', to: 'mock_users#create'
12
+ put 'Users/:id', to: 'mock_users#replace'
13
+ patch 'Users/:id', to: 'mock_users#update'
14
+ delete 'Users/:id', to: 'mock_users#destroy'
15
15
 
16
16
  get 'Groups', to: 'mock_groups#index'
17
17
  get 'Groups/:id', to: 'mock_groups#show'
@@ -21,6 +21,11 @@ Rails.application.routes.draw do
21
21
  #
22
22
  delete 'CustomDestroyUsers/:id', to: 'custom_destroy_mock_users#destroy'
23
23
 
24
+ # For testing blocks passed to ActiveRecordBackedResourcesController#save!
25
+ #
26
+ post 'CustomSaveUsers', to: 'custom_save_mock_users#create'
27
+ get 'CustomSaveUsers/:id', to: 'custom_save_mock_users#show'
28
+
24
29
  # For testing environment inside Scimitar::ApplicationController subclasses.
25
30
  #
26
31
  get 'CustomRequestVerifiers', to: 'custom_request_verifiers#index'
@@ -7,6 +7,7 @@ class CreateMockUsers < ActiveRecord::Migration[6.1]
7
7
  #
8
8
  t.text :scim_uid
9
9
  t.text :username
10
+ t.text :password
10
11
  t.text :first_name
11
12
  t.text :last_name
12
13
  t.text :work_email_address
@@ -34,6 +34,7 @@ ActiveRecord::Schema.define(version: 2021_03_08_044214) do
34
34
  t.datetime "updated_at", precision: 6, null: false
35
35
  t.text "scim_uid"
36
36
  t.text "username"
37
+ t.text "password"
37
38
  t.text "first_name"
38
39
  t.text "last_name"
39
40
  t.text "work_email_address"
@@ -9,8 +9,8 @@ RSpec.describe Scimitar::ResourceTypesController do
9
9
  it 'renders the resource type for user' do
10
10
  get :index, format: :scim
11
11
  response_hash = JSON.parse(response.body)
12
- expected_response = [ Scimitar::Resources::User.resource_type(scim_resource_type_url(name: 'User')),
13
- Scimitar::Resources::Group.resource_type(scim_resource_type_url(name: 'Group'))
12
+ expected_response = [ Scimitar::Resources::User.resource_type(scim_resource_type_url(name: 'User', test: 1)),
13
+ Scimitar::Resources::Group.resource_type(scim_resource_type_url(name: 'Group', test: 1))
14
14
  ].to_json
15
15
 
16
16
  response_hash = JSON.parse(response.body)
@@ -1,6 +1,7 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  RSpec.describe Scimitar::SchemasController do
4
+ routes { Scimitar::Engine.routes }
4
5
 
5
6
  before(:each) { allow(controller).to receive(:authenticated?).and_return(true) }
6
7
 
@@ -26,6 +27,13 @@ RSpec.describe Scimitar::SchemasController do
26
27
  expect(parsed_body['name']).to eql('User')
27
28
  end
28
29
 
30
+ it 'includes the controller customized schema location' do
31
+ get :index, params: { name: Scimitar::Schema::User.id, format: :scim }
32
+ expect(response).to be_ok
33
+ parsed_body = JSON.parse(response.body)
34
+ expect(parsed_body.dig('meta', 'location')).to eq scim_schemas_url(name: Scimitar::Schema::User.id, test: 1)
35
+ end
36
+
29
37
  it 'returns only the Group schema when its id is provided' do
30
38
  get :index, params: { name: Scimitar::Schema::Group.id, format: :scim }
31
39
  expect(response).to be_ok
@@ -405,7 +405,7 @@ RSpec.describe Scimitar::Lists::QueryParser do
405
405
  query = @instance.to_activerecord_query(MockUser.all)
406
406
 
407
407
  expect(query.count).to eql(1)
408
- expect(query.pluck(:primary_key)).to eql([user_1.id])
408
+ expect(query.pluck(:primary_key)).to eql([user_1.primary_key])
409
409
 
410
410
  @instance.parse('name.givenName sw J') # First name starts with 'J'
411
411
  query = @instance.to_activerecord_query(MockUser.all)
@@ -481,6 +481,66 @@ RSpec.describe Scimitar::Lists::QueryParser do
481
481
  end
482
482
  end # "context 'when instructed to ignore an attribute' do"
483
483
 
484
+ context 'when an arel column is mapped' do
485
+ let(:scope_with_groups) { MockUser.left_joins(:mock_groups) }
486
+
487
+ context 'with binary operators' do
488
+ it 'reads across all using OR' do
489
+ @instance.parse('groups eq "12345"')
490
+ query = @instance.to_activerecord_query(scope_with_groups)
491
+
492
+ expect(query.to_sql).to eql(<<~SQL.squish)
493
+ SELECT "mock_users".*
494
+ FROM "mock_users"
495
+ LEFT OUTER JOIN "mock_groups_users" ON "mock_groups_users"."mock_user_id" = "mock_users"."primary_key"
496
+ LEFT OUTER JOIN "mock_groups" ON "mock_groups"."id" = "mock_groups_users"."mock_group_id"
497
+ WHERE "mock_groups"."id" ILIKE 12345
498
+ SQL
499
+ end
500
+
501
+ it 'works with other query elements using correct precedence' do
502
+ @instance.parse('groups eq "12345" and emails eq "any@test.com"')
503
+ query = @instance.to_activerecord_query(scope_with_groups)
504
+
505
+ expect(query.to_sql).to eql(<<~SQL.squish)
506
+ SELECT "mock_users".*
507
+ FROM "mock_users"
508
+ LEFT OUTER JOIN "mock_groups_users" ON "mock_groups_users"."mock_user_id" = "mock_users"."primary_key"
509
+ LEFT OUTER JOIN "mock_groups" ON "mock_groups"."id" = "mock_groups_users"."mock_group_id"
510
+ WHERE "mock_groups"."id" ILIKE 12345 AND ("mock_users"."work_email_address" ILIKE 'any@test.com' OR "mock_users"."home_email_address" ILIKE 'any@test.com')
511
+ SQL
512
+ end
513
+ end # "context 'with binary operators' do"
514
+
515
+ context 'with unary operators' do
516
+ it 'reads across all using OR' do
517
+ @instance.parse('groups pr')
518
+ query = @instance.to_activerecord_query(scope_with_groups)
519
+
520
+ expect(query.to_sql).to eql(<<~SQL.squish)
521
+ SELECT "mock_users".*
522
+ FROM "mock_users"
523
+ LEFT OUTER JOIN "mock_groups_users" ON "mock_groups_users"."mock_user_id" = "mock_users"."primary_key"
524
+ LEFT OUTER JOIN "mock_groups" ON "mock_groups"."id" = "mock_groups_users"."mock_group_id"
525
+ WHERE ("mock_groups"."id" != NULL AND "mock_groups"."id" IS NOT NULL)
526
+ SQL
527
+ end
528
+
529
+ it 'works with other query elements using correct precedence' do
530
+ @instance.parse('name.familyName eq "John" and groups pr')
531
+ query = @instance.to_activerecord_query(scope_with_groups)
532
+
533
+ expect(query.to_sql).to eql(<<~SQL.squish)
534
+ SELECT "mock_users".*
535
+ FROM "mock_users"
536
+ LEFT OUTER JOIN "mock_groups_users" ON "mock_groups_users"."mock_user_id" = "mock_users"."primary_key"
537
+ LEFT OUTER JOIN "mock_groups" ON "mock_groups"."id" = "mock_groups_users"."mock_group_id"
538
+ WHERE "mock_users"."last_name" ILIKE 'John' AND ("mock_groups"."id" != NULL AND "mock_groups"."id" IS NOT NULL)
539
+ SQL
540
+ end
541
+ end # "context 'with unary operators' do
542
+ end # "context 'when an arel column is mapped' do"
543
+
484
544
  context 'with complex cases' do
485
545
  context 'using AND' do
486
546
  it 'generates expected SQL' do
@@ -532,6 +592,13 @@ RSpec.describe Scimitar::Lists::QueryParser do
532
592
  expect(query.to_sql).to eql(%q{SELECT "mock_users".* FROM "mock_users" WHERE "mock_users"."first_name" ILIKE 'Jane' AND ("mock_users"."last_name" ILIKE '%avi%' OR "mock_users"."last_name" ILIKE '%ith')})
533
593
  end
534
594
 
595
+ it 'combined parentheses generates expected SQL' do
596
+ @instance.parse('(name.givenName eq "Jane" OR name.givenName eq "Jaden") and (name.familyName co "avi" or name.familyName ew "ith")')
597
+ query = @instance.to_activerecord_query(MockUser.all)
598
+
599
+ expect(query.to_sql).to eql(%q{SELECT "mock_users".* FROM "mock_users" WHERE ("mock_users"."first_name" ILIKE 'Jane' OR "mock_users"."first_name" ILIKE 'Jaden') AND ("mock_users"."last_name" ILIKE '%avi%' OR "mock_users"."last_name" ILIKE '%ith')})
600
+ end
601
+
535
602
  it 'finds expected items' do
536
603
  user_1 = MockUser.create(username: '1', first_name: 'Jane', last_name: 'Davis') # Match
537
604
  user_2 = MockUser.create(username: '2', first_name: 'Jane', last_name: 'Smith') # Match
@@ -14,7 +14,10 @@ RSpec.describe Scimitar::Resources::Base do
14
14
  ),
15
15
  Scimitar::Schema::Attribute.new(
16
16
  name: 'names', multiValued: true, complexType: Scimitar::ComplexTypes::Name, required: false
17
- )
17
+ ),
18
+ Scimitar::Schema::Attribute.new(
19
+ name: 'privateName', complexType: Scimitar::ComplexTypes::Name, required: false, returned: false
20
+ ),
18
21
  ]
19
22
  end
20
23
  end
@@ -30,6 +33,10 @@ RSpec.describe Scimitar::Resources::Base do
30
33
  name: {
31
34
  givenName: 'John',
32
35
  familyName: 'Smith'
36
+ },
37
+ privateName: {
38
+ givenName: 'Alt John',
39
+ familyName: 'Alt Smith'
33
40
  }
34
41
  }
35
42
 
@@ -39,6 +46,9 @@ RSpec.describe Scimitar::Resources::Base do
39
46
  expect(resource.name.is_a?(Scimitar::ComplexTypes::Name)).to be(true)
40
47
  expect(resource.name.givenName).to eql('John')
41
48
  expect(resource.name.familyName).to eql('Smith')
49
+ expect(resource.privateName.is_a?(Scimitar::ComplexTypes::Name)).to be(true)
50
+ expect(resource.privateName.givenName).to eql('Alt John')
51
+ expect(resource.privateName.familyName).to eql('Alt Smith')
42
52
  end
43
53
 
44
54
  it 'which builds an array of nested resources' do
@@ -101,14 +111,38 @@ RSpec.describe Scimitar::Resources::Base do
101
111
  context '#as_json' do
102
112
  it 'renders the json with the resourceType' do
103
113
  resource = CustomResourse.new(name: {
104
- givenName: 'John',
114
+ givenName: 'John',
105
115
  familyName: 'Smith'
106
116
  })
107
117
 
108
118
  result = resource.as_json
109
- expect(result['schemas']).to eql(['custom-id'])
119
+
120
+ expect(result['schemas'] ).to eql(['custom-id'])
121
+ expect(result['meta']['resourceType']).to eql('CustomResourse')
122
+ expect(result['errors'] ).to be_nil
123
+ end
124
+
125
+ it 'excludes attributes that are flagged as do-not-return' do
126
+ resource = CustomResourse.new(
127
+ name: {
128
+ givenName: 'John',
129
+ familyName: 'Smith'
130
+ },
131
+ privateName: {
132
+ givenName: 'Alt John',
133
+ familyName: 'Alt Smith'
134
+ }
135
+ )
136
+
137
+ result = resource.as_json
138
+
139
+ expect(result['schemas'] ).to eql(['custom-id'])
110
140
  expect(result['meta']['resourceType']).to eql('CustomResourse')
111
- expect(result['errors']).to be_nil
141
+ expect(result['errors'] ).to be_nil
142
+ expect(result['name'] ).to be_present
143
+ expect(result['name']['givenName'] ).to eql('John')
144
+ expect(result['name']['familyName'] ).to eql('Smith')
145
+ expect(result['privateName'] ).to be_present
112
146
  end
113
147
  end # "context '#as_json' do"
114
148
 
@@ -267,7 +301,10 @@ RSpec.describe Scimitar::Resources::Base do
267
301
  end
268
302
 
269
303
  def self.scim_attributes
270
- [ Scimitar::Schema::Attribute.new(name: 'relationship', type: 'string', required: true) ]
304
+ [
305
+ Scimitar::Schema::Attribute.new(name: 'relationship', type: 'string', required: true),
306
+ Scimitar::Schema::Attribute.new(name: "userGroups", multiValued: true, complexType: Scimitar::ComplexTypes::ReferenceGroup, mutability: "writeOnly")
307
+ ]
271
308
  end
272
309
  end
273
310
 
@@ -291,6 +328,12 @@ RSpec.describe Scimitar::Resources::Base do
291
328
  resource = resource_class.new('extension-id' => {relationship: 'GAGA'})
292
329
  expect(resource.relationship).to eql('GAGA')
293
330
  end
331
+
332
+ it 'allows setting complex extension attributes' do
333
+ user_groups = [{ value: '123' }, { value: '456'}]
334
+ resource = resource_class.new('extension-id' => {userGroups: user_groups})
335
+ expect(resource.userGroups.map(&:value)).to eql(['123', '456'])
336
+ end
294
337
  end # "context '#initialize' do"
295
338
 
296
339
  context '#as_json' do
@@ -160,13 +160,14 @@ RSpec.describe Scimitar::Resources::Mixin do
160
160
 
161
161
  context '#to_scim' do
162
162
  context 'with a UUID, renamed primary key column' do
163
- it 'compiles instance attribute values into a SCIM representation' do
163
+ it 'compiles instance attribute values into a SCIM representation, but omits do-not-return fields' do
164
164
  uuid = SecureRandom.uuid
165
165
 
166
166
  instance = MockUser.new
167
167
  instance.primary_key = uuid
168
168
  instance.scim_uid = 'AA02984'
169
169
  instance.username = 'foo'
170
+ instance.password = 'correcthorsebatterystaple'
170
171
  instance.first_name = 'Foo'
171
172
  instance.last_name = 'Bar'
172
173
  instance.work_email_address = 'foo.bar@test.com'
@@ -404,6 +405,7 @@ RSpec.describe Scimitar::Resources::Mixin do
404
405
  it 'ignoring read-only lists' do
405
406
  hash = {
406
407
  'userName' => 'foo',
408
+ 'password' => 'staplebatteryhorsecorrect',
407
409
  'name' => {'givenName' => 'Foo', 'familyName' => 'Bar'},
408
410
  'active' => true,
409
411
  'emails' => [{'type' => 'work', 'primary' => true, 'value' => 'foo.bar@test.com'}],
@@ -428,6 +430,7 @@ RSpec.describe Scimitar::Resources::Mixin do
428
430
 
429
431
  expect(instance.scim_uid ).to eql('AA02984')
430
432
  expect(instance.username ).to eql('foo')
433
+ expect(instance.password ).to eql('staplebatteryhorsecorrect')
431
434
  expect(instance.first_name ).to eql('Foo')
432
435
  expect(instance.last_name ).to eql('Bar')
433
436
  expect(instance.work_email_address).to eql('foo.bar@test.com')
@@ -2685,6 +2688,14 @@ RSpec.describe Scimitar::Resources::Mixin do
2685
2688
  #
2686
2689
  context 'public interface' do
2687
2690
  shared_examples 'a patcher' do | force_upper_case: |
2691
+ it 'gives the user a comprehensible error when operations are missing' do
2692
+ patch = { 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] }
2693
+
2694
+ expect do
2695
+ @instance.from_scim_patch!(patch_hash: patch)
2696
+ end.to raise_error Scimitar::InvalidSyntaxError, "Missing PATCH \"operations\""
2697
+ end
2698
+
2688
2699
  it 'which updates simple values' do
2689
2700
  @instance.update!(username: 'foo')
2690
2701
 
@@ -42,25 +42,25 @@ RSpec.describe Scimitar::Resources::User do
42
42
  let(:user) { described_class.new }
43
43
 
44
44
  it 'adds the error when the value is a string' do
45
- user.add_errors_from_hash({key: 'some error'})
45
+ user.add_errors_from_hash(errors_hash: {key: 'some error'})
46
46
  expect(user.errors.messages.to_h).to eql({key: ['some error']})
47
47
  expect(user.errors.full_messages).to eql(['Key some error'])
48
48
  end
49
49
 
50
50
  it 'adds the error when the value is an array' do
51
- user.add_errors_from_hash({key: ['error1', 'error2']})
51
+ user.add_errors_from_hash(errors_hash: {key: ['error1', 'error2']})
52
52
  expect(user.errors.messages.to_h).to eql({key: ['error1', 'error2']})
53
53
  expect(user.errors.full_messages).to eql(['Key error1', 'Key error2'])
54
54
  end
55
55
 
56
56
  it 'adds the error with prefix when the value is a string' do
57
- user.add_errors_from_hash({key: 'some error'}, prefix: :pre)
57
+ user.add_errors_from_hash(errors_hash: {key: 'some error'}, prefix: :pre)
58
58
  expect(user.errors.messages.to_h).to eql({:'pre.key' => ['some error']})
59
59
  expect(user.errors.full_messages).to eql(['Pre key some error'])
60
60
  end
61
61
 
62
62
  it 'adds the error wity prefix when the value is an array' do
63
- user.add_errors_from_hash({key: ['error1', 'error2']}, prefix: :pre)
63
+ user.add_errors_from_hash(errors_hash: {key: ['error1', 'error2']}, prefix: :pre)
64
64
  expect(user.errors.messages.to_h).to eql({:'pre.key' => ['error1', 'error2']})
65
65
  expect(user.errors.full_messages).to eql(['Pre key error1', 'Pre key error2'])
66
66
  end
@@ -46,6 +46,28 @@ RSpec.describe Scimitar::Schema::Attribute do
46
46
  expect(attribute.errors.messages.to_h).to eql({userName: ['has the wrong type. It has to be a(n) string.']})
47
47
  end
48
48
 
49
+ it 'is valid if multi-valued and type is string and given value is an array of strings' do
50
+ attribute = described_class.new(name: 'scopes', multiValued: true, type: 'string')
51
+ expect(attribute.valid?(['something', 'something else'])).to be(true)
52
+ end
53
+
54
+ it 'is valid if multi-valued and type is string and given value is an empty array' do
55
+ attribute = described_class.new(name: 'scopes', multiValued: true, type: 'string')
56
+ expect(attribute.valid?([])).to be(true)
57
+ end
58
+
59
+ it 'is invalid if multi-valued and type is string and given value is not an array' do
60
+ attribute = described_class.new(name: 'scopes', multiValued: true, type: 'string')
61
+ expect(attribute.valid?('something')).to be(false)
62
+ expect(attribute.errors.messages.to_h).to eql({scopes: ['or one of its elements has the wrong type. It has to be an array of strings.']})
63
+ end
64
+
65
+ it 'is invalid if multi-valued and type is string and given value is an array containing another type' do
66
+ attribute = described_class.new(name: 'scopes', multiValued: true, type: 'string')
67
+ expect(attribute.valid?(['something', 123])).to be(false)
68
+ expect(attribute.errors.messages.to_h).to eql({scopes: ['or one of its elements has the wrong type. It has to be an array of strings.']})
69
+ end
70
+
49
71
  it 'is valid if type is boolean and given value is boolean' do
50
72
  expect(described_class.new(name: 'name', type: 'boolean').valid?(false)).to be(true)
51
73
  expect(described_class.new(name: 'name', type: 'boolean').valid?(true)).to be(true)
@@ -5,8 +5,6 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
5
5
  before :each do
6
6
  allow_any_instance_of(Scimitar::ApplicationController).to receive(:authenticated?).and_return(true)
7
7
 
8
- lmt = Time.parse("2023-01-09 14:25:00 +1300")
9
-
10
8
  # If a sort order is unspecified, the controller defaults to ID ascending.
11
9
  # With UUID based IDs, testing life is made easier by ensuring that the
12
10
  # creation order matches an ascending UUID sort order (which is what would
@@ -282,7 +280,7 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
282
280
  it 'with minimal parameters' do
283
281
  mock_before = MockUser.all.to_a
284
282
 
285
- attributes = { userName: '4' } # Minimum required by schema
283
+ attributes = { userName: '4' } # Minimum required by schema
286
284
  attributes = spec_helper_hupcase(attributes) if force_upper_case
287
285
 
288
286
  expect_any_instance_of(MockUsersController).to receive(:create).once.and_call_original
@@ -337,7 +335,7 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
337
335
  expect(response.status).to eql(201)
338
336
  result = JSON.parse(response.body)
339
337
 
340
- expect(result['id']).to eql(new_mock.primary_key.to_s)
338
+ expect(result['id']).to eql(new_mock.id.to_s)
341
339
  expect(result['meta']['resourceType']).to eql('User')
342
340
  expect(new_mock.username).to eql('4')
343
341
  expect(new_mock.first_name).to eql('Given')
@@ -399,6 +397,22 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
399
397
  expect(result['scimType']).to eql('invalidValue')
400
398
  expect(result['detail']).to include('is reserved')
401
399
  end
400
+
401
+ it 'invokes a block if given one' do
402
+ mock_before = MockUser.all.to_a
403
+ attributes = { userName: '5' } # Minimum required by schema
404
+
405
+ expect_any_instance_of(CustomSaveMockUsersController).to receive(:create).once.and_call_original
406
+ expect {
407
+ post "/CustomSaveUsers", params: attributes.merge(format: :scim)
408
+ }.to change { MockUser.count }.by(1)
409
+
410
+ mock_after = MockUser.all.to_a
411
+ new_mock = (mock_after - mock_before).first
412
+
413
+ expect(response.status).to eql(201)
414
+ expect(new_mock.username).to eql(CustomSaveMockUsersController::CUSTOM_SAVE_BLOCK_USERNAME_INDICATOR)
415
+ end
402
416
  end # "context '#create' do"
403
417
 
404
418
  # ===========================================================================
@@ -18,7 +18,7 @@ RSpec.describe Scimitar::ApplicationController do
18
18
  parsed_body = JSON.parse(response.body)
19
19
  expect(parsed_body['request']['is_scim' ]).to eql(true)
20
20
  expect(parsed_body['request']['format' ]).to eql('application/scim+json')
21
- expect(parsed_body['request']['content_type']).to eql('application/scim+json')
21
+ expect(parsed_body['request']['content_type']).to eql('application/scim+json') # Filled in by ApplicationController#require_scim
22
22
  end
23
23
 
24
24
  it 'renders 400 if given bad JSON' do
@@ -26,7 +26,6 @@ RSpec.describe Scimitar::ApplicationController do
26
26
 
27
27
  expect(response).to have_http_status(:bad_request)
28
28
  expect(JSON.parse(response.body)['detail']).to start_with('Invalid JSON - ')
29
- expect(JSON.parse(response.body)['detail']).to include("'not-json-12345'")
30
29
  end
31
30
 
32
31
  it 'translates Content-Type to Rails request format' do
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.5.3
4
+ version: 1.7.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-09-16 00:00:00.000000000 Z
12
+ date: 2023-11-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -196,6 +196,7 @@ files:
196
196
  - lib/scimitar/version.rb
197
197
  - spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb
198
198
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
199
+ - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
199
200
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
200
201
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
201
202
  - spec/apps/dummy/app/models/mock_group.rb
@@ -260,13 +261,14 @@ required_rubygems_version: !ruby/object:Gem::Requirement
260
261
  - !ruby/object:Gem::Version
261
262
  version: '0'
262
263
  requirements: []
263
- rubygems_version: 3.4.4
264
+ rubygems_version: 3.4.10
264
265
  signing_key:
265
266
  specification_version: 4
266
267
  summary: SCIM v2 for Rails
267
268
  test_files:
268
269
  - spec/apps/dummy/app/controllers/custom_destroy_mock_users_controller.rb
269
270
  - spec/apps/dummy/app/controllers/custom_request_verifiers_controller.rb
271
+ - spec/apps/dummy/app/controllers/custom_save_mock_users_controller.rb
270
272
  - spec/apps/dummy/app/controllers/mock_groups_controller.rb
271
273
  - spec/apps/dummy/app/controllers/mock_users_controller.rb
272
274
  - spec/apps/dummy/app/models/mock_group.rb