permittable 0.11.0 → 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.
@@ -96,7 +96,10 @@ module Permittable
96
96
  end
97
97
 
98
98
  def matching(regexp)
99
- @expected[:format] = regexp
99
+ # `format: "email"` names a preset exactly as `format: :email` does
100
+ # (see resolve_format!), so the matcher reads a String the same way;
101
+ # as a Symbol it also reports the preset the same way in a failure.
102
+ @expected[:format] = regexp.is_a?(String) ? regexp.to_sym : regexp
100
103
  self
101
104
  end
102
105
 
@@ -148,7 +151,7 @@ module Permittable
148
151
  @subject = resolve_subject(subject)
149
152
  raise ArgumentError, negated_qualifier_message unless @expected.empty?
150
153
 
151
- %i[undeclared too_deep].include?(locate)
154
+ %i[undeclared too_deep misindexed].include?(locate)
152
155
  end
153
156
 
154
157
  def failure_message
@@ -156,6 +159,7 @@ module Permittable
156
159
  case @status
157
160
  when :no_rule then "#{subject} it #{@problem}"
158
161
  when :root_prefixed then "#{subject} #{root_prefix_hint}"
162
+ when :misindexed then "#{subject} #{misindexed_reason}"
159
163
  when :too_deep
160
164
  "#{subject} it is deeper than the opaque :json field #{label_for(@opaque[:path])} allows " \
161
165
  "(max_depth: #{@opaque[:field][:max_depth]})"
@@ -202,18 +206,21 @@ module Permittable
202
206
  # The one lookup both directions share. Sets @status to :no_rule,
203
207
  # :declared, :opaque (the path runs into a :json field, which accepts
204
208
  # any nested key without declaring it), :too_deep (it runs into one
205
- # deeper than its max_depth: allows), :root_prefixed (the path starts
206
- # 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.
207
213
  # Every per-run ivar is reset first: a matcher object can be reused
208
214
  # on another subject, and must not answer from the previous run.
209
215
  def locate
210
- @rule = @field = @opaque = @problem = @missing_among = nil
216
+ @rule = @field = @opaque = @misindexed = @problem = @missing_among = nil
211
217
  @mismatches = []
212
218
  @rule = resolve_rule(@subject)
213
219
  return @status = :no_rule unless @rule
214
220
 
215
221
  @field = resolve_field(@rule[:fields], @segments)
216
222
  @status = if @field then :declared
223
+ elsif @misindexed then :misindexed
217
224
  elsif @opaque then opaque_within_depth? ? :opaque : :too_deep
218
225
  elsif root_prefixed? then :root_prefixed
219
226
  else :undeclared
@@ -247,6 +254,7 @@ module Permittable
247
254
  # field is opaque: it has no :fields, yet lets any nested key through,
248
255
  # so a path running past one is recorded rather than called missing,
249
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.
250
258
  def resolve_field(fields, segments, depth = 0)
251
259
  name = segments[depth][:name].to_sym
252
260
  field = fields.find { |f| f[:name] == name }
@@ -254,17 +262,44 @@ module Permittable
254
262
  @missing_among = fields
255
263
  return nil
256
264
  end
265
+ if segments[depth][:indexes] > index_levels(field)
266
+ @misindexed = { path: field_path(segments, depth), field: field }
267
+ return nil
268
+ end
257
269
  return field if depth == segments.length - 1
258
270
 
259
271
  if field[:kind] == :json
260
- @opaque = { path: segments.take(depth + 1).map { |seg| seg[:name] }.join("."), field: field,
261
- steps: steps_below(segments, depth) }
272
+ @opaque = { path: field_path(segments, depth), field: field, steps: steps_below(segments, depth) }
262
273
  return nil
263
274
  end
264
275
 
265
276
  resolve_field(field[:fields] || [], segments, depth + 1)
266
277
  end
267
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
+
268
303
  # Each key or [n] step past the :json field descends into one more
269
304
  # container — the same count the runtime's max_depth: check makes,
270
305
  # where the field's own Hash is the first level and arrays count too.
@@ -289,7 +324,7 @@ module Permittable
289
324
  missing_among = @missing_among
290
325
  found = resolve_field(@rule[:fields], @segments.drop(1)) || (@opaque && opaque_within_depth?)
291
326
  @missing_among = missing_among
292
- @opaque = nil
327
+ @opaque = @misindexed = nil
293
328
  found ? true : false
294
329
  end
295
330
 
@@ -304,8 +339,10 @@ module Permittable
304
339
  end
305
340
 
306
341
  # Accepts the runtime's own path form too — violation details say
307
- # "line_items[0].sku" — so a path copied from one resolves: the [n]
308
- # 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.
309
346
  def path_segments!(path)
310
347
  raws = path.split(".", -1)
311
348
  matches = raws.map { |raw| SEGMENT.match(raw) }
@@ -364,8 +401,9 @@ module Permittable
364
401
  "expected the field to be #{expected}, but it is #{actual}" unless actual == expected
