permittable 0.13.1 → 0.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.
@@ -453,7 +453,11 @@ module Permittable
453
453
  # proc cannot rewrite the spec's own literal (the same reason
454
454
  # validate_authored_value! copies before normalizing).
455
455
  own = expected.is_a?(String) ? expected.dup : expected
456
- status, value = Coercion.cast(field[:type], Coercion.apply_normalize(field[:normalize], own))
456
+ normalized = Coercion.apply_normalize(field[:normalize], own)
457
+ # Absent on a nullable field is the nil a contract stores for it.
458
+ return nil if field[:nullable] && Coercion.absent_value?(normalized)
459
+
460
+ status, value = Coercion.cast(field[:type], normalized)
457
461
  status == :ok ? value : expected
458
462
  when :array
459
463
  return expected unless expected.is_a?(Array)
@@ -465,6 +469,8 @@ module Permittable
465
469
  end
466
470
 
467
471
  def cast_json_default(field, expected)
472
+ # Absent on a nullable field is the nil a contract stores for it.
473
+ return nil if field[:nullable] && Coercion.absent_value?(expected)
468
474
  return expected unless expected.is_a?(Hash)
469
475
 
470
476
  status, value = AuthoredValues.read_json(field, expected)
@@ -630,7 +636,7 @@ module Permittable
630
636
  # `not_to ...returning(x)` would pass both on a rejection and on an
631
637
  # acceptance returning anything else.
632
638
  def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
633
- raise ArgumentError, negated_qualifier_message if @returning || !@expected_violations.empty?
639
+ raise ArgumentError, negated_qualifier_message(subject) if @returning || !@expected_violations.empty?
634
640
 
635
641
  rule = locate(subject)
636
642
  return false unless rule
@@ -645,9 +651,12 @@ module Permittable
645
651
  if @expect_accepted
646
652
  return "expected #{subject_name} to #{description}, but it rejected them: #{summary(@violations)}" unless @violations.empty?
647
653
 
648
- "expected #{subject_name} to #{description}, but it accepted them but returned #{@result.to_h.inspect}"
654
+ "expected #{subject_name} to #{description}, but it accepted them but returned " \
655
+ "#{printable(@result.to_h)}#{differs_note}"
649
656
  else
650
- return "expected #{subject_name} to #{description}, but it accepted them, returning #{@result.to_h.inspect}" if @violations.empty?
657
+ if @violations.empty?
658
+ return "expected #{subject_name} to #{description}, but it accepted them, returning #{printable(@result.to_h)}"
659
+ end
651
660
 
652
661
  "expected #{subject_name} to #{description}, but the violations were: #{summary(@violations)}"
653
662
  end
@@ -664,7 +673,7 @@ module Permittable
664
673
  label = @expect_accepted ? "accept those params" : "reject those params"
665
674
  label += action_label
666
675
  label += " with #{summary(@expected_violations)}" unless @expected_violations.empty?
667
- label += " returning #{@returning.inspect}" if @returning
676
+ label += " returning #{printable(@returning)}" if @returning
668
677
  label
669
678
  end
670
679
 
@@ -678,15 +687,27 @@ module Permittable
678
687
  # first so a matcher object reused across examples (or re-chained
679
688
  # with another for_action) never reports a previous run's problem.
680
689
  def locate(subject)
681
- @problem = @violations = @result = nil
690
+ @problem = @violations = @result = @root = nil
682
691
  @subject = resolve_subject(subject)
683
- resolve_rule
684
- end
685
-
686
- def negated_qualifier_message
692
+ rule = resolve_rule
693
+ @root = rule[:root] if rule
694
+ rule
695
+ end
696
+
697
+ def negated_qualifier_message(subject = nil)
698
+ # Raised before the rule is located, so the root a scoped filter
699
+ # needs is looked up here, best effort: a subject or an ambiguity
700
+ # that locate would refuse (a BasicObject answers no respond_to?) is not
701
+ # this message's to report, and leaves the filter unscoped (the
702
+ # shallow one) rather than masking the qualifier error.
703
+ begin
704
+ locate(subject) if subject
705
+ rescue StandardError
706
+ @root = nil
707
+ end
687
708
  call = @expect_accepted ? "accept_params" : "reject_params"
688
709
  qualifiers = []
689
- qualifiers << "returning #{@returning.inspect}" if @returning
710
+ qualifiers << "returning #{printable(@returning)}" if @returning
690
711
  qualifiers << "with_violation #{summary(@expected_violations)}" unless @expected_violations.empty?
691
712
  "#{LABEL}: `not_to #{call}(...)` cannot take qualifiers (here: #{qualifiers.join(', ')}) — " \
692
713
  "negating one is ambiguous, since it would pass both when the params are " \
@@ -749,7 +770,7 @@ module Permittable
749
770
  mirror_csrf_key(host)
750
771
  host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
751
772
  instance = host.new
