scimitar 2.13.0 → 2.15.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 172c9b71a2da57bcc0b0ebc02c551a5ed8e70c7efda9fcbb01a6cea7b99f6c6b
4
- data.tar.gz: 0d6fc7e2dcbe4fabcef481c197b96c6f757af8cc5f1ad8b5990a13c346d39517
3
+ metadata.gz: 7c705a0369ccfd6204da2dd2a0e5a17a15f2fa658703218404d7556c1568a891
4
+ data.tar.gz: cd8ab1a8fc432533a6a0e4ee6045f20677c9164dfd330c416a68b5728b58947f
5
5
  SHA512:
6
- metadata.gz: 060e4aff1aa65516fef31e8c0403dfda82d2b015e990bcfed7b45e78830b8f545db30c8abe915126e4a760e62c4f5f34660b19b0d30f1815cc6575406961f9c4
7
- data.tar.gz: 85659a28c38997bbb937fe153b48f77c4fe75f98d6c6dbad82006d8cd30d029db5f5e7e33e7fd614ee2630aba2ec558338d9de4e888be884fa11eb1159c261a4
6
+ metadata.gz: 11846119f8df609b6f75f95a436a78e2ca9dceed1a9d236cd08c89df052e45677efcee573943feaee719b7fff6f648bd13622a47d32f493bb69d3b8a58415363
7
+ data.tar.gz: 32bdc2ba6b4613c498ed988bfb2a4397c26f1d0196341f071b52d4bdb13065b9a658654dba1666bbb75c6a185afa924d6b6aa60d2f4ef9784cc9af6df4395623
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 RIPA Global
3
+ Copyright (c) 2026 RIPA Global c/o Andrew Hodgkinson
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -64,7 +64,7 @@ Some aspects of configuration are handled via a `config/initializers/scimitar.rb
64
64
  Rails.application.config.to_prepare do
65
65
  Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
66
66
  # ...see subsections below for configuration options...
67
- end
67
+ })
68
68
  end
69
69
  ```
70
70
 
