permittable 0.11.1 → 0.12.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.
@@ -151,7 +151,7 @@ module Permittable
151
151
  @subject = resolve_subject(subject)
152
152
  raise ArgumentError, negated_qualifier_message unless @expected.empty?
153
153
 
154
- %i[undeclared too_deep].include?(locate)
154
+ %i[undeclared too_deep misindexed].include?(locate)
155
155
  end
156
156
 
157
157
  def failure_message
@@ -159,6 +159,7 @@ module Permittable
159
159
  case @status
160
160
  when :no_rule then "#{subject} it #{@problem}"
161
161
  when :root_prefixed then "#{subject} #{root_prefix_hint}"
162
+ when :misindexed then "#{subject} #{misindexed_reason}"
162
163
  when :too_deep
163
164
  "#{subject} it is deeper than the opaque :json field #{label_for(@opaque[:path])} allows " \
164
165
  "(max_depth: #{@opaque[:field][:max_depth]})"
@@ -205,18 +206,21 @@ module Permittable
205
206
  # The one lookup both directions share. Sets @status to :no_rule,
206
207
  # :declared, :opaque (the path runs into a :json field, which accepts
207
208
  # any nested key without declaring it), :too_deep (it runs into one
208
- # deeper than its max_depth: allows), :root_prefixed (the path starts
209
- # with the rule's root: and resolves without it), or :undeclared.
209
+ # deeper than its max_depth: allows), :misindexed (it puts an [n] on
210
+ # a field the runtime refuses an Array for, or a second one on an
211
+ # array), :root_prefixed (the path starts with the rule's root: and
212
+ # resolves without it), or :undeclared.
210
213
  # Every per-run ivar is reset first: a matcher object can be reused
211
214
  # on another subject, and must not answer from the previous run.
212
215
  def locate
213
- @rule = @field = @opaque = @problem = @missing_among = nil
216
+ @rule = @field = @opaque = @misindexed = @problem = @missing_among = nil
214
217
  @mismatches = []
215
218
  @rule = resolve_rule(@subject)
216
219
  return @status = :no_rule unless @rule
217
220
 
218
221
  @field = resolve_field(@rule[:fields], @segments)
219
222
  @status = if @field then :declared
223
+ elsif @misindexed then :misindexed
220
224
  elsif @opaque then opaque_within_depth? ? :opaque : :too_deep
221
225
  elsif root_prefixed? then :root_prefixed
222
226
  else :undeclared
@@ -250,6 +254,7 @@ module Permittable
250
254
  # field is opaque: it has no :fields, yet lets any nested key through,
251
255
  # so a path running past one is recorded rather than called missing,
252
256
  # along with how many container levels the rest of the path needs.
257
+ # An [n] on a declared field is held to the field's shape on the way.
253
258
  def resolve_field(fields, segments, depth = 0)
254
259
  name = segments[depth][:name].to_sym
255
260
  field = fields.find { |f| f[:name] == name }
@@ -257,17 +262,44 @@ module Permittable
257
262
  @missing_among = fields
258
263
  return nil
259
264
  end
265
+ if segments[depth][:indexes] > index_levels(field)
266
+ @misindexed = { path: field_path(segments, depth), field: field }
267
+ return nil
268
+ end
260
269
  return field if depth == segments.length - 1
261
270
 
262
271
  if field[:kind] == :json
263
- @opaque = { path: segments.take(depth + 1).map { |seg| seg[:name] }.join("."), field: field,
264
- steps: steps_below(segments, depth) }
272
+ @opaque = { path: field_path(segments, depth), field: field, steps: steps_below(segments, depth) }
265
273
  return nil
266
274
  end
267
275
 
268
276
  resolve_field(field[:fields] || [], segments, depth + 1)
269
277
  end
270
278
 