752
- instance.params = @params
773
+ instance.params = request_params
753
774
  violations = instance.permittable_violations(action)
754
775
  result = violations.empty? ? instance.permitted_params(action) : nil
755
776
  [violations, result]
@@ -772,6 +793,56 @@ module Permittable
772
793
  host.define_method(:permittable_action_name) { action }
773
794
  end
774
795
 
796
+ # What a controller reads as `params`: the payload after the
797
+ # normalization every request goes through, ActionDispatch's Utils.
798
+ # Under the default perform_deep_munge it removes nil
799
+ # from every array, so a JSON `{"tags": ["a", null]}` reaches the
800
+ # contract as ["a"], and `length:` never counts the null. Handing the
801
+ # raw payload over made reject_params(tags: ["a", nil]) pass for a
802
+ # request that is a 200. An app that turned perform_deep_munge off
803
+ # still sees the null, because the setting is read, not assumed.
804
+ # A copy goes in: the normalizer rewrites arrays in place, and the
805
+ # payload belongs to the spec. The copy stringifies Symbol keys, which
806
+ # the normalizer's HashWithIndifferentAccess does anyway but only after
807
+ # it has read a Symbol :tempfile key as an upload, a key a request
808
+ # (String keys only) never has, and Symbol values (see copy_payload).
809
+ # A Parameters payload is unwrapped first so it is normalized like the
810
+ # Hash it wraps. Without actionpack's request code only the normalizer
811
+ # is skipped; the copy (and its Symbol stringifying) still happens.
812
+ def request_params
813
+ payload = copy_payload(@params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params)
814
+ utils = request_utils
815
+ utils ? utils.normalize_encode_params(payload) : payload
816
+ end
817
+
818
+ # Rails main moved the class from ActionDispatch::Request::Utils to
819
+ # ActionDispatch::Http::Utils. nil when actionpack's request code is not
820
+ # loaded (a non-Rails stand-in controller, or a bare ActionDispatch module
821
+ # that something else namespaced): there is nothing to normalize with, and
822
+ # nothing was renamed. Once ActionDispatch::Request exists, a rename this
823
+ # does not know raises instead of silently skipping the normalization,
824
+ # which would bring the false greens back.
825
+ def request_utils
826
+ if defined?(ActionDispatch::Http::Utils) then ActionDispatch::Http::Utils
827
+ elsif defined?(ActionDispatch::Request::Utils) then ActionDispatch::Request::Utils
828
+ elsif defined?(ActionDispatch::Request)
829
+ raise "Permittable's matchers cannot find Rails' parameter normalizer: neither " \
830
+ "ActionDispatch::Http::Utils nor ActionDispatch::Request::Utils is defined"
831
+ end
832
+ end
833
+
834
+ # Symbol keys become Strings, and so do Symbol values: a request carries
835
+ # every scalar as a String (or a JSON number, boolean or null), never a
836
+ # Symbol, so `status: :active` is the "active" the contract would read.
837
+ def copy_payload(value)
838
+ case value
839
+ when Hash then value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s : key, copy_payload(item)] }
840
+ when Array then value.map { |item| copy_payload(item) }
841
+ when Symbol then value.to_s
842
+ else value
843
+ end
844
+ end
845
+
775
846
  def run_contract
776
847
  result = @subject.call(@params)
777
848
  [result.violations, result.params]
@@ -824,6 +895,116 @@ module Permittable
824
895
  raise ArgumentError, "#{LABEL}: the subject of accept_params/reject_params must include Permittable (got #{subject.inspect})"
825
896
  end
826
897
 
