Commit 18581b9

mo khan <mo@mokhan.ca>
2026-07-28 22:55:32
fix: schema validation to not accept bare array at root
cli
1 parent a429c56
lib/scim/kit/cli/schemas/resource_types.schema.json
@@ -1,42 +1,53 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "type": "array",
-  "items": {
-    "type": "object",
-    "properties": {
-      "schemas": { "type": "array", "items": { "type": "string" } },
-      "id": { "type": "string" },
-      "name": { "type": "string" },
-      "description": { "type": "string" },
-      "endpoint": { "type": "string" },
-      "schema": { "type": "string" },
-      "schemaExtensions": {
-        "type": "array",
-        "items": {
-          "type": "object",
-          "properties": {
-            "schema": { "type": "string" },
-            "required": { "type": "boolean" }
-          },
-          "required": ["schema", "required"],
-          "additionalProperties": false
-        }
-      },
-      "externalId": { "type": "string" },
-      "meta": {
+  "type": "object",
+  "properties": {
+    "schemas": { "type": "array", "items": { "type": "string" } },
+    "totalResults": { "type": "integer" },
+    "itemsPerPage": { "type": "integer" },
+    "startIndex": { "type": "integer" },
+    "Resources": {
+      "type": "array",
+      "items": {
         "type": "object",
         "properties": {
-          "resourceType": { "type": "string" },
-          "created": { "type": "string", "format": "date-time" },
-          "lastModified": { "type": "string", "format": "date-time" },
-          "location": { "type": "string" },
-          "version": { "type": "string" }
+          "schemas": { "type": "array", "items": { "type": "string" } },
+          "id": { "type": "string" },
+          "name": { "type": "string" },
+          "description": { "type": "string" },
+          "endpoint": { "type": "string" },
+          "schema": { "type": "string" },
+          "schemaExtensions": {
+            "type": "array",
+            "items": {
+              "type": "object",
+              "properties": {
+                "schema": { "type": "string" },
+                "required": { "type": "boolean" }
+              },
+              "required": ["schema", "required"],
+              "additionalProperties": false
+            }
+          },
+          "externalId": { "type": "string" },
+          "meta": {
+            "type": "object",
+            "properties": {
+              "resourceType": { "type": "string" },
+              "created": { "type": "string", "format": "date-time" },
+              "lastModified": { "type": "string", "format": "date-time" },
+              "location": { "type": "string" },
+              "version": { "type": "string" }
+            },
+            "required": [],
+            "additionalProperties": false
+          }
         },
-        "required": [],
+        "required": ["schemas", "name", "endpoint", "schema"],
         "additionalProperties": false
       }
-    },
-    "required": ["schemas", "name", "endpoint", "schema"],
-    "additionalProperties": false
-  }
+    }
+  },
+  "required": ["schemas", "totalResults", "Resources"],
+  "additionalProperties": false
 }
lib/scim/kit/cli/schemas/schemas.schema.json
@@ -1,23 +1,34 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "type": "array",
-  "items": {
-    "type": "object",
-    "properties": {
-      "schemas": { "type": "array", "items": { "type": "string" } },
-      "id": { "type": "string" },
-      "name": { "type": "string" },
-      "description": { "type": "string" },
-      "attributes": {
-        "type": "array",
-        "items": { "$ref": "#/$defs/attribute" }
-      },
-      "externalId": { "type": "string" },
-      "meta": { "$ref": "#/$defs/meta" }
-    },
-    "required": ["schemas", "id", "attributes"],
-    "additionalProperties": false
+  "type": "object",
+  "properties": {
+    "schemas": { "type": "array", "items": { "type": "string" } },
+    "totalResults": { "type": "integer" },
+    "itemsPerPage": { "type": "integer" },
+    "startIndex": { "type": "integer" },
+    "Resources": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "properties": {
+          "schemas": { "type": "array", "items": { "type": "string" } },
+          "id": { "type": "string" },
+          "name": { "type": "string" },
+          "description": { "type": "string" },
+          "attributes": {
+            "type": "array",
+            "items": { "$ref": "#/$defs/attribute" }
+          },
+          "externalId": { "type": "string" },
+          "meta": { "$ref": "#/$defs/meta" }
+        },
+        "required": ["schemas", "id", "attributes"],
+        "additionalProperties": false
+      }
+    }
   },