365
402
  end
366
403
 
367
- # `matching(:email)` asserts the preset by name, `matching(/re/)` the
368
- # Regexp itself.
404
+ # `matching(:email)` (or `matching("email")`, normalized to a Symbol by
405
+ # `matching`) asserts the preset by name, `matching(/re/)` the Regexp
406
+ # itself.
369
407
  def format_mismatch(field, expected)
370
408
  return option_mismatch(field, :format, expected) unless expected.is_a?(Symbol)
371
409
  return if field[:format_name] == expected
@@ -374,7 +412,21 @@ module Permittable
374
412
  end
375
413
 
376
414
  def default_mismatch(field, expected)
377
- option_mismatch(field, :default, expected) unless field.key?(:default) && field[:default] == cast_default(field, expected)
415
+ option_mismatch(field, :default, expected) unless field.key?(:default) && same_default?(field, expected)
416
+ end
417
+
418
+ # A :json default is stored as authored, but the action reads it as
419
+ # a HashWithIndifferentAccess, String-keyed all the way down. The
420
+ # declaration's spelling, `with_default(theme: "dark")`, and the
421
+ # action's, `with_default("theme" => "dark")`, name the same default,
422
+ # so both sides are read that way before comparing.
423
+ def same_default?(field, expected)
424
+ declared = field[:default]
425
+ if field[:kind] == :json && declared.is_a?(Hash) && expected.is_a?(Hash)
426
+ ActiveSupport::HashWithIndifferentAccess.new(declared) == ActiveSupport::HashWithIndifferentAccess.new(expected)
427
+ else
428
+ declared == cast_default(field, expected)
429
+ end
378
430
  end
379
431
 
380
432
  # A contract stores its `default:` as the field reads it — cast,
@@ -388,7 +440,11 @@ module Permittable
388
440
  # (see validate_authored_value!/validate_array_authored_value!), so
389
441
  # `expected` is compared bare, not cast — the matcher would otherwise
390
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" }`.
391
446
  def cast_default(field, expected)
447
+ return cast_json_default(field, expected) if field[:kind] == :json
392
448
  return expected if field[:transform]
393
449
 
394
450
  case field[:kind]
@@ -408,6 +464,13 @@ module Permittable
408
464
  end
409
465
  end
410
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
+
411
474
  # A contract stores an `in:` list cast by the field's type, so
412
475
  # `within(%i[draft published])` — the declaration repeated as written —
413
476
  # is read the same way before comparing, by the same two functions the
@@ -483,8 +546,11 @@ module Permittable
483
546
  end
484
547
  end
485
548
 
549
+ # An anonymous controller class has no name, and a Contract instance
550
+ # never does, so the fallback names the kind of subject it is.
486
551
  def subject_name