279
+ def field_path(segments, depth)
280
+ segments.take(depth + 1).map { |seg| seg[:name] }.join(".")
281
+ end
282
+
283
+ # How many [n] the runtime takes on a field's own segment. An array
284
+ # is one level deep (`of:` a scalar type, or with a block of hashes;
285
+ # the DSL has no array of arrays), and every other field refuses an
286
+ # Array as invalid_type, a :json field included: it takes a Hash.
287
+ # Below a :json field the path is opaque, [n] and all.
288
+ def index_levels(field)
289
+ field[:kind] == :array ? 1 : 0
290
+ end
291
+
292
+ def misindexed_reason
293
+ field = @misindexed[:field]
294
+ label = label_for(@misindexed[:path])
295
+ case field[:kind]
296
+ when :array then "#{label} is an array of #{field[:fields] ? 'hashes' : ":#{field[:of]}"}, not an array of arrays"
297
+ when :nested then "#{label} is a nested hash, not an array"
298
+ when :json then "#{label} is an opaque :json field, which takes a Hash, not an array"
299
+ else "#{label} is a :#{field[:type]} field, not an array"
300
+ end
301
+ end
302
+
271
303
  # Each key or [n] step past the :json field descends into one more
272
304
  # container — the same count the runtime's max_depth: check makes,
273
305
  # where the field's own Hash is the first level and arrays count too.
@@ -292,7 +324,7 @@ module Permittable
292
324
  missing_among = @missing_among
293
325
  found = resolve_field(@rule[:fields], @segments.drop(1)) || (@opaque && opaque_within_depth?)
294
326
  @missing_among = missing_among
295
- @opaque = nil
327
+ @opaque = @misindexed = nil
296
328
  found ? true : false
297
329
  end
298
330
 
@@ -307,8 +339,10 @@ module Permittable
307
339
  end
308
340
 
309
341
  # Accepts the runtime's own path form too — violation details say
310
- # "line_items[0].sku" — so a path copied from one resolves: the [n]
311
- # indexes are dropped for the walk and kept only as depth.
342
+ # "line_items[0].sku" — so a path copied from one resolves. The [n]
343
+ # indexes are not walked: each must land on an array field (see
344
+ # index_levels), except below a :json field, where they count only
345
+ # as depth.
312
346
  def path_segments!(path)
313
347
  raws = path.split(".", -1)
314
348
  matches = raws.map { |raw| SEGMENT.match(raw) }
@@ -406,7 +440,11 @@ module Permittable
406
440
  # (see validate_authored_value!/validate_array_authored_value!), so
407
441
  # `expected` is compared bare, not cast — the matcher would otherwise
408
442
  # compare a cast value against an uncast stored one and never match.
443
+ # A :json default is the exception: it is always stored as the walker
444
+ # reads the hash (indifferent, and never cast), so `expected` is read
445
+ # the same way, and `{ theme: "dark" }` matches `{ "theme" => "dark" }`.
409
446
  def cast_default(field, expected)
447
+ return cast_json_default(field, expected) if field[:kind] == :json
410
448
  return expected if field[:transform]
411
449
 
412
450
  case field[:kind]
@@ -426,6 +464,13 @@ module Permittable
426
464
  end
427
465
  end
428
466
 
467
+ def cast_json_default(field, expected)
468
+ return expected unless expected.is_a?(Hash)
469
+
470
+ status, value = AuthoredValues.read_json(field, expected)
471
+ status == :ok ? value : expected
472
+ end
473
+
429
474
  # A contract stores an `in:` list cast by the field's type, so
430
475
  # `within(%i[draft published])` — the declaration repeated as written —
431
476
  # is read the same way before comparing, by the same two functions the
@@ -541,14 +586,28 @@ module Permittable
541
586
  end
542
587
 
543
588
  # accept_params only: assert the cast, defaulted, transformed output.
589
+ # Refused on reject_params rather than ignored: rejected_ok? never
590
+ # reads it, so the chain would pass while its description claims an
591
+ # assertion that was never made.
544
592
  def returning(hash)
593
+ unless @expect_accepted
594
+ raise ArgumentError, "#{LABEL}: `returning` belongs to accept_params, not reject_params — " \
595
+ "a rejected payload returns nothing to compare"
596
+ end
597
+
545
598
  @returning = hash
546
599
  self
