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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +29 -15
- data/lib/permittable/authored_values.rb +17 -3
- data/lib/permittable/column_guard.rb +29 -0
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/error_envelope.rb +43 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +40 -1
- data/lib/permittable/json_schema.rb +14 -2
- data/lib/permittable/rspec.rb +152 -29
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +388 -68
- metadata +2 -2
data/lib/permittable/rspec.rb
CHANGED
|
@@ -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), :
|
|
209
|
-
#
|
|
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
|
|
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
|
|
311
|
-
# indexes are
|
|
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
|
-
|
|
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.
|
|
626
|
-
#
|
|
627
|
-
#
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
#
|
|
632
|
-
#
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
data/lib/permittable/version.rb
CHANGED