devise_scim 0.1.11

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +124 -0
  3. data/CHANGELOG.md +47 -0
  4. data/CODE_OF_CONDUCT.md +11 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +348 -0
  7. data/Rakefile +21 -0
  8. data/app/controllers/devise_scim/application_controller.rb +69 -0
  9. data/app/controllers/devise_scim/groups_controller.rb +67 -0
  10. data/app/controllers/devise_scim/resource_types_controller.rb +43 -0
  11. data/app/controllers/devise_scim/schemas_controller.rb +55 -0
  12. data/app/controllers/devise_scim/service_provider_controller.rb +34 -0
  13. data/app/controllers/devise_scim/users_controller.rb +281 -0
  14. data/docs/contributing.md +163 -0
  15. data/docs/custom_adapter.md +456 -0
  16. data/docs/idp_setup.md +335 -0
  17. data/docs/multi_tenant.md +328 -0
  18. data/docs/testing.md +444 -0
  19. data/lib/devise_scim/auth/base_strategy.rb +16 -0
  20. data/lib/devise_scim/auth/oauth_strategy.rb +28 -0
  21. data/lib/devise_scim/auth/token_strategy.rb +25 -0
  22. data/lib/devise_scim/concerns/scim_group_identifiable.rb +21 -0
  23. data/lib/devise_scim/concerns/scim_tenant.rb +41 -0
  24. data/lib/devise_scim/configuration.rb +92 -0
  25. data/lib/devise_scim/engine.rb +15 -0
  26. data/lib/devise_scim/filter/arel_visitor.rb +77 -0
  27. data/lib/devise_scim/filter/parser.rb +190 -0
  28. data/lib/devise_scim/middleware/authenticator.rb +51 -0
  29. data/lib/devise_scim/minitest.rb +57 -0
  30. data/lib/devise_scim/models/scim_tenant.rb +14 -0
  31. data/lib/devise_scim/models/scim_tenant_user.rb +15 -0
  32. data/lib/devise_scim/routing.rb +43 -0
  33. data/lib/devise_scim/rspec/factories.rb +17 -0
  34. data/lib/devise_scim/rspec/scim_helpers.rb +43 -0
  35. data/lib/devise_scim/rspec/shared_examples/discovery_endpoints.rb +94 -0
  36. data/lib/devise_scim/rspec/shared_examples/groups_endpoint.rb +148 -0
  37. data/lib/devise_scim/rspec/shared_examples/users_endpoint.rb +301 -0
  38. data/lib/devise_scim/rspec.rb +7 -0
  39. data/lib/devise_scim/scim/error.rb +59 -0
  40. data/lib/devise_scim/scim/group.rb +66 -0
  41. data/lib/devise_scim/scim/list_response.rb +32 -0
  42. data/lib/devise_scim/scim/patch_operation.rb +55 -0
  43. data/lib/devise_scim/scim/user.rb +161 -0
  44. data/lib/devise_scim/scim_adapter.rb +84 -0
  45. data/lib/devise_scim/version.rb +5 -0
  46. data/lib/devise_scim.rb +48 -0
  47. data/lib/generators/devise_scim/adapter_generator.rb +17 -0
  48. data/lib/generators/devise_scim/install_generator.rb +117 -0
  49. data/lib/generators/devise_scim/templates/add_scim_to_tenant.rb.tt +17 -0
  50. data/lib/generators/devise_scim/templates/add_scim_to_users.rb.tt +15 -0
  51. data/lib/generators/devise_scim/templates/application_scim_adapter.rb.tt +34 -0
  52. data/lib/generators/devise_scim/templates/create_scim_tenant_users.rb.tt +22 -0
  53. data/lib/generators/devise_scim/templates/create_scim_tenants.rb.tt +18 -0
  54. data/lib/generators/devise_scim/templates/devise_scim.rb.tt +53 -0
  55. data/sig/devise_scim.rbs +4 -0
  56. metadata +146 -0
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Filter
5
+ # Translates a Filter AST into Arel conditions applied to an AR scope.
6
+ class ArelVisitor
7
+ SCIM_TO_AR = {
8
+ "userName" => "email",
9
+ "externalId" => "scim_uid",
10
+ "active" => "scim_active",
11
+ "id" => "id",
12
+ "emails" => "email",
13
+ "emails.value" => "email",
14
+ "name.givenName" => "first_name",
15
+ "name.familyName" => "last_name"
16
+ }.freeze
17
+
18
+ def initialize(model)
19
+ @model = model
20
+ @table = model.arel_table
21
+ end
22
+
23
+ def apply(ast, scope)
24
+ scope.where(visit(ast))
25
+ end
26
+
27
+ private
28
+
29
+ def visit(node)
30
+ case node
31
+ when Comparison then visit_comparison(node)
32
+ when Conjunction then visit(node.left).and(visit(node.right))
33
+ when Disjunction then visit(node.left).or(visit(node.right))
34
+ when AttrPath then visit_attr_path(node)
35
+ else raise InvalidFilter, "Unknown AST node: #{node.class}"
36
+ end
37
+ end
38
+
39
+ # rubocop:disable Metrics/CyclomaticComplexity
40
+ def visit_comparison(node)
41
+ col_name = SCIM_TO_AR[node.attr_path] || node.attr_path
42
+ col = resolve_column(col_name)
43
+ val = node.value
44
+
45
+ case node.op
46
+ when "eq" then col.eq(val)
47
+ when "ne" then col.not_eq(val)
48
+ when "co" then col.matches("%#{sanitize_like(val)}%")
49
+ when "sw" then col.matches("#{sanitize_like(val)}%")
50
+ when "ew" then col.matches("%#{sanitize_like(val)}")
51
+ when "pr" then col.not_eq(nil)
52
+ when "gt" then col.gt(val)
53
+ when "ge" then col.gteq(val)
54
+ when "lt" then col.lt(val)
55
+ when "le" then col.lteq(val)
56
+ else raise InvalidFilter, "Unknown operator '#{node.op}'"
57
+ end
58
+ end
59
+ # rubocop:enable Metrics/CyclomaticComplexity
60
+
61
+ def visit_attr_path(node)
62
+ col_name = SCIM_TO_AR[node.attribute] || node.attribute
63
+ resolve_column(col_name).not_eq(nil)
64
+ end
65
+
66
+ def resolve_column(col_name)
67
+ raise InvalidFilter, "Unknown attribute '#{col_name}'" unless @model.column_names.include?(col_name)
68
+
69
+ @table[col_name]
70
+ end
71
+
72
+ def sanitize_like(str)
73
+ str.gsub(/[%_\\]/) { |c| "\\#{c}" }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Filter
5
+ Comparison = Struct.new(:attr_path, :op, :value, keyword_init: true)
6
+ Conjunction = Struct.new(:left, :right, keyword_init: true)
7
+ Disjunction = Struct.new(:left, :right, keyword_init: true)
8
+ AttrPath = Struct.new(:attribute, :sub_filter, :sub_attr, keyword_init: true)
9
+
10
+ # Recursive descent parser for SCIM filter expressions (RFC 7644 §3.4.2.2).
11
+ # Supported: eq/ne/co/sw/ew/pr/gt/ge/lt/le, and/or, attr[sub-filter].sub-attr comparisons.
12
+ class Parser # rubocop:disable Metrics/ClassLength
13
+ class ParseError < ::DeviseScim::InvalidFilter; end
14
+
15
+ Token = Struct.new(:type, :value, keyword_init: true)
16
+
17
+ COMP_OPS = %w[eq ne co sw ew pr gt ge lt le].freeze
18
+
19
+ def self.parse(str)
20
+ new(str).parse
21
+ end
22
+
23
+ def initialize(str)
24
+ @tokens = tokenize(str)
25
+ @pos = 0
26
+ end
27
+
28
+ def parse
29
+ ast = parse_or
30
+ raise ParseError, "Unexpected token '#{current&.value}'" unless at_end?
31
+
32
+ ast
33
+ end
34
+
35
+ private
36
+
37
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
38
+ def tokenize(str)
39
+ tokens = []
40
+ s = str.strip
41
+ until s.empty?
42
+ s = s.lstrip
43
+ break if s.empty?
44
+
45
+ # Use explicit match objects so String#gsub doesn't clobber $&.
46
+ if (m = /\A"((?:[^"\\]|\\.)*)"/.match(s))
47
+ tokens << Token.new(type: :string, value: m[1].gsub('\\"', '"'))
48
+ s = s[m[0].length..]
49
+ elsif (m = /\Atrue\b/i.match(s))
50
+ tokens << Token.new(type: :boolean, value: true)
51
+ s = s[m[0].length..]
52
+ elsif (m = /\Afalse\b/i.match(s))
53
+ tokens << Token.new(type: :boolean, value: false)
54
+ s = s[m[0].length..]
55
+ elsif (m = /\Anull\b/i.match(s))
56
+ tokens << Token.new(type: :null, value: nil)
57
+ s = s[m[0].length..]
58
+ elsif (m = /\Anot\b/i.match(s))
59
+ tokens << Token.new(type: :not, value: "not")
60
+ s = s[m[0].length..]
61
+ elsif (m = /\Aand\b/i.match(s))
62
+ tokens << Token.new(type: :and, value: "and")
63
+ s = s[m[0].length..]
64
+ elsif (m = /\Aor\b/i.match(s))
65
+ tokens << Token.new(type: :or, value: "or")
66
+ s = s[m[0].length..]
67
+ elsif (m = /\A(eq|ne|co|sw|ew|pr|gt|ge|lt|le)\b/i.match(s))
68
+ tokens << Token.new(type: :op, value: m[1].downcase)
69
+ s = s[m[0].length..]
70
+ elsif s.start_with?("(")
71
+ tokens << Token.new(type: :lparen, value: "(")
72
+ s = s[1..]
73
+ elsif s.start_with?(")")
74
+ tokens << Token.new(type: :rparen, value: ")")
75
+ s = s[1..]
76
+ elsif s.start_with?("[")
77
+ tokens << Token.new(type: :lbracket, value: "[")
78
+ s = s[1..]
79
+ elsif s.start_with?("]")
80
+ tokens << Token.new(type: :rbracket, value: "]")
81
+ s = s[1..]
82
+ elsif s.start_with?(".")
83
+ tokens << Token.new(type: :dot, value: ".")
84
+ s = s[1..]
85
+ elsif (m = /\A[\w:-]+/.match(s))
86
+ tokens << Token.new(type: :identifier, value: m[0])
87
+ s = s[m[0].length..]
88
+ elsif (m = /\A-?\d+(\.\d+)?/.match(s))
89
+ tokens << Token.new(type: :number, value: m[0].to_f)
90
+ s = s[m[0].length..]
91
+ else
92
+ raise ParseError, "Unexpected character '#{s[0]}'"
93
+ end
94
+ end
95
+ tokens
96
+ end
97
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
98
+
99
+ def parse_or
100
+ left = parse_and
101
+ while current&.type == :or
102
+ advance
103
+ left = Disjunction.new(left: left, right: parse_and)
104
+ end
105
+ left
106
+ end
107
+
108
+ def parse_and
109
+ left = parse_primary
110
+ while current&.type == :and
111
+ advance
112
+ left = Conjunction.new(left: left, right: parse_primary)
113
+ end
114
+ left
115
+ end
116
+
117
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
118
+ def parse_primary
119
+ if current&.type == :lparen
120
+ advance
121
+ node = parse_or
122
+ expect!(:rparen)
123
+ return node
124
+ end
125
+
126
+ attr = expect!(:identifier).value
127
+
128
+ if current&.type == :lbracket
129
+ advance
130
+ sub_filter = parse_or
131
+ expect!(:rbracket)
132
+ sub_attr = extract_sub_attr
133
+ if current&.type == :op
134
+ op = advance.value
135
+ val = parse_value
136
+ Comparison.new(attr_path: sub_attr ? "#{attr}.#{sub_attr}" : attr, op: op, value: val)
137
+ else
138
+ AttrPath.new(attribute: attr, sub_filter: sub_filter, sub_attr: sub_attr)
139
+ end
140
+ elsif current&.type == :op
141
+ op = advance.value
142
+ val = op == "pr" ? nil : parse_value
143
+ Comparison.new(attr_path: attr, op: op, value: val)
144
+ else
145
+ raise ParseError, "Expected comparator after '#{attr}', got #{current&.value.inspect}"
146
+ end
147
+ end
148
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
149
+
150
+ def extract_sub_attr
151
+ return nil unless current&.type == :dot
152
+
153
+ advance
154
+ expect!(:identifier).value
155
+ end
156
+
157
+ def parse_value
158
+ tok = current
159
+ case tok&.type
160
+ when :string, :boolean, :null, :number
161
+ advance
162
+ tok.value
163
+ else
164
+ raise ParseError, "Expected value, got #{tok&.value.inspect}"
165
+ end
166
+ end
167
+
168
+ def current
169
+ @tokens[@pos]
170
+ end
171
+
172
+ def advance
173
+ tok = @tokens[@pos]
174
+ @pos += 1
175
+ tok
176
+ end
177
+
178
+ def at_end?
179
+ @pos >= @tokens.length
180
+ end
181
+
182
+ def expect!(type)
183
+ tok = advance
184
+ raise ParseError, "Expected #{type}, got #{tok&.type} (#{tok&.value.inspect})" unless tok&.type == type
185
+
186
+ tok
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Middleware
5
+ class Authenticator
6
+ SCIM_CONTENT_TYPE = "application/scim+json"
7
+
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless scim_path?(env["PATH_INFO"])
14
+
15
+ result = build_strategy.authenticate(env)
16
+
17
+ if result.nil?
18
+ unauthorized_response(env)
19
+ else
20
+ env["devise_scim.tenant"] = result unless result == :ok
21
+ @app.call(env)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def scim_path?(path)
28
+ path.start_with?(DeviseScim.configuration.route_prefix)
29
+ end
30
+
31
+ def build_strategy
32
+ if DeviseScim.configuration.auth_method == :oauth
33
+ Auth::OauthStrategy.new
34
+ else
35
+ Auth::TokenStrategy.new
36
+ end
37
+ end
38
+
39
+ def unauthorized_response(env)
40
+ # Prevent Warden from intercepting the 401 and attempting its failure app.
41
+ env["warden"].custom_failure! if env["warden"].respond_to?(:custom_failure!)
42
+ body = {
43
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
44
+ status: "401",
45
+ detail: "Unauthorized"
46
+ }.to_json
47
+ [401, { "Content-Type" => SCIM_CONTENT_TYPE }, [body]]
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Minitest
5
+ module ScimAssertions
6
+ def assert_scim_status(response, expected_status)
7
+ assert_equal expected_status.to_s, response.status.to_s,
8
+ "Expected SCIM status #{expected_status}, got #{response.status}\n#{response.body}"
9
+ end
10
+
11
+ def assert_scim_content_type(response)
12
+ assert_includes response.content_type, "application/scim+json",
13
+ "Expected Content-Type application/scim+json"
14
+ end
15
+
16
+ def assert_scim_schema(response, expected_schema)
17
+ body = JSON.parse(response.body)
18
+ assert_includes body["schemas"], expected_schema,
19
+ "Expected schema #{expected_schema.inspect} in #{body["schemas"].inspect}"
20
+ end
21
+
22
+ def assert_scim_list_response(response)
23
+ body = JSON.parse(response.body)
24
+ assert_scim_schema(response, DeviseScim::Scim::LIST_RESPONSE_SCHEMA)
25
+ assert body.key?("totalResults"), "Expected totalResults key in ListResponse"
26
+ assert body.key?("Resources"), "Expected Resources key in ListResponse"
27
+ end
28
+
29
+ def assert_scim_error(response, expected_status: nil)
30
+ body = JSON.parse(response.body)
31
+ assert_includes body["schemas"], DeviseScim::Scim::ERROR_SCHEMA,
32
+ "Expected SCIM error schema in response"
33
+ assert_equal expected_status.to_s, body["status"] if expected_status
34
+ end
35
+
36
+ def scim_json(response)
37
+ JSON.parse(response.body)
38
+ end
39
+
40
+ def scim_auth_headers(token)
41
+ { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
42
+ end
43
+
44
+ def scim_user_payload(user_name:, **attrs)
45
+ { "schemas" => [DeviseScim::Scim::USER_SCHEMA], "userName" => user_name }
46
+ .merge(attrs.transform_keys(&:to_s))
47
+ end
48
+
49
+ def scim_patch_payload(*operations)
50
+ {
51
+ "schemas" => ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
52
+ "Operations" => operations
53
+ }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class ScimTenant < ActiveRecord::Base
5
+ self.table_name = "scim_tenants"
6
+
7
+ include DeviseScim::Concerns::ScimTenant
8
+
9
+ has_many :scim_tenant_users, class_name: "DeviseScim::ScimTenantUser",
10
+ foreign_key: :scim_tenant_id, dependent: :destroy
11
+ belongs_to :doorkeeper_application,
12
+ class_name: "Doorkeeper::Application", optional: true
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ class ScimTenantUser < ActiveRecord::Base
5
+ self.table_name = "scim_tenant_users"
6
+
7
+ # Default associations use built-in DeviseScim::ScimTenant and the host app's
8
+ # User model. When config.tenant_model is customized, host apps override these
9
+ # associations (and the FK) in their own initializer.
10
+ belongs_to :scim_tenant,
11
+ class_name: "DeviseScim::ScimTenant",
12
+ foreign_key: :scim_tenant_id
13
+ belongs_to :user, foreign_key: :user_id
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module Routing
5
+ def scim_for(_resource, at: DeviseScim.configuration.route_prefix)
6
+ config = DeviseScim.configuration
7
+ scope at, format: false do
8
+ draw_user_routes
9
+ draw_group_routes if config.enable_groups
10
+ post "oauth/token", to: "doorkeeper/tokens#create" if config.auth_method == :oauth
11
+ draw_discovery_routes
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def draw_user_routes
18
+ get "Users", to: "devise_scim/users#index"
19
+ post "Users", to: "devise_scim/users#create"
20
+ get "Users/:id", to: "devise_scim/users#show"
21
+ put "Users/:id", to: "devise_scim/users#replace"
22
+ patch "Users/:id", to: "devise_scim/users#update"
23
+ delete "Users/:id", to: "devise_scim/users#destroy"
24
+ end
25
+
26
+ def draw_group_routes
27
+ get "Groups", to: "devise_scim/groups#index"
28
+ post "Groups", to: "devise_scim/groups#create"
29
+ get "Groups/:id", to: "devise_scim/groups#show"
30
+ put "Groups/:id", to: "devise_scim/groups#replace"
31
+ patch "Groups/:id", to: "devise_scim/groups#update"
32
+ delete "Groups/:id", to: "devise_scim/groups#destroy"
33
+ end
34
+
35
+ def draw_discovery_routes
36
+ get "ServiceProviderConfig", to: "devise_scim/service_provider#show"
37
+ get "Schemas", to: "devise_scim/schemas#index"
38
+ get "ResourceTypes", to: "devise_scim/resource_types#index"
39
+ end
40
+ end
41
+ end
42
+
43
+ ActionDispatch::Routing::Mapper.include DeviseScim::Routing
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(FactoryBot)
4
+
5
+ FactoryBot.define do
6
+ factory :scim_tenant, class: "DeviseScim::ScimTenant" do
7
+ sequence(:name) { |n| "Test Tenant #{n}" }
8
+ auth_method { "token" }
9
+ active { true }
10
+ end
11
+
12
+ factory :scim_tenant_user, class: "DeviseScim::ScimTenantUser" do
13
+ association :scim_tenant
14
+ active { true }
15
+ provisioned_at { Time.current }
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeviseScim
4
+ module RSpec
5
+ module ScimHelpers
6
+ def scim_json(body)
7
+ JSON.parse(body)
8
+ end
9
+
10
+ def scim_prefix
11
+ DeviseScim.configuration.route_prefix
12
+ end
13
+
14
+ def scim_auth_headers(token)
15
+ { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
16
+ end
17
+
18
+ def scim_user_payload(user_name:, **attrs)
19
+ base = { "schemas" => [DeviseScim::Scim::USER_SCHEMA], "userName" => user_name }
20
+ base.merge(attrs.transform_keys(&:to_s))
21
+ end
22
+
23
+ def scim_patch_payload(*operations)
24
+ {
25
+ "schemas" => ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
26
+ "Operations" => operations
27
+ }
28
+ end
29
+
30
+ def scim_replace_op(path, value)
31
+ { "op" => "replace", "path" => path, "value" => value }
32
+ end
33
+
34
+ def scim_add_op(path, value)
35
+ { "op" => "add", "path" => path, "value" => value }
36
+ end
37
+
38
+ def scim_remove_op(path)
39
+ { "op" => "remove", "path" => path }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ RSpec.shared_examples "SCIM discovery endpoints" do |_options = {}|
5
+ include DeviseScim::RSpec::ScimHelpers
6
+
7
+ let(:_scim_test_token) { "scim-test-token-#{SecureRandom.hex(8)}" }
8
+ let(:_scim_headers) { scim_auth_headers(_scim_test_token) }
9
+
10
+ before do
11
+ DeviseScim.configure do |c|
12
+ c.tenancy = :single
13
+ c.auth_method = :token
14
+ c.token = _scim_test_token
15
+ end
16
+ end
17
+
18
+ after { DeviseScim.reset_configuration! }
19
+
20
+ # ── ServiceProviderConfig ────────────────────────────────────────────────────
21
+
22
+ describe "GET /ServiceProviderConfig" do
23
+ it "returns 200 with the correct schema" do
24
+ get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
25
+ expect(response).to have_http_status(:ok)
26
+ body = scim_json(response.body)
27
+ expect(body["schemas"]).to include("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig")
28
+ end
29
+
30
+ it "advertises patch support" do
31
+ get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
32
+ body = scim_json(response.body)
33
+ expect(body.dig("patch", "supported")).to be(true)
34
+ end
35
+
36
+ it "advertises filter support" do
37
+ get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
38
+ body = scim_json(response.body)
39
+ expect(body.dig("filter", "supported")).to be(true)
40
+ end
41
+
42
+ it "sets Content-Type to application/scim+json" do
43
+ get "#{scim_prefix}/ServiceProviderConfig", headers: _scim_headers
44
+ expect(response.content_type).to include("application/scim+json")
45
+ end
46
+ end
47
+
48
+ # ── Schemas ──────────────────────────────────────────────────────────────────
49
+
50
+ describe "GET /Schemas" do
51
+ it "returns 200 with the User schema" do
52
+ get "#{scim_prefix}/Schemas", headers: _scim_headers
53
+ expect(response).to have_http_status(:ok)
54
+ body = scim_json(response.body)
55
+ ids = body["Resources"].map { |r| r["id"] }
56
+ expect(ids).to include(DeviseScim::Scim::USER_SCHEMA)
57
+ end
58
+
59
+ context "with groups enabled" do
60
+ before { DeviseScim.configure { |c| c.enable_groups = true } }
61
+
62
+ it "includes the Group schema" do
63
+ get "#{scim_prefix}/Schemas", headers: _scim_headers
64
+ body = scim_json(response.body)
65
+ ids = body["Resources"].map { |r| r["id"] }
66
+ expect(ids).to include(DeviseScim::Scim::GROUP_SCHEMA)
67
+ end
68
+ end
69
+ end
70
+
71
+ # ── ResourceTypes ────────────────────────────────────────────────────────────
72
+
73
+ describe "GET /ResourceTypes" do
74
+ it "returns 200 with the User resource type" do
75
+ get "#{scim_prefix}/ResourceTypes", headers: _scim_headers
76
+ expect(response).to have_http_status(:ok)
77
+ body = scim_json(response.body)
78
+ names = body["Resources"].map { |r| r["name"] }
79
+ expect(names).to include("User")
80
+ end
81
+
82
+ context "with groups enabled" do
83
+ before { DeviseScim.configure { |c| c.enable_groups = true } }
84
+
85
+ it "includes the Group resource type" do
86
+ get "#{scim_prefix}/ResourceTypes", headers: _scim_headers
87
+ body = scim_json(response.body)
88
+ names = body["Resources"].map { |r| r["name"] }
89
+ expect(names).to include("Group")
90
+ end
91
+ end
92
+ end
93
+ end
94
+ # rubocop:enable Metrics/BlockLength