547
600
  end
548
601
 
549
602
  # reject_params only: assert a particular violation is among those
550
- # recorded. Repeatable; the code is optional.
603
+ # recorded. Repeatable; the code is optional. Refused on accept_params
604
+ # for the same reason returning is on reject_params.
551
605
  def with_violation(param, code = nil)
606
+ if @expect_accepted
607
+ raise ArgumentError, "#{LABEL}: `with_violation` belongs to reject_params, not accept_params — " \
608
+ "an accepted payload has no violations"
609
+ end
610
+
552
611
  @expected_violations << { param: param.to_s, code: code&.to_s }
553
612
  self
554
613
  end
@@ -556,14 +615,30 @@ module Permittable
556
615
  # -- RSpec protocol -----------------------------------------------------
557
616
 
558
617
  def matches?(subject)
559
- @subject = resolve_subject(subject)
560
- rule = resolve_rule
618
+ rule = locate(subject)
561
619
  return false unless rule
562
620
 
563
621
  @violations, @result = run(rule)
564
622
  @expect_accepted ? accepted_ok? : rejected_ok?
565
623
  end
566
624
 
625
+ # Defined explicitly rather than left to RSpec's `!matches?`: an
626
+ # unresolved rule makes matches? false, which a negation would read
627
+ # as a pass — `not_to accept_params(...).for_action(:craete)` passing
628
+ # on a typo, or on a controller that declares nothing at all. A
629
+ # qualifier is refused for the same reason permit_param refuses one:
630
+ # `not_to ...returning(x)` would pass both on a rejection and on an
631
+ # acceptance returning anything else.
632
+ def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
633
+ raise ArgumentError, negated_qualifier_message if @returning || !@expected_violations.empty?
634
+
635
+ rule = locate(subject)
636
+ return false unless rule
637
+
638
+ @violations, @result = run(rule)
639
+ @expect_accepted ? !accepted_ok? : !rejected_ok?
640
+ end
641
+
567
642
  def failure_message
568
643
  return "expected #{subject_name} to #{description}, but it #{@problem}" if @problem
569
644
 
@@ -580,6 +655,8 @@ module Permittable
580
655
 
581
656
  def failure_message_when_negated
582
657
  verb = @expect_accepted ? "accept" : "reject"
658
+ return "expected #{subject_name} not to #{verb} those params#{action_label}, but it #{@problem}" if @problem
659
+
583
660
  "expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
584
661
  end
585
662
 
@@ -597,6 +674,28 @@ module Permittable
597
674
 
598
675
  private
599
676
 
677
+ # Resolves the subject and its rule, resetting every per-run ivar
678
+ # first so a matcher object reused across examples (or re-chained
679
+ # with another for_action) never reports a previous run's problem.
680
+ def locate(subject)
681
+ @problem = @violations = @result = nil
682
+ @subject = resolve_subject(subject)
683
+ resolve_rule
684
+ end
685
+
686
+ def negated_qualifier_message
687
+ call = @expect_accepted ? "accept_params" : "reject_params"
688
+ qualifiers = []
689
+ qualifiers << "returning #{@returning.inspect}" if @returning
690
+ qualifiers << "with_violation #{summary(@expected_violations)}" unless @expected_violations.empty?
691
+ "#{LABEL}: `not_to #{call}(...)` cannot take qualifiers (here: #{qualifiers.join(', ')}) — " \
692
+ "negating one is ambiguous, since it would pass both when the params are " \
693
+ "#{@expect_accepted ? 'rejected' : 'accepted'} and when they are #{@expect_accepted ? 'accepted' : 'rejected'} " \
694
+ "differently. Assert the outcome with the positive form " \
695
+ "(#{@expect_accepted ? 'reject_params' : 'accept_params'}(...), or #{call}(...) with the qualifier), " \
696
+ "or drop the qualifier to assert only that the params are not #{@expect_accepted ? 'accepted' : 'rejected'}."
697
+ end
698
+
600
699
  def accepted_ok?
601
700
  return false unless @violations.empty?
602
701
 