@@ -184,7 +184,36 @@ Note that Okta has some [curious documentation on its use of `POST` vs `PATCH` f
184
184
 
185
185
  ### Google Workspace note
186
186
 
187
- Using SCIM with Google Workspace might only work for a subset of applications. Since web UIs for major service providers change very often, it doesn't make sense to provide extensive documentation here as it would get out of date quickly; you may have to figure out the setup as best you can using whatever current Google documentation exists for their system. There are [some notes which were relevant around mid-2025](https://github.com/pond/scimitar/issues/142#issuecomment-2699050541) (from when a workarond/fix was incorporated into Scimitar to allow it to work with Google Workspace) which may help you get started.
187
+ Using SCIM with Google Workspace might only work for a subset of applications. Since web UIs for major service providers change very often, it doesn't make sense to provide extensive documentation here as it would get out of date quickly; you may have to figure out the setup as best you can using whatever current Google documentation exists for their system. There are [some notes which were relevant around mid-2025](https://github.com/pond/scimitar/issues/142#issuecomment-2699050541) (from when a workaround/fix was incorporated into Scimitar to allow it to work with Google Workspace) which may help you get started.
188
+
189
+ ### Request content type handling
190
+
191
+ The correct content type for SCIM is `application/scim+json`. Scimitar tolerates some variants of this, rewriting things internally so that the request continues to be processed by the rest of the gem (including ending up in subclass code you write) under this media type, with a Rails request format of `:scim`. Sometimes, callers into SCIM endpoints might use a content type that Scimitar rejects. If so, you can configure a custom request sanitizer Proc (with a "z", in keeping with Rails spelling of "sanitize"):
192
+
193
+ ```ruby
194
+ Rails.application.config.to_prepare do
195
+ Scimitar.engine_configuration = Scimitar::EngineConfiguration.new({
196
+
197
+ custom_request_sanitizer: Proc.new do | request |
198
+ #
199
+ # Examine e.g. 'request.media_type' and evaluate to a Symbol:
200
+ #
201
+ # :success - set the standard content type and ensure the Rails request
202
+ # format is :scim; continue processing normally
203
+ #
204
+ # :preserve - retain the existing content type and Rails request format;
205
+ # continue processing normally
206
+ #
207
+ # :fail - the request format appears to be invalid; generate a 406
208
+ # ("Not Acceptable") response
209
+ #
210
+ end
211
+
212
+ })
213
+ end
214
+ ```
215
+
216
+ The Proc is passed the [`ActionDispatch::Request`](https://api.rubyonrails.org/classes/ActionDispatch/Request.html) instance for the current request. It **must** evaluable to a Symbol as shown above. Typically, `:preserve` is only for very special use cases where you understand that the request headers and/or format might not match what other parts of Scimitar expect, but have written appropriate custom code elsewhere to deal with that.
188
217
 
189
218
  ### Data models
190
219
 
data/Rakefile CHANGED
@@ -1,7 +1,6 @@
1
1
  require 'rake'
2
2
  require 'rspec/core/rake_task'
3
3
  require 'rdoc/task'
4
- require 'sdoc'
5
4
 
6
5
  RSpec::Core::RakeTask.new(:default) do | t |
7
6
  end
@@ -12,5 +11,5 @@ Rake::RDocTask.new do | rd |
12
11
  rd.title = 'Scimitar'
13
12
  rd.main = 'README.md'
14
13
  rd.rdoc_dir = 'docs/rdoc'
15
- rd.generator = 'sdoc'
14
+ rd.generator = 'rdoc'
16
15
  end
@@ -36,11 +36,17 @@ module Scimitar
36
36
 
37
37
  pagination_info = scim_pagination_info(query.count())
38
38
 
39
- page_of_results = query
40
- .order(@id_column => :asc)
41
- .offset(pagination_info.offset)
42
- .limit(pagination_info.limit)
43
- .to_a()
39
+ # SCIM 2.0 RFC 7644: When count=0, return metadata only (no Resources).
40
+ # This avoids an unnecessary database query for record data.
41
+ page_of_results = if pagination_info.limit == 0
42
+ []
43
+ else
44
+ query
45
+ .order(@id_column => :asc)
46
+ .offset(pagination_info.offset)
47
+ .limit(pagination_info.limit)
48
+ .to_a()
49
+ end
44
50
 
45
51
  super(pagination_info, page_of_results) do | record |
46
52
  record_to_scim(record)
@@ -48,7 +48,7 @@ module Scimitar
48
48
  #
49
49
  def handle_scim_error(error_response, exception = error_response)
50
50
  unless Scimitar.engine_configuration.exception_reporter.nil?
51
- Scimitar.engine_configuration.exception_reporter.call(exception)
51
+ instance_exec(exception, &Scimitar.engine_configuration.exception_reporter)
52
52
  end
53
53
 
54
54
  render json: error_response, status: error_response.status
@@ -93,19 +93,37 @@ module Scimitar
93
93
  #
94
94
  def require_scim
95
95
  scim_mime_type = Mime::Type.lookup_by_extension(:scim).to_s
96
+ failure_detail = "Only #{scim_mime_type} type is accepted."
97
+
98
+ if Scimitar.engine_configuration.custom_request_sanitizer.is_a?(Proc)
99
+
100
+ result = Scimitar.engine_configuration.custom_request_sanitizer.call(request)
101
+ case result
102
+ when :fail
103
+ handle_scim_error(ErrorResponse.new(status: 406, detail: failure_detail))
104
+ when :preserve
105
+ # Do nothing
106
+ else
107
+ request.format = :scim
108
+ request.headers['CONTENT_TYPE'] = scim_mime_type
109
+ end
110
+
111
+ else # "if Scimitar.engine_configuration.custom_request_sanitizer.present?"
112
+
113
+ if request.media_type.nil? || request.media_type.empty?
114
+ request.format = :scim
115
+ request.headers['CONTENT_TYPE'] = scim_mime_type
116
+ elsif request.media_type.downcase == scim_mime_type
117
+ request.format = :scim
118
+ elsif request.format == :scim
119
+ request.headers['CONTENT_TYPE'] = scim_mime_type
120
+ elsif request.media_type.downcase == 'application/json' && request.user_agent&.start_with?('Google') # https://github.com/pond/scimitar/issues/142
121
+ request.format = :scim
122
+ request.headers["CONTENT_TYPE"] = scim_mime_type
123
+ else
124
+ handle_scim_error(ErrorResponse.new(status: 406, detail: failure_detail))
125
+ end
96
126
 
97
- if request.media_type.nil? || request.media_type.empty?
98
- request.format = :scim
99
- request.headers['CONTENT_TYPE'] = scim_mime_type
100
- elsif request.media_type.downcase == scim_mime_type
101
- request.format = :scim
102
- elsif request.format == :scim
103
- request.headers['CONTENT_TYPE'] = scim_mime_type
104
- elsif request.media_type.downcase == 'application/json' && request.user_agent.start_with?('Google') # https://github.com/pond/scimitar/issues/142
105
- request.format = :scim
106
- request.headers["CONTENT_TYPE"] = scim_mime_type
107
- else
108
- handle_scim_error(ErrorResponse.new(status: 406, detail: "Only #{scim_mime_type} type is accepted."))
109
127
  end
110
128
  end
111
129
 
@@ -12,9 +12,11 @@ module Scimitar
12
12
  :basic_authenticator,
13
13
  :token_authenticator,
14
14
  :custom_authenticator,
15
+ :custom_request_sanitizer,
15
16
  :application_controller_mixin,
16
17
  :exception_reporter,
17
18
  :optional_value_fields_required,
19
+ :render_mapped_nil_values_in_response,
18
20
  :schema_list_from_attribute_mappings,
19
21
  )
20
22
 
@@ -24,8 +26,9 @@ module Scimitar
24
26
  # Set defaults that may be overridden by the initializer.
25
27
  #
26
28
  defaults = {
27
- optional_value_fields_required: true,
28
- schema_list_from_attribute_mappings: []
29
+ optional_value_fields_required: true,
30
+ render_mapped_nil_values_in_response: true,
31
+ schema_list_from_attribute_mappings: []
29
32
  }
30
33
 
31
34
  super(defaults.merge(attributes))
@@ -15,9 +15,12 @@ module Scimitar
15
15
 
16
16
  # Set a limit (page size) value.
17
17
  #
18
- # +value+:: Integer value held in a String. Must be >= 1.
18
+ # +value+:: Integer value held in a String. Must be >= 0.
19
+ #
20
+ # Per SCIM 2.0 RFC 7644 Section 3.4.2.4: "A value of '0' indicates that
21
+ # no resource results are to be returned except for 'totalResults'."
19
22
  #
20
- # Raises exceptions if given non-numeric, zero or negative input.
23
+ # Raises exceptions if given non-numeric or negative input.
21
24
  #
22
25
  def limit=(value)
23
26
  value = value&.to_s
@@ -25,7 +28,7 @@ module Scimitar
25
28
 
26
29
  validate_numericality(value)
27
30
  input = value.to_i
28
- raise if input < 1
31
+ raise if input < 0
29
32
  @limit = input
30
33
  end
31
34
 
@@ -612,7 +612,13 @@ module Scimitar
612
612
  end
613
613
  end
614
614
 
615
- result.compact! if include_attributes.any?
615
+ if (
616
+ include_attributes.any? or
617
+ ! Scimitar.engine_configuration.render_mapped_nil_values_in_response
618
+ )
619
+ result.compact!
620
+ end
621
+
616
622
  result
617
623
 
618
624
  when Array # Static or dynamic mapping against lists in data source
@@ -102,7 +102,9 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
102
102
  # JSON response to the API caller. If you want exceptions to also be
103
103
  # reported to a third party system such as sentry.io or raygun.com, you can
104
104
  # configure a Proc to do so. It is passed a Ruby exception subclass object.
105
- # For example, a minimal sentry.io reporter might do this:
105
+ # The Proc is called via 'instance_exec' in the controller context, so you
106
+ # have access to things like 'request', 'params' and 'action_name'. For
107
+ # example, a minimal sentry.io reporter might do this:
106
108
  #
107
109
  # exception_reporter: Proc.new do | exception |
108
110
  # Sentry.capture_exception(exception)
@@ -119,6 +121,12 @@ Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
119
121
  #
120
122
  # optional_value_fields_required: false
121
123
 
124
+ # When rendering responses, +nil+ values can either still be included via
125
+ # the attributes map with a JSON value of +null+, or omitted. By default,
126
+ # all attributes in your map are returned in responses.
127
+ #
128
+ # render_mapped_nil_values_in_response: false
129
+
122
130
  # The SCIM standard `/Schemas` endpoint lists, by default, all known schema
123
131
  # definitions with the mutabilty (read-write, read-only, write-only) state
124
132
  # described by those definitions, and includes all defined attributes. For
@@ -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.13.0'
6
+ VERSION = '2.15.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 = '2025-09-12'
11
+ DATE = '2026-03-06'
12
12
 
13
13
  end
@@ -352,8 +352,9 @@ RSpec.describe Scimitar::ApplicationController do
352
352
  context 'with an exception reporter' do
353
353
  around :each do | example |
354
354
  original_configuration = Scimitar.engine_configuration.exception_reporter
355
+ exceptions = @exceptions = []
355
356
  Scimitar.engine_configuration.exception_reporter = Proc.new do | exception |
356
- @exception = exception
357
+ exceptions << exception
357
358
  end
358
359
  example.run()
359
360
  ensure
@@ -364,8 +365,8 @@ RSpec.describe Scimitar::ApplicationController do
364
365
  it 'is invoked' do
365
366
  get :index, params: { format: :scim }
366
367
 
367
- expect(@exception).to be_a(RuntimeError)
368
- expect(@exception.message).to eql('Bang')
368
+ expect(@exceptions.first).to be_a(RuntimeError)
369
+ expect(@exceptions.first.message).to eql('Bang')
369
370
  end
370
371
  end
371
372
 
@@ -379,8 +380,8 @@ RSpec.describe Scimitar::ApplicationController do
379
380
  it 'is invoked' do
380
381
  get :index, params: { format: :scim }
381
382
 
382
- expect(@exception).to be_a(ActiveRecord::RecordNotFound)
383
- expect(@exception.message).to eql('42')
383
+ expect(@exceptions.first).to be_a(ActiveRecord::RecordNotFound)
384
+ expect(@exceptions.first.message).to eql('42')
384
385
  end
385
386
  end
386
387
 
@@ -398,8 +399,8 @@ RSpec.describe Scimitar::ApplicationController do
398
399
  it 'is invoked' do
399
400
  get :index, params: { format: :scim }
400
401
 
401
- expect(@exception).to be_a(ActionDispatch::Http::Parameters::ParseError)
402
- expect(@exception.message).to eql('Hello')
402
+ expect(@exceptions.first).to be_a(ActionDispatch::Http::Parameters::ParseError)
403
+ expect(@exceptions.first.message).to eql('Hello')
403
404
  end
404
405
  end
405
406
 
@@ -412,8 +413,8 @@ RSpec.describe Scimitar::ApplicationController do
412
413
  request.headers['Content-Type'] = 'text/plain'
413
414
  get :index
414
415
 
415
- expect(@exception).to be_a(Scimitar::ErrorResponse)
416
- expect(@exception.message).to eql('Only application/scim+json type is accepted.')
416
+ expect(@exceptions.first).to be_a(Scimitar::ErrorResponse)
417
+ expect(@exceptions.first.message).to eql('Only application/scim+json type is accepted.')
417
418
  end
418
419
  end
419
420
 
@@ -423,18 +424,90 @@ RSpec.describe Scimitar::ApplicationController do
423
424
  request.headers['User-Agent' ] = 'Google-Auto-Provisioning'
424
425
  get :index
425
426
 
426
- expect(@exception).to be_a(RuntimeError)
427
- expect(@exception.message).to eql('Bang')
427
+ expect(@exceptions.first).to be_a(RuntimeError)
428
+ expect(@exceptions.first.message).to eql('Bang')
428
429
  end
429
430
 
430
431
  it 'is invoked early for unrecognised agents' do
431
432
  request.headers['Content-Type'] = 'application/json'
432
433
  get :index
433
434
 
434
- expect(@exception).to be_a(Scimitar::ErrorResponse)
435
- expect(@exception.message).to eql('Only application/scim+json type is accepted.')
435
+ expect(@exceptions.first).to be_a(Scimitar::ErrorResponse)
436
+ expect(@exceptions.first.message).to eql('Only application/scim+json type is accepted.')
436
437
  end
437
438
  end # "context 'and with Google SCIM calls' do"
439
+
440
+ context 'and with a custom request sanitizer' do
441
+ around :each do | example |
442
+ original_configuration = Scimitar.engine_configuration.custom_request_sanitizer
443
+ Scimitar.engine_configuration.custom_request_sanitizer = Proc.new do | request |
444
+ case request.media_type
445
+ when 'application/json+success'
446
+ :success
447
+ when 'application/json+preserve'
448
+ :preserve
449
+ else
450
+ :fail
451
+ end
452
+ end
453
+ example.run()
454
+ ensure
455
+ Scimitar.engine_configuration.custom_request_sanitizer = original_configuration
456
+ end
457
+
458
+ context 'returning "success"' do
459
+ it 'reaches the controller action with cleaned up request data' do
460
+ request.headers['Content-Type'] = 'application/json+success'
461
+ get :index
462
+
463
+ expect(@exceptions.first).to be_a(RuntimeError)
464
+ expect(@exceptions.first.message).to eql('Bang')
465
+
466
+ expect(request.format == :scim).to eql(true)
467
+ expect(request.headers['CONTENT_TYPE']).to eql('application/scim+json')
468
+ end
469
+ end # "context 'returning "success"' do"
470
+
471
+ context 'returning "preserve"' do
472
+ it 'reaches the controller action with unmodified request data' do
473
+ request.headers['Content-Type'] = 'application/json+preserve'
474
+ get :index
475
+
476
+ expect(@exceptions.first).to be_a(RuntimeError)
477
+ expect(@exceptions.first.message).to eql('Bang')
478
+
479
+ expect(request.format == :html).to eql(true)
480
+ expect(request.headers['CONTENT_TYPE']).to eql('application/json+preserve')
481
+ end
482
+ end # "context 'returning "keep"' do"
483
+
484
+ context 'returning "fail"' do
485
+ it 'is invoked' do
486
+ request.headers['Content-Type'] = 'application/json+fail'
487
+ get :index
488
+
489
+ expect(@exceptions.first).to be_a(Scimitar::ErrorResponse)
490
+ expect(@exceptions.first.message).to eql('Only application/scim+json type is accepted.')
491
+ end
492
+ end # "context 'returning "fail"' do"
493
+ end # "context 'and with a custom request sanitizer' do"
494
+
495
+ context 'evaluated in controller context' do
496
+ it 'has access to controller methods like request and params' do
497
+ reported_request_method = nil
498
+ reported_params = nil
499
+ Scimitar.engine_configuration.exception_reporter = Proc.new do | exception |
500
+ reported_request_method = request.method
501
+ reported_params = params
502
+ end
503
+
504
+ get :index, params: { format: :scim, foo: 'bar' }
505
+
506
+ expect(reported_request_method).to eql('GET')
507
+ expect(reported_params['format']).to eql('scim')
508
+ expect(reported_params['foo']).to eql('bar')
509
+ end
510
+ end
438
511
  end # "context 'exception reporter' do"
439
512
  end # "context 'error handling' do"
440
513
  end
@@ -34,12 +34,17 @@ RSpec.describe Scimitar::Lists::Count do
34
34
  expect { @instance.limit = 'A' }.to raise_error(RuntimeError)
35
35
  end
36
36
 
37
- it 'complains about attempts to set zero values' do
38
- expect { @instance.limit = '0' }.to raise_error(RuntimeError)
37
+ it 'allows count=0 per SCIM 2.0 specification (RFC 7644)' do
38
+ expect { @instance.limit = '0' }.to_not raise_error
39
+ expect(@instance.limit).to eql(0)
39
40
  end
40
41
 
41
- it 'complains about attempts to set zero values' do
42
+ it 'allows count=0 as integer' do
43
+ expect { @instance.limit = 0 }.to_not raise_error
44
+ expect(@instance.limit).to eql(0)
45
+ end
42
46
 
47
+ it 'complains about attempts to set negative values' do
43
48
  expect { @instance.limit = '-10' }.to raise_error(RuntimeError)
44
49
  end
45
50
  end # "context 'on-read error checking' do"
@@ -267,7 +267,7 @@ RSpec.describe Scimitar::Resources::Mixin do
267
267
  instance.first_name = 'Foo'
268
268
  instance.last_name = 'Bar'
269
269
  instance.work_email_address = 'foo.bar@test.com'
270
- instance.home_email_address = nil
270
+ instance.home_email_address = 'foo.bar@example.com'
271
271
  instance.work_phone_number = '+642201234567'
272
272
  instance.organization = 'SOMEORG'
273
273
 
@@ -299,6 +299,49 @@ RSpec.describe Scimitar::Resources::Mixin do
299
299
  'urn:ietf:params:scim:schemas:extension:manager:1.0:User' => {},
300
300
  })