+  "required": ["schemas", "totalResults", "Resources"],
+  "additionalProperties": false,
   "$defs": {
     "attribute": {
       "type": "object",
lib/scim/kit/cli/schemas/service_provider_config.schema.json
@@ -58,7 +58,8 @@
           "name": { "type": "string" },
           "description": { "type": "string" },
           "specUri": { "type": "string" },
-          "documentationUri": { "type": "string" }
+          "documentationUri": { "type": "string" },
+          "primary": { "type": "boolean" }
         },
         "required": ["type", "name", "description"],
         "additionalProperties": false
lib/scim/kit/cli/resource_schema_resolver.rb
@@ -85,9 +85,10 @@ module Scim
         def fetch_schemas(urns)
           uri = Cli.join_uri(base_url, 'Schemas')
           result = http.fetch(uri, headers: headers)
-          return {} unless result.ok? && result.body.is_a?(Array)
+          schemas = result.ok? ? Cli.collection(result.body) : nil
+          return {} if schemas.nil?
 
-          result.body.each_with_object({}) do |schema, hash|
+          schemas.each_with_object({}) do |schema, hash|
             hash[schema[:id]] = schema if urns.include?(schema[:id])
           end
         end
lib/scim/kit/cli/resource_type_resolver.rb
@@ -32,11 +32,13 @@ module Scim
           uri = Cli.join_uri(base_url, 'ResourceTypes')
           result = http.fetch(uri, headers: headers)
           raise RequestFailed, result unless result.ok?
-          unless result.body.is_a?(Array)
+
+          types = Cli.collection(result.body)
+          unless types
             raise InvalidResponse, 'expected /ResourceTypes to return a list'
           end
 
-          result.body
+          types
         end
       end
     end
lib/scim/kit/cli.rb
@@ -42,6 +42,17 @@ module Scim
       def self.join_uri(base_url, path)
         URI.join("#{base_url.to_s.sub(%r{/+\z}, '')}/", path)
       end
+
+      # SCIM collection endpoints (/Schemas, /ResourceTypes) return a
+      # ListResponse per RFC 7644 section 4, but some servers return a
+      # bare array. Return the underlying array for either shape, or nil.
+      def self.collection(body)
+        return body if body.is_a?(Array)
+        return unless body.is_a?(Hash)
+
+        resources = body[:Resources]
+        resources if resources.is_a?(Array)
+      end
     end
   end
 end
spec/scim/kit/cli/app_spec.rb
@@ -127,24 +127,32 @@ RSpec.describe Scim::Kit::Cli::App do
         }
       end
       let(:schemas) do
-        [
-          {
-            id: 'urn:ietf:params:scim:schemas:core:2.0:User',
-            schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
-            attributes: [
-              { name: 'userName', type: 'string', required: true }
-            ]
-          }
-        ]
+        {
+          schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+          totalResults: 1,
+          Resources: [
+            {
+              id: 'urn:ietf:params:scim:schemas:core:2.0:User',
+              schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
+              attributes: [
+                { name: 'userName', type: 'string', required: true }
+              ]
+            }
+          ]
+        }
       end
       let(:resource_types) do
-        [
-          {
-            schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
-            name: 'User', endpoint: '/Users',
-            schema: 'urn:ietf:params:scim:schemas:core:2.0:User'
-          }
-        ]
+        {
+          schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+          totalResults: 1,
+          Resources: [
+            {
+              schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
+              name: 'User', endpoint: '/Users',
+              schema: 'urn:ietf:params:scim:schemas:core:2.0:User'
+            }
+          ]
+        }
       end
 
       before do
@@ -165,6 +173,34 @@ RSpec.describe Scim::Kit::Cli::App do
       end
     end
 
+    context 'when --validate is set and a collection is a bare array' do
+      let(:schemas) do
+        [
+          {
+            id: 'urn:ietf:params:scim:schemas:core:2.0:User',
+            schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
+            attributes: [{ name: 'userName', type: 'string' }]
+          }
+        ]
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/ServiceProviderConfig").to_return(
+          status: 200, body: service_provider_configuration.to_json
+        )
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: schemas.to_json)
+      end
+
+      it 'flags the bare array as non-compliant with ListResponse format' do
+        allow($stdout).to receive(:print)
+        instance = app('validate' => true)
+
+        expect { exit_status { instance.discover } }
+          .to output(/root is not of type: object/).to_stderr
+      end
+    end
+
     context 'when --validate is set and a document is invalid' do
       let(:service_provider_configuration) { { patch: { supported: true } } }
       let(:schemas) { [{ id: 'User', name: 'User' }] }
spec/scim/kit/cli/resource_schema_resolver_spec.rb
@@ -130,5 +130,28 @@ RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
         )
       end
     end