487
- (@subject.respond_to?(:name) && @subject.name) || "the controller"
552
+ (@subject.respond_to?(:name) && @subject.name) ||
553
+ (@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
488
554
  end
489
555
 
490
556
  def path_label
@@ -520,14 +586,28 @@ module Permittable
520
586
  end
521
587
 
522
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.
523
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
+
524
598
  @returning = hash
525
599
  self
526
600
  end
527
601
 
528
602
  # reject_params only: assert a particular violation is among those
529
- # 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.
530
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
+
531
611
  @expected_violations << { param: param.to_s, code: code&.to_s }
532
612
  self
533
613
  end
@@ -535,14 +615,30 @@ module Permittable
535
615
  # -- RSpec protocol -----------------------------------------------------
536
616
 
537
617
  def matches?(subject)
538
- @subject = resolve_subject(subject)
539
- rule = resolve_rule
618
+ rule = locate(subject)
540
619
  return false unless rule
541
620
 
542
621
  @violations, @result = run(rule)
543
622
  @expect_accepted ? accepted_ok? : rejected_ok?
544
623
  end
545
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
+
546
642
  def failure_message
547
643
  return "expected #{subject_name} to #{description}, but it #{@problem}" if @problem
548
644
 
@@ -559,6 +655,8 @@ module Permittable
559
655
 
560
656
  def failure_message_when_negated
561
657
  verb = @expect_accepted ? "accept" : "reject"
658
+ return "expected #{subject_name} not to #{verb} those params#{action_label}, but it #{@problem}" if @problem
659
+
562
660
  "expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
563
661
  end
564
662
 
@@ -576,6 +674,28 @@ module Permittable
576
674
 
577
675
  private
578
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
+
579
699
  def accepted_ok?
580
700
  return false unless @violations.empty?
581
701
 
@@ -601,38 +721,80 @@ module Permittable
601
721
  # of @subject's own class: @subject may be a real controller, and
602
722
  # instantiating one outside the framework's own dispatch (no request,
603
723
  # no response, whatever else its own before_actions assume) is not
604
- # safe in general. That means it does not automatically inherit any
605
- # override @subject's class makes to permittable_check_unknown — which
606
- # is exactly what a standalone Permittable::Contract relies on
607
- # (Contract#initialize forces top_level: false, since standalone input
608
- # has no router to exempt routing keys for). So that one override is
609
- # replicated here for a Contract subject specifically, keeping the
610
- # controller path (which is presumed to use the plain module default)
611
- # 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.
612
739
  def run(rule)
740
+ return run_contract if @subject.is_a?(Permittable::Contract)
741
+
613
742
  host = Class.new do
614
743
  include Permittable
615
744
 
616
745
  attr_accessor :params
617
746
  end
618
- mirror_contract_unknown_check(host) if @subject.is_a?(Permittable::Contract)
747
+ action = @action || rule[:actions].first || "call"
748
+ label_instrumentation(host, action)
749
+ mirror_csrf_key(host)
619
750
  host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
620
751
  instance = host.new
621
752
  instance.params = @params
622
- action = @action || rule[:actions].first || "call"
623
753
  violations = instance.permittable_violations(action)
624
754
  result = violations.empty? ? instance.permitted_params(action) : nil
625
755
  [violations, result]
626
756
  end
627
757
 
628
- # Mirrors Permittable::Contract's own host override verbatim (see
629
- # lib/permittable/contract.rb): standalone input has no router and no
630
- # request, so nothing is exempt from unknown: checking, top level or
631
- # not.
632
- def mirror_contract_unknown_check(host)
633
- host.define_method(:permittable_check_unknown) do |fields, hash, path:, unknown:, top_level:, violations:| # rubocop:disable Lint/UnusedBlockArgument
634
- super(fields, hash, path: path, unknown: unknown, top_level: false, violations: violations)
635
- end
758
+ # A rejection instruments invalid_parameters.permittable exactly as a
759
+ # request would. The host is anonymous, so without this the payload
760
+ # carried controller: nil, action: nil — a subscriber following the
761
+ # README's shape (`payload[:controller].tr("/", ".")`) raised out of
762
+ # the matcher. Label it the way the subject itself would: a
763
+ # controller by its path (or name, or "the contract" when anonymous —
764
+ # an anonymous controller's controller_path is nil), a Contract as
765
+ # Contract#call does.
766
+ def label_instrumentation(host, action)
767
+ label = if @subject.is_a?(Permittable::Contract) then "Permittable::Contract"
768
+ elsif @subject.respond_to?(:controller_path) then @subject.controller_path || subject_name
769
+ else subject_name
770
+ end
771
+ host.define_method(:permittable_controller_name) { label }
772
+ host.define_method(:permittable_action_name) { action }
773
+ end
774
+
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 }
636
798
  end
637
799
 
638
800
  def resolve_rule
@@ -666,8 +828,11 @@ module Permittable
666
828
  violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
667
829
  end
668
830
 
831
+ # An anonymous controller class has no name, and a Contract instance
832
+ # never does, so the fallback names the kind of subject it is.
669
833
  def subject_name
670
- (@subject.respond_to?(:name) && @subject.name) || "the contract"
834
+ (@subject.respond_to?(:name) && @subject.name) ||
835
+ (@subject.is_a?(Permittable::Contract) ? "the contract" : "the controller")
671
836
  end
672
837
 
673
838
  def action_label
@@ -4,7 +4,8 @@
4
4
  # summary goes to stderr.
5
5
  #
6
6
  # bin/rails permittable:generate # every uncovered controller not
7
- # # left out by Permittable.audit_ignore
7
+ # # left out by Permittable.audit_ignore,
8
+ # # for the actions it defines
8
9
  # bin/rails "permittable:generate[UsersController]" # one controller, even if covered
9
10
  namespace :permittable do
10
11
  desc "Draft Permittable contracts from models and existing permit calls"
@@ -17,12 +18,13 @@ namespace :permittable do
17
18
  controllers = bases.flat_map(&:descendants).uniq.select(&:name)
18
19
 
19
20
  # { controller => actions to leave out of its draft }. A named controller
20
- # is drafted whole, even if covered or ignored; the sweep skips both, and
21
- # leaves out what Permittable.audit_ignore names (see Generator.targets).
21
+ # is drafted even if covered or ignored; the sweep skips both, and leaves
22
+ # out what Permittable.audit_ignore names. Either leaves out an action the
23
+ # controller does not define (see Generator.targets and named_target).
22
24
  if task_args[:controller]
23
25
  controllers = controllers.select { |c| c.name == task_args[:controller] }
24
26
  abort "Permittable: no controller named #{task_args[:controller]} was found" if controllers.empty?
25
- targets = controllers.to_h { |c| [c, []] }
27
+ targets = controllers.to_h { |c| [c, Permittable::Generator.named_target(c)] }
26
28
  else
27
29
  targets = Permittable::Generator.targets(controllers)
28
30
  end
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.11.0".freeze
2
+ VERSION = "0.12.0".freeze
3
3
  end