301
301
  end
302
+
303
+ it 'hides "nil" value attributes' do
304
+ uuid = SecureRandom.uuid
305
+
306
+ instance = MockUser.new
307
+ instance.primary_key = uuid
308
+ instance.scim_uid = 'AA02984'
309
+ instance.username = nil
310
+ instance.password = 'correcthorsebatterystaple'
311
+ instance.first_name = nil
312
+ instance.last_name = 'Bar'
313
+ instance.work_email_address = 'foo.bar@test.com'
314
+ instance.home_email_address = 'foo.bar@example.com'
315
+ instance.work_phone_number = '+642201234567'
316
+ instance.organization = 'SOMEORG'
317
+
318
+ g1 = MockGroup.create!(display_name: 'Group 1')
319
+ g2 = MockGroup.create!(display_name: 'Group 2')
320
+ g3 = MockGroup.create!(display_name: 'Group 3')
321
+
322
+ g1.mock_users << instance
323
+ g3.mock_users << instance
324
+
325
+ scim = instance.to_scim(location: "https://test.com/mock_users/#{uuid}", include_attributes: %w[id userName name groups.display groups.value organization])
326
+ json = scim.to_json()
327
+ hash = JSON.parse(json)
328
+
329
+ expect(hash).to eql({
330
+ 'id' => uuid,
331
+ 'name' => {'familyName'=>'Bar'},
332
+ 'groups' => [{'display'=>g1.display_name, 'value'=>g1.id.to_s}, {'display'=>g3.display_name, 'value'=>g3.id.to_s}],
333
+ 'meta' => {'location'=>"https://test.com/mock_users/#{uuid}", 'resourceType'=>'User'},
334
+ 'schemas' => [
335
+ 'urn:ietf:params:scim:schemas:core:2.0:User',
336
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
337
+ 'urn:ietf:params:scim:schemas:extension:manager:1.0:User',
338
+ ],
339
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
340
+ 'organization' => 'SOMEORG',
341
+ },
342
+ 'urn:ietf:params:scim:schemas:extension:manager:1.0:User' => {},
343
+ })
344
+ end
302
345
  end # "context 'with list of requested attributes' do"
