Commit 48d05c1

mo khan <mo@mokhan.ca>
2026-08-04 20:44:55
fix: validate unassigned values correctly
cli
1 parent 2d72d47
lib/scim/kit/cli/unassigned_values.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      # RFC 7643 2.5 makes the null value equivalent to an unassigned
+      # attribute, and omitting an unassigned attribute is only a MAY.
+      # Dropping nulls before validation is what makes the two spellings
+      # equivalent: "required" then reports an unassigned required attribute,
+      # and every other attribute is simply absent.
+      #
+      # 2.5 says the same of an empty array, but that is left alone on
+      # purpose: an empty collection is how a conformant server reports zero
+      # results, and "Resources" is only REQUIRED when "totalResults" is
+      # non-zero (RFC 7644 3.4.2).
+      module UnassignedValues
+        class << self
+          def strip(data)
+            case data
+            when Hash then assigned(data)
+            when Array then data.map { |value| strip(value) }
+            else data
+            end
+          end
+
+          private
+
+          # A null inside a multi-valued attribute is a value the server sent,
+          # not an unassigned attribute, so it stays and is reported.
+          def assigned(data)
+            data.each_with_object({}) do |(name, value), result|
+              result[name] = strip(value) unless value.nil?
+            end
+          end
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/validator.rb
@@ -6,8 +6,9 @@ module Scim
       module Validator
         class << self
           def errors_for(schema, data)
+            document = UnassignedValues.strip(normalize(data))
             JSONSchemer.schema(schema)
-              .validate(CanonicalKeys.apply(schema, normalize(data)))
+              .validate(CanonicalKeys.apply(schema, document))
               .map { |error| JSONSchemer::Errors.pretty(error) }
           end
 
lib/scim/kit/cli.rb
@@ -15,6 +15,7 @@ require 'scim/kit/cli/schema_registry'
 require 'scim/kit/cli/scim_schema_converter'
 require 'scim/kit/cli/settings'
 require 'scim/kit/cli/sparse_schema'
+require 'scim/kit/cli/unassigned_values'
 require 'scim/kit/cli/validator'
 
 module Scim
spec/scim/kit/cli/unassigned_values_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::UnassignedValues do
+  describe '.strip' do
+    it { expect(described_class.strip('userName' => 'mo', 'externalId' => nil)).to eql('userName' => 'mo') }
+    it { expect(described_class.strip('Resources' => [])).to eql('Resources' => []) }
+    it { expect(described_class.strip('name' => { 'givenName' => nil })).to eql('name' => {}) }
+    it { expect(described_class.strip('emails' => [{ 'type' => nil }])).to eql('emails' => [{}]) }
+    it { expect(described_class.strip('emails' => [nil])).to eql('emails' => [nil]) }
+    it { expect(described_class.strip('active' => false)).to eql('active' => false) }
+    it { expect(described_class.strip('userName' => '')).to eql('userName' => '') }
+
+    it 'leaves the original document untouched' do
+      document = { 'externalId' => nil }
+
+      described_class.strip(document)
+
+      expect(document).to eql('externalId' => nil)
+    end
+  end
+end
spec/scim/kit/cli/validator_spec.rb
@@ -29,6 +29,64 @@ RSpec.describe Scim::Kit::Cli::Validator do
       expect(errors).to eql(["property '/userName' is not of type: string"])
     end
 
+    context 'with an optional attribute' do
+      let(:schema) do
+        {
+          'type' => 'object',
+          'properties' => {
+            'userName' => { 'type' => 'string' },
+            'externalId' => { 'type' => 'string' }
+          },
+          'required' => ['userName']
+        }
+      end
+
+      it 'accepts null, which RFC 7643 2.5 makes equivalent to unassigned' do
+        errors = described_class.errors_for(
+          schema, { userName: 'bjensen', externalId: nil }
+        )
+
+        expect(errors).to eql([])
+      end
+
+      it 'still reports a wrong type' do
+        errors = described_class.errors_for(
+          schema, { userName: 'bjensen', externalId: 1 }
+        )
+
+        expect(errors).to eql(["property '/externalId' is not of type: string"])
+      end
+    end
+
+    it 'reports null for a required attribute as unassigned' do
+      errors = described_class.errors_for(schema, { userName: nil })
+
+      expect(errors).to eql(['root is missing required keys: userName'])
+    end
+
+    context 'with an optional attribute behind a $ref' do
+      let(:schema) do
+        {
+          'type' => 'object',
+          'properties' => { 'meta' => { '$ref' => '#/$defs/meta' } },
+          '$defs' => {
+            'meta' => {
+              'type' => 'object',
+              'properties' => { 'version' => { 'type' => 'string' } }
+            }
+          }
+        }
+      end
+
+      it 'reports only the error the server actually made' do
+        errors = described_class.errors_for(schema, { meta: { version: 1 } })
+
+        expect(errors).to eql(
+          ["property '/meta/version' is not of type: string"]
+        )
+      end
+    end
+
     it 'accepts a declared attribute the server spelled differently' do
       errors = described_class.errors_for(schema, { USERNAME: 'bjensen' })