@@ -622,23 +721,32 @@ module Permittable
622
721
  # of @subject's own class: @subject may be a real controller, and
623
722
  # instantiating one outside the framework's own dispatch (no request,
624
723
  # no response, whatever else its own before_actions assume) is not
625
- # safe in general. That means it does not automatically inherit any
626
- # override @subject's class makes to permittable_check_unknown — which
627
- # is exactly what a standalone Permittable::Contract relies on
628
- # (Contract#initialize forces top_level: false, since standalone input
629
- # has no router to exempt routing keys for). So that one override is
630
- # replicated here for a Contract subject specifically, keeping the
631
- # controller path (which is presumed to use the plain module default)
632
- # byte-for-byte unchanged.
724
+ # safe in general.
725
+ #
726
+ # A standalone Permittable::Contract needs no stand-in: it always
727
+ # enforces, and #call is what its callers run, so the matcher runs
728
+ # exactly that — its input handling, its host's overrides (no
729
+ # router-key exemption from unknown:, its instrumentation label) and
730
+ # anything #call grows later. A stand-in skipped Contract's
731
+ # normalize_input: an Array or a String, which #call refuses with
732
+ # ArgumentError, read as {} here, so accept_params passed on an
733
+ # all-optional contract and reject_params(...).with_violation(:missing)
734
+ # on a required one.
735
+ #
736
+ # A controller's own CSRF parameter name is mirrored onto the host too
737
+ # (mirror_csrf_key), since the unknown: check reads it off the live
738
+ # controller.
633
739
  def run(rule)
740
+ return run_contract if @subject.is_a?(Permittable::Contract)
741
+
634
742
  host = Class.new do
635
743
  include Permittable
636
744
 
637
745
  attr_accessor :params
638
746
  end
639
- mirror_contract_unknown_check(host) if @subject.is_a?(Permittable::Contract)
640
747
  action = @action || rule[:actions].first || "call"
641
748
  label_instrumentation(host, action)
749
+ mirror_csrf_key(host)
642
750
  host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
643
751
  instance = host.new
644
752
  instance.params = @params
@@ -664,14 +772,29 @@ module Permittable
664
772
  host.define_method(:permittable_action_name) { action }
665
773
  end
666
774
 
667
- # Mirrors Permittable::Contract's own host override verbatim (see
668
- # lib/permittable/contract.rb): standalone input has no router and no
669
- # request, so nothing is exempt from unknown: checking, top level or
670
- # not.
671
- def mirror_contract_unknown_check(host)
672
- host.define_method(:permittable_check_unknown) do |fields, hash, path:, unknown:, top_level:, violations:| # rubocop:disable Lint/UnusedBlockArgument
673
- super(fields, hash, path: path, unknown: unknown, top_level: false, violations: violations)
674
- end
775
+ def run_contract
776
+ result = @subject.call(@params)
777
+ [result.violations, result.params]
778
+ end
779
+
780
+ # A controller exempts its configured CSRF parameter from unknown:
781
+ # :error (permittable_configured_csrf_key, which asks the live
782
+ # controller for `request_forgery_protection_token`). The bare host has
783
+ # no such method, so without this the matcher reported the app's
784
+ # renamed token (`self.request_forgery_protection_token = :csrf_token`)
785
+ # as unknown while the same POST is a 200 at runtime. The setting is
786
+ # class-level, so it is read off the subject class and answered from
787
+ # the host. A standalone Contract has no such method and keeps
788
+ # exempting nothing, like Contract#call.
789
+ #
790
+ # Route path parameters cannot be mirrored the same way: the matcher
791
+ # has no route to read them from (see the README).
792
+ def mirror_csrf_key(host)
793
+ return unless @subject.respond_to?(:request_forgery_protection_token)
794
+
795
+ token = @subject.request_forgery_protection_token
796
+ key = token && token.to_s
797
+ host.define_method(:permittable_configured_csrf_key) { key }
675
798
  end
676
799
 
677
800
  def resolve_rule
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.11.1".freeze
2
+ VERSION = "0.12.0".freeze
3
3
  end