303
346
 
304
347
  context 'with a UUID, renamed primary key column' do
@@ -332,7 +375,7 @@ RSpec.describe Scimitar::Resources::Mixin do
332
375
  'userName' => 'foo',
333
376
  'name' => {'givenName'=>'Foo', 'familyName'=>'Bar'},
334
377
  'active' => true,
335
- 'emails' => [{'type'=>'work', 'primary'=>true, 'value'=>'foo.bar@test.com'}, {"primary"=>false, "type"=>"home", "value"=>nil}],
378
+ 'emails' => [{'type'=>'work', 'primary'=>true, 'value'=>'foo.bar@test.com'}, {'primary'=>false, 'type'=>'home', 'value'=>nil}],
336
379
  'phoneNumbers'=> [{'type'=>'work', 'primary'=>false, 'value'=>'+642201234567'}],
337
380
  'id' => uuid,
338
381
  'externalId' => 'AA02984',
@@ -353,6 +396,71 @@ RSpec.describe Scimitar::Resources::Mixin do
353
396
  },
354
397
  })
355
398
  end
399
+
400
+ context 'and when configured to omit "nil" values in the response' do
401
+ around :each do | example |
402
+ original_configuration = Scimitar.engine_configuration.render_mapped_nil_values_in_response
403
+ Scimitar.engine_configuration.render_mapped_nil_values_in_response = false
404
+ example.run()
405
+ ensure
406
+ Scimitar.engine_configuration.render_mapped_nil_values_in_response = original_configuration
407
+ end
408
+
409
+ it 'omits "nil" values as expected' do
410
+ uuid = SecureRandom.uuid
411
+
412
+ instance = MockUser.new
413
+ instance.primary_key = uuid
414
+ instance.scim_uid = 'AA02984'
415
+ instance.username = 'foo'
416
+ instance.password = 'correcthorsebatterystaple'
417
+ instance.first_name = nil
418
+ instance.last_name = 'Bar'
419
+ instance.work_email_address = 'foo.bar@test.com'
420
+ instance.home_email_address = nil
421
+ instance.work_phone_number = '+642201234567'
422
+ instance.organization = 'SOMEORG'
423
+
424
+ g1 = MockGroup.create!(display_name: 'Group 1')
425
+ g2 = MockGroup.create!(display_name: 'Group 2')
426
+ g3 = MockGroup.create!(display_name: 'Group 3')
427
+
428
+ g1.mock_users << instance
429
+ g3.mock_users << instance
430
+
431
+ scim = instance.to_scim(location: "https://test.com/mock_users/#{uuid}")
432
+ json = scim.to_json()
433
+ hash = JSON.parse(json)
434
+
435
+ # Note currently limited implementation for things like static maps,
436
+ # where part of the returned value is included; in this case, the
437
+ # "primary" value is "false" for e-mail of type "home", so the
438
+ # structure for that *does* appear in the output array even though
439
+ # the source dynamic data field from the NockUser instance is "nil".
440
+ #
441
+ expect(hash).to eql({
442
+ 'userName' => 'foo',
443
+ 'name' => {'familyName'=>'Bar'},
444
+ 'active' => true,
445
+ 'emails' => [{'type'=>'work', 'primary'=>true, 'value'=>'foo.bar@test.com'}, {'primary' => false, 'type' => 'home'}],
446
+ 'phoneNumbers'=> [{'type'=>'work', 'primary'=>false, 'value'=>'+642201234567'}],
447
+ 'id' => uuid,
448
+ 'externalId' => 'AA02984',
449
+ 'groups' => [{'display'=>g1.display_name, 'value'=>g1.id.to_s}, {'display'=>g3.display_name, 'value'=>g3.id.to_s}],
450
+ 'meta' => {'location'=>"https://test.com/mock_users/#{uuid}", 'resourceType'=>'User'},
451
+ 'schemas' => [
452
+ 'urn:ietf:params:scim:schemas:core:2.0:User',
453
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
454
+ 'urn:ietf:params:scim:schemas:extension:manager:1.0:User',
455
+ ],
456
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => {
457
+ 'organization' => 'SOMEORG',
458
+ 'primaryEmail' => instance.work_email_address,
459
+ },
460
+ 'urn:ietf:params:scim:schemas:extension:manager:1.0:User' => {}
461
+ })
462
+ end
463
+ end # "context 'and when configured to omit "nil" values in the response'" do"
356
464
  end # "context 'with a UUID, renamed primary key column' do"