898
+ # A hash echoed into a failure message, which lands in CI logs, goes out
899
+ # redacted the way a request log is: by the names `sensitive:` fields
900
+ # registered, and by the app's config.filter_parameters when a Rails
901
+ # application is booted. Only the printed copy is filtered; the matching
902
+ # itself reads the real values.
903
+ def printable(hash)
904
+ redacted(hash).inspect
905
+ end
906
+
907
+ # The redacted structure itself, which differs_note walks beside the
908
+ # real one. A rule with a root: has its hash put back under that root
909
+ # for the app's filter, as a request logs it, so a scoped filter
910
+ # ("payment.number", /\Apayment\.code\z/) matches; the registry pass
911
+ # runs on the hash alone, as it does at runtime.
912
+ def redacted(hash)
913
+ inner = redact_sensitive(hash)
914
+ filter = parameter_filter
915
+ return inner unless filter
916
+ return filter.filter(inner) unless @root
917
+
918
+ key = @root.to_s
919
+ filter.filter(key => inner)[key]
920
+ end
921
+
922
+ # The registry's names, by whatever it can answer: #include?, else its
923
+ # #names matched the way the registry matches (case-insensitive
924
+ # substring). One with neither is left to its #to_proc in
925
+ # parameter_filter. A masked value is replaced whole, a Hash included.
926
+ # An ActionController::Parameters is unwrapped, so its nested values
927
+ # are reached too.
928
+ def redact_sensitive(value)
929
+ value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
930
+ return value.map { |item| redact_sensitive(item) } if value.is_a?(Array)
931
+ return value unless value.is_a?(Hash)
932
+
933
+ registry = Permittable.filter_parameter_registry
934
+ value.to_h do |key, item|
935
+ [key, sensitive_name?(registry, key) ? Permittable::FilterParameterRegistry::FILTERED : redact_sensitive(item)]
936
+ end
937
+ end
938
+
939
+ def sensitive_name?(registry, key)
940
+ if registry.respond_to?(:include?)
941
+ registry.include?(key)
942
+ elsif registry.respond_to?(:names)
943
+ name = key.to_s.downcase
944
+ registry.names.any? { |sensitive| name.include?(sensitive.to_s.downcase) }
945
+ else
946
+ false
947
+ end
948
+ end
949
+
950
+ # Rails' ParameterFilter over the app's config.filter_parameters, plus
951
+ # the registry's own #to_proc when it can answer neither #include? nor
952
+ # #names (a proc masks only Strings, but it is all such a registry
953
+ # offers). ActiveSupport::ParameterFilter is not loaded by
954
+ # `require "active_support"` before 7.0, and a Rails 6.1 app without
955
+ # ActionDispatch::Request has not loaded it either; it has been
956
+ # requirable since 6.0.
957
+ def parameter_filter
958
+ filters = []
959
+ app = defined?(Rails) && Rails.respond_to?(:application) && Rails.application
960
+ filters.concat(Array(app.config.filter_parameters)) if app
961
+ registry = Permittable.filter_parameter_registry
962
+ filters << registry.to_proc unless registry.respond_to?(:include?) || registry.respond_to?(:names)
963
+ return if filters.empty?
964
+
965
+ begin
966
+ require "active_support/parameter_filter"
967
+ rescue LoadError
968
+ return
969
+ end
970
+ ActiveSupport::ParameterFilter.new(filters)
971
+ end
972
+
973
+ # The registry matches by substring, like Rails (`pin` also redacts
974
+ # `shipping`), so an expected and an actual hash that differ only under
975
+ # redacted names print the same. Say where they differ, by name only.
976
+ def differs_note
977
+ return "" unless @returning
978
+
979
+ expected = ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h
980
+ actual = @result.to_h
981
+ return "" if expected == actual || printable(expected) != printable(actual)
982
+
983
+ # A filter on the root itself masks the whole hash, leaving the root as the one path.
984
+ paths = differing_paths(expected, actual, redacted(expected), redacted(actual)).map { |path| path || @root.to_s }
985
+ paths.empty? ? "" : "; differs at: #{paths.join(', ')}"
986
+ end
987
+
988
+ # Walks the real hashes beside their redacted copies and stops at the
989
+ # redaction boundary: a node the copy shows as masked is named by its
990
+ # path alone when the real values differ, and not entered, since a key
991
+ # or an index below it is as secret as its value.
992
+ def differing_paths(expected, actual, expected_masked, actual_masked, prefix = nil)
993
+ filtered = Permittable::FilterParameterRegistry::FILTERED
994
+ masked = expected_masked == filtered || actual_masked == filtered
995
+ if !masked && expected.is_a?(Hash) && actual.is_a?(Hash)
996
+ (expected.keys | actual.keys).flat_map do |key|
997
+ differing_paths(expected[key], actual[key], expected_masked[key], actual_masked[key], [prefix, key].compact.join("."))
998
+ end
999
+ elsif !masked && expected.is_a?(Array) && actual.is_a?(Array) && expected.length == actual.length
1000
+ expected.each_index.flat_map do |i|
1001
+ differing_paths(expected[i], actual[i], expected_masked[i], actual_masked[i], "#{prefix}[#{i}]")
1002
+ end
1003
+ else
1004
+ expected == actual ? [] : [prefix]
1005
+ end
1006
+ end
1007
+
827
1008
  def summary(violations)
828
1009
  violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
829
1010
  end
@@ -13,9 +13,10 @@ namespace :permittable do
13
13
 
14
14
  # Every controller, not just the ones including Permittable — a controller
15
15
  # that never included it is unguarded, which is exactly the finding. Bare
16
- # ActionController::Metal ones included; see Audit.rails_controllers.
17
- controllers = Permittable::Audit.rails_controllers
16
+ # ActionController::Metal ones included, and autoloaded framework ones
17
+ # (Rails::HealthController) the routes name; see Audit.rails_controllers.
18
18
  routes = Permittable::OpenAPI.rails_routes(Rails.application)
19
+ controllers = Permittable::Audit.rails_controllers(routes)
19
20
 
20
21
  entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
21
22
  stale = Permittable::Audit.stale(controllers: controllers, routes: routes)
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.13.1".freeze
2
+ VERSION = "0.15.0".freeze
3
3
  end