+
+    context 'when /Schemas returns a ListResponse envelope' do
+      let(:resource_type) do
+        { id: 'User', name: 'User', endpoint: '/Users', schema: core_urn }
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/Schemas").to_return(
+          status: 200,
+          body: {
+            schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+            totalResults: 1,
+            Resources: [core_schema]
+          }.to_json
+        )
+      end
+
+      it 'composes the schema from the Resources array' do
+        schema = subject.schema_for(resource_type)
+
+        expect(schema['properties']).to include('userName')
+      end
+    end
   end
 end
spec/scim/kit/cli/resource_type_resolver_spec.rb
@@ -44,6 +44,23 @@ RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
         expect(a_request(:get, "#{base_url}/ResourceTypes").with(headers: headers)).to have_been_made
       end
     end
+
+    context 'when /ResourceTypes returns a ListResponse envelope' do
+      before do
+        stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+          status: 200,
+          body: {
+            schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+            totalResults: 1,
+            Resources: [{ id: 'User', name: 'User', endpoint: '/Users' }]
+          }.to_json
+        )
+      end
+
+      it 'resolves the entry from the Resources array' do
+        expect(subject.resource_type_for('User')).to include(endpoint: '/Users')
+      end
+    end
   end
 
   context 'when the request fails' do
@@ -54,11 +71,11 @@ RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
     end
   end
 
-  context 'when the response body is not a list' do
+  context 'when the response body is neither a list nor a ListResponse' do
     before do
       stub_request(:get, "#{base_url}/ResourceTypes").to_return(
         status: 200,
-        body: { Resources: [] }.to_json
+        body: { detail: 'not a collection' }.to_json
       )
     end
 
spec/scim/kit/cli/schema_registry_spec.rb
@@ -8,16 +8,60 @@ RSpec.describe Scim::Kit::Cli::SchemaRegistry do
       expect(schema['required']).to include('patch', 'bulk')
     end
 
-    it 'loads the resource_types schema' do
+    it 'expects resource_types wrapped in a ListResponse envelope' do
       schema = described_class.fetch(:resource_types)
 
-      expect(schema['items']['required']).to include('endpoint')
+      expect(schema['required']).to include('totalResults', 'Resources')
     end
 
-    it 'loads the schemas schema' do
+    it 'validates resource_type items inside the envelope' do
+      schema = described_class.fetch(:resource_types)
+
+      items = schema['properties']['Resources']['items']
+      expect(items['required']).to include('endpoint')
+    end
+
+    it 'expects schemas wrapped in a ListResponse envelope' do
+      schema = described_class.fetch(:schemas)
+
+      expect(schema['required']).to include('totalResults', 'Resources')
+    end
+
+    it 'validates schema items inside the envelope' do
       schema = described_class.fetch(:schemas)
 
-      expect(schema['items']['required']).to include('attributes')
+      items = schema['properties']['Resources']['items']
+      expect(items['required']).to include('attributes')
+    end
+  end
+
+  describe 'service_provider_configuration schema' do
+    let(:config) do
+      {
+        schemas: [
+          'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'
+        ],
+        patch: { supported: true },
+        bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
+        filter: { supported: false, maxResults: 0 },
+        changePassword: { supported: false },
+        sort: { supported: false },
+        etag: { supported: false },
+        authenticationSchemes: [
+          {
+            type: 'oauthbearertoken', name: 'OAuth Bearer Token',
+            description: 'desc', primary: true
+          }
+        ]
+      }
+    end
+
+    it 'accepts a primary flag on authentication schemes' do
+      schema = described_class.fetch(:service_provider_configuration)
+
+      errors = Scim::Kit::Cli::Validator.errors_for(schema, config)
+
+      expect(errors).to be_empty
     end
   end
 
spec/scim/kit/cli_spec.rb
@@ -20,4 +20,24 @@ RSpec.describe Scim::Kit::Cli do
       expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
     end
   end
+
+  describe '.collection' do
+    it 'returns a bare array unchanged' do
+      expect(described_class.collection([{ id: '1' }])).to eql([{ id: '1' }])
+    end
+
+    it 'unwraps the Resources array from a ListResponse envelope' do
+      body = { totalResults: 1, Resources: [{ id: '1' }] }
+
+      expect(described_class.collection(body)).to eql([{ id: '1' }])
+    end
+
+    it 'returns nil for a hash without a Resources array' do
+      expect(described_class.collection(detail: 'boom')).to be_nil
+    end
+
+    it 'returns nil for a scalar body' do
+      expect(described_class.collection('nope')).to be_nil
+    end
+  end
 end