357
465
 
358
466
  context 'with an integer, conventionally named primary key column' do
@@ -292,6 +292,92 @@ RSpec.describe Scimitar::ActiveRecordBackedResourcesController do
292
292
  usernames = result['Resources'].map { |resource| resource['userName'] }
293
293
  expect(usernames).to match_array(['2', '3'])
294
294
  end
295
+
296
+ # SCIM 2.0 RFC 7644 Section 3.4.2.4: count=0 support
297
+ context 'with count=0' do
298
+ it 'returns 200 OK' do
299
+ get '/Users', params: {
300
+ format: :scim,
301
+ count: 0
302
+ }
303
+
304
+ expect(response.status).to eql(200)
305
+ expect(response.headers['Content-Type']).to eql('application/scim+json; charset=utf-8')
306
+ end
307
+
308
+ it 'returns totalResults with actual count' do
309
+ get '/Users', params: {
310
+ format: :scim,
311
+ count: 0
312
+ }
313
+
314
+ result = JSON.parse(response.body)
315
+ expect(result['totalResults']).to eql(3)
316
+ end
317
+
318
+ it 'returns itemsPerPage as 0' do
319
+ get '/Users', params: {
320
+ format: :scim,
321
+ count: 0
322
+ }
323
+
324
+ result = JSON.parse(response.body)
325
+ expect(result['itemsPerPage']).to eql(0)
326
+ end
327
+
328
+ it 'returns empty Resources array' do
329
+ get '/Users', params: {
330
+ format: :scim,
331
+ count: 0
332
+ }
333
+
334
+ result = JSON.parse(response.body)
335
+ expect(result['Resources']).to eql([])
336
+ end
337
+
338
+ it 'respects startIndex parameter' do
339
+ get '/Users', params: {
340
+ format: :scim,
341
+ count: 0,
342
+ startIndex: 5
343
+ }
344
+
345
+ result = JSON.parse(response.body)
346
+ expect(result['startIndex']).to eql(5)
347
+ end
348
+
349
+ it 'applies filters when calculating totalResults' do
350
+ get '/Users', params: {
351
+ format: :scim,
352
+ count: 0,
353
+ filter: 'name.familyName eq "Bar"'
354
+ }
355
+
356
+ result = JSON.parse(response.body)
357
+ expect(result['totalResults']).to eql(1)
358
+ expect(result['Resources']).to eql([])
359
+ end
360
+
361
+ it 'does not query for records (performance optimization)' do
362
+ # We should get the count but not fetch records
363
+ query_double = instance_double(ActiveRecord::Relation)
364
+ allow(MockUser).to receive(:all).and_return(query_double)
365
+ allow(query_double).to receive(:count).and_return(3)
366
+
367
+ # Should NOT call order, offset, limit, or to_a when count=0
368
+ expect(query_double).not_to receive(:order)
369
+ expect(query_double).not_to receive(:offset)
370
+ expect(query_double).not_to receive(:limit)
371
+ expect(query_double).not_to receive(:to_a)
372
+
373
+ get '/Users', params: {
374
+ format: :scim,
375
+ count: 0
376
+ }
377
+
378
+ expect(response.status).to eql(200)
379
+ end
380
+ end # "context 'with count=0' do"
295
381
  end # "context 'with items' do"
296
382
 
297
383
  context 'with bad calls' do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scimitar
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.13.0
4
+ version: 2.15.0
5
5
  platform: ruby
6
6
  authors:
7
- - RIPA Global
8
7
  - Andrew David Hodgkinson
8
+ - RIPA Global
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-09-12 00:00:00.000000000 Z
11
+ date: 2026-03-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '13.2'
47
+ version: '13.3'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '13.2'
54
+ version: '13.3'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: pg
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +86,14 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '6.14'
89
+ version: '7.2'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '6.14'
96
+ version: '7.2'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: warden
99
99
  requirement: !ruby/object:Gem::Requirement
@@ -296,7 +296,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
296
296
  - !ruby/object:Gem::Version
297
297
  version: '0'
298
298
  requirements: []
299
- rubygems_version: 3.6.2
299
+ rubygems_version: 4.0.3
300
300
  specification_version: 4
301
301
  summary: SCIM v2 for Rails
302
302
  test_files: