permittable 0.8.0 → 0.10.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.
@@ -14,6 +14,14 @@ module Permittable
14
14
  # expect(UsersController).to permit_param("address.zip").as(:string).optional
15
15
  # expect(UsersController).not_to permit_param(:admin).for_action(:create)
16
16
  #
17
+ # The negated form asserts that the contract does not declare the param,
18
+ # and takes no qualifiers: `not_to permit_param(:admin).required` would
19
+ # pass both when :admin is undeclared and when it is declared optional,
20
+ # which is a false positive in exactly the assertion most likely to guard
21
+ # a security property. It raises instead, and names the positive form to
22
+ # write. It reads the contract, not the runtime mode: under a rule in
23
+ # monitor mode, permitted_params hands back undeclared keys regardless.
24
+ #
17
25
  # `for_action` picks the rule exactly like a request would
18
26
  # (`permit_rule_for`); it may be omitted only when the controller declares
19
27
  # a single contract, so an ambiguous expectation fails loudly instead of
@@ -23,14 +31,35 @@ module Permittable
23
31
  PermitParamMatcher.new(path)
24
32
  end
25
33
 
34
+ # What the contract DOES with a payload, as opposed to what it declares.
35
+ #
36
+ # expect(UsersController).to accept_params(user: { name: "Jo" })
37
+ # .for_action(:create).returning("name" => "Jo", "plan" => "free")
38
+ #
39
+ # expect(UsersController).to reject_params(user: {})
40
+ # .for_action(:create).with_violation("user.name", :missing)
41
+ #
42
+ # No request is dispatched: the rule is run against the payload directly,
43
+ # so these work on a controller class, a controller instance, or a
44
+ # standalone Permittable::Contract.
45
+ def accept_params(params)
46
+ ParamsBehaviourMatcher.new(params, expect_accepted: true)
47
+ end
48
+
49
+ def reject_params(params)
50
+ ParamsBehaviourMatcher.new(params, expect_accepted: false)
51
+ end
52
+
26
53
  class PermitParamMatcher
27
54
  OPTION_LABELS = { in: "in:", format: "format:", length: "length:", default: "default:" }.freeze
55
+ # One path segment: a name plus any [n] indexes (the runtime's form).
56
+ SEGMENT = /\A([^\[\]]+)((?:\[\d+\])*)\z/
28
57
 
29
58
  def initialize(path)
30
59
  @path = path.to_s
60
+ @segments = path_segments!(@path)
31
61
  @action = nil
32
62
  @expected = {}
33
- @mismatches = []
34
63
  end
35
64
 
36
65
  # -- chains -----------------------------------------------------------
@@ -100,34 +129,54 @@ module Permittable
100
129
 
101
130
  def matches?(subject)
102
131
  @subject = resolve_subject(subject)
103
- rule = resolve_rule(@subject)
104
- return false unless rule
132
+ case locate
133
+ when :declared then @mismatches = collect_mismatches(@field)
134
+ when :opaque then @mismatches = @expected.empty? ? [] : [opaque_qualifier_mismatch]
135
+ else return false
136
+ end
137
+ @mismatches.empty?
138
+ end
105
139
 
106
- @field = resolve_field(rule[:fields], @path.split("."))
107
- return false unless @field
140
+ # Negation is only unambiguous without qualifiers ("not declared"),
141
+ # and only meaningful against a rule that exists: a mistyped
142
+ # `for_action(:craete)` with no catch-all rule resolves to no rule,
143
+ # which declares nothing, so a lenient not_to would pass for any param
144
+ # whatsoever. (With a catch-all it resolves there, as a request
145
+ # would, and is checked against that rule.) The subject is
146
+ # resolved first so a wrong subject is the error reported.
147
+ def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
148
+ @subject = resolve_subject(subject)
149
+ raise ArgumentError, negated_qualifier_message unless @expected.empty?
108
150
 
109
- @mismatches = collect_mismatches(@field)
110
- @mismatches.empty?
151
+ %i[undeclared too_deep].include?(locate)
111
152
  end
112
153
 
113
154
  def failure_message
114
- return "expected #{subject_name} to permit #{path_label}#{action_label}, but it #{@problem}" if @problem
115
-
116
- if @field.nil?
117
- declared = (@missing_among || []).map { |f| f[:name] }.join(", ")
118
- return "expected #{subject_name} to permit #{path_label}#{action_label}, " \
119
- "but it is not declared (declared: #{declared})"
155
+ subject = "expected #{subject_name} to permit #{path_label}#{action_label}, but"
156
+ case @status
157
+ when :no_rule then "#{subject} it #{@problem}"
158
+ when :root_prefixed then "#{subject} #{root_prefix_hint}"
159
+ when :too_deep
160
+ "#{subject} it is deeper than the opaque :json field #{label_for(@opaque[:path])} allows " \
161
+ "(max_depth: #{@opaque[:field][:max_depth]})"
162
+ when :undeclared
163
+ "#{subject} it is not declared (declared: #{(@missing_among || []).map { |f| f[:name] }.join(', ')})"
164
+ else "#{subject}:\n #{@mismatches.join("\n ")}"
120
165
  end
121
-
122
- "expected #{subject_name} to permit #{path_label}#{action_label}, but:\n #{@mismatches.join("\n ")}"
123
166
  end
124
167
 
125
168
  def failure_message_when_negated
126
- "expected #{subject_name} not to permit #{path_label}#{action_label}, but the contract declares it"
169
+ subject = "expected #{subject_name} not to permit #{path_label}#{action_label}, but"
170
+ case @status
171
+ when :no_rule then "#{subject} it #{@problem}, so there is no rule to check the param against"
172
+ when :opaque
173
+ "#{subject} it is inside the opaque :json field #{label_for(@opaque[:path])}, which lets any nested key through"
174
+ when :root_prefixed then "#{subject} #{root_prefix_hint} — which the contract lets through"
175
+ else "#{subject} the contract declares it"
176
+ end
127
177
  end
128
178
 
129
179
  def description
130
- descriptors = @expected.filter_map { |key, value| describe_check(key, value) }
131
180
  label = "permit #{path_label}"
132
181
  label += " (for ##{@action})" if @action
133
182
  label += " #{descriptors.join(', ')}" unless descriptors.empty?
@@ -150,6 +199,27 @@ module Permittable
150
199
  raise ArgumentError, "#{LABEL}: the subject of permit_param must include Permittable (got #{subject.inspect})"
151
200
  end
152
201
 
202
+ # The one lookup both directions share. Sets @status to :no_rule,
203
+ # :declared, :opaque (the path runs into a :json field, which accepts
204
+ # 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.
207
+ # Every per-run ivar is reset first: a matcher object can be reused
208
+ # on another subject, and must not answer from the previous run.
209
+ def locate
210
+ @rule = @field = @opaque = @problem = @missing_among = nil
211
+ @mismatches = []
212
+ @rule = resolve_rule(@subject)
213
+ return @status = :no_rule unless @rule
214
+
215
+ @field = resolve_field(@rule[:fields], @segments)
216
+ @status = if @field then :declared
217
+ elsif @opaque then opaque_within_depth? ? :opaque : :too_deep
218
+ elsif root_prefixed? then :root_prefixed
219
+ else :undeclared
220
+ end
221
+ end
222
+
153
223
  def resolve_rule(subject)
154
224
  return resolve_rule_for_action(subject) if @action
155
225
 
@@ -173,17 +243,78 @@ module Permittable
173
243
  end
174
244
 
175
245
  # Walks a dotted path through nested blocks and array-of-hash blocks
176
- # alike, since both carry their sub-fields under :fields.
177
- def resolve_field(fields, segments)
178
- name = segments.first.to_sym
246
+ # alike, since both carry their sub-fields under :fields. A :json
247
+ # field is opaque: it has no :fields, yet lets any nested key through,
248
+ # so a path running past one is recorded rather than called missing,
249
+ # along with how many container levels the rest of the path needs.
250
+ def resolve_field(fields, segments, depth = 0)
251
+ name = segments[depth][:name].to_sym
179
252
  field = fields.find { |f| f[:name] == name }
180
253
  if field.nil?
181
254
  @missing_among = fields
182
255
  return nil
183
256
  end
184
- return field if segments.length == 1
257
+ return field if depth == segments.length - 1
258
+
259
+ if field[:kind] == :json
260
+ @opaque = { path: segments.take(depth + 1).map { |seg| seg[:name] }.join("."), field: field,
261
+ steps: steps_below(segments, depth) }
262
+ return nil
263
+ end
264
+
265
+ resolve_field(field[:fields] || [], segments, depth + 1)
266
+ end
267
+
268
+ # Each key or [n] step past the :json field descends into one more
269
+ # container — the same count the runtime's max_depth: check makes,
270
+ # where the field's own Hash is the first level and arrays count too.
271
+ def steps_below(segments, depth)
272
+ (segments.length - depth - 1) + segments.drop(depth).sum { |seg| seg[:indexes] }
273
+ end
274
+
275
+ def opaque_within_depth?
276
+ limit = @opaque[:field][:max_depth]
277
+ limit.nil? || @opaque[:steps] <= limit
278
+ end
279
+
280
+ # Paths are relative to root:, so "user.email" under `root: :user`
281
+ # names params[:user][:user][:email]. When dropping the prefix would
282
+ # resolve (to a declared field, or into an opaque :json one within its
283
+ # max_depth:), that is almost certainly what was meant — and silently
284
+ # passing a negated expectation on it would be a false pass.
285
+ def root_prefixed?
286
+ root = @rule[:root]
287
+ return false unless root && @segments.length > 1 && @segments.first[:name] == root.to_s
185
288
 
186
- resolve_field(field[:fields] || [], segments.drop(1))
289
+ missing_among = @missing_among
290
+ found = resolve_field(@rule[:fields], @segments.drop(1)) || (@opaque && opaque_within_depth?)
291
+ @missing_among = missing_among
292
+ @opaque = nil
293
+ found ? true : false
294
+ end
295
+
296
+ def root_prefix_hint
297
+ relative = @segments.drop(1).map { |seg| seg[:raw] }.join(".")
298
+ "paths are relative to root: :#{@rule[:root]}, so write permit_param(#{label_for(relative)})"
299
+ end
300
+
301
+ def opaque_qualifier_mismatch
302
+ "it is inside the opaque :json field #{label_for(@opaque[:path])}, which declares nothing about its keys — " \
303
+ "assert qualifiers on #{label_for(@opaque[:path])} itself"
304
+ end
305
+
306
+ # 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.
309
+ def path_segments!(path)
310
+ raws = path.split(".", -1)
311
+ matches = raws.map { |raw| SEGMENT.match(raw) }
312
+ if raws.empty? || matches.any?(&:nil?)
313
+ raise ArgumentError, "#{LABEL}: permit_param needs a param name or a dotted path like " \
314
+ "\"address.zip\" or \"line_items[0].sku\" (got #{path.inspect})"
315
+ end
316
+
317
+ raws.zip(matches).map { |raw, m| { name: m[1], indexes: m[2].count("["), raw: raw } }
187
318
  end
188
319
 
189
320
  def collect_mismatches(field)
@@ -194,15 +325,31 @@ module Permittable
194
325
  case key
195
326
  when :type then type_mismatch(field, value)
196
327
  when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
197
- when :of then "expected an array of :#{value}, but it is of: :#{field[:of]}" unless field[:of] == value
328
+ when :of then of_mismatch(field, value)
198
329
  when :required then required_mismatch(field, value)
330
+ when :format then format_mismatch(field, value)
331
+ # Compared cast, but reported as written.
332
+ when :default then default_mismatch(field, value)
333
+ when :in then option_mismatch(field, :in, value) unless field.key?(:in) && same_in?(field[:in], cast_in(field, value))
199
334
  when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key]
200
335
  else option_mismatch(field, key, value)
201
336
  end
202
337
  end
203
338
 
339
+ # A non-array field is already reported by the :array check that
340
+ # as_array always chains alongside :of, so this stays silent for it;
341
+ # an array of hashes has sub-fields rather than an element type.
342
+ def of_mismatch(field, type)
343
+ return if field[:kind] != :array || field[:of] == type
344
+ return "expected an array of :#{type}, but :#{field[:name]} is an array of hashes" if field[:fields]
345
+
346
+ "expected an array of :#{type}, but it is of: :#{field[:of]}"
347
+ end
348
+
204
349
  def type_mismatch(field, type)
205
- if field[:kind] == :array
350
+ if field[:kind] == :array && field[:fields]
351
+ "expected type :#{type}, but :#{field[:name]} is an array of hashes — assert it with as_array"
352
+ elsif field[:kind] == :array
206
353
  "expected type :#{type}, but :#{field[:name]} is an array — assert it with as_array(of: ...)"
207
354
  elsif field[:kind] == :nested
208
355
  "expected type :#{type}, but :#{field[:name]} is a nested hash"
@@ -217,6 +364,79 @@ module Permittable
217
364
  "expected the field to be #{expected}, but it is #{actual}" unless actual == expected
218
365
  end
219
366
 
367
+ # `matching(:email)` asserts the preset by name, `matching(/re/)` the
368
+ # Regexp itself.
369
+ def format_mismatch(field, expected)
370
+ return option_mismatch(field, :format, expected) unless expected.is_a?(Symbol)
371
+ return if field[:format_name] == expected
372
+
373
+ "expected format: :#{expected}, but the contract #{declared_format(field)}"
374
+ end
375
+
376
+ def default_mismatch(field, expected)
377
+ option_mismatch(field, :default, expected) unless field.key?(:default) && field[:default] == cast_default(field, expected)
378
+ end
379
+
380
+ # A contract stores its `default:` as the field reads it — cast,
381
+ # normalized, and for an array read by the request walker — so
382
+ # `with_default("18")` on an :integer, the declaration repeated as
383
+ # written, is read the same way before comparing, by the same code. A
384
+ # value that does not read cleanly is compared as given, and the
385
+ # failure shows both sides.
386
+ #
387
+ # A field declaring `transform:` stores its default AS AUTHORED instead
388
+ # (see validate_authored_value!/validate_array_authored_value!), so
389
+ # `expected` is compared bare, not cast — the matcher would otherwise
390
+ # compare a cast value against an uncast stored one and never match.
391
+ def cast_default(field, expected)
392
+ return expected if field[:transform]
393
+
394
+ case field[:kind]
395
+ when :scalar
396
+ # Copied first, like a request's String, so a mutating `normalize:`
397
+ # proc cannot rewrite the spec's own literal (the same reason
398
+ # validate_authored_value! copies before normalizing).
399
+ own = expected.is_a?(String) ? expected.dup : expected
400
+ status, value = Coercion.cast(field[:type], Coercion.apply_normalize(field[:normalize], own))
401
+ status == :ok ? value : expected
402
+ when :array
403
+ return expected unless expected.is_a?(Array)
404
+
405
+ value, violations = AuthoredValues.read_array(field, expected)
406
+ violations.empty? ? value : expected
407
+ else expected
408
+ end
409
+ end
410
+
411
+ # A contract stores an `in:` list cast by the field's type, so
412
+ # `within(%i[draft published])` — the declaration repeated as written —
413
+ # is read the same way before comparing, by the same two functions the
414
+ # contract uses: what counts as a list (a Hash as its keys), then the
415
+ # cast. Anything that is not a list, or does not cast, is compared as
416
+ # given — a Range and a host's own allowlist are stored as given too.
417
+ def cast_in(field, expected)
418
+ members = field[:kind] == :scalar && Coercion.in_list(expected)
419
+ return expected unless members
420
+
421
+ status, cast = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable])
422
+ status == :ok ? cast : expected
423
+ end
424
+
425
+ # A list's order and container say nothing about what it allows:
426
+ # `in: Post.statuses` is stored as a Set, and `within(%w[draft
427
+ # published])` names exactly its values.
428
+ def same_in?(declared, expected)
429
+ lists = [declared, expected].all? { |list| list.is_a?(Array) || list.is_a?(Set) }
430
+ lists ? declared.to_set == expected.to_set : declared == expected
431
+ end
432
+
433
+ def declared_format(field)
434
+ return "declares format: :#{field[:format_name]}" if field[:format_name]
435
+ return "declares format: #{field[:format].inspect}" if field[:format]
436
+
437
+ "does not declare format:"
438
+ end
439
+
220
440
  def option_mismatch(field, key, value)
221
441
  return if field.key?(key) && field[key] == value
222
442
 
@@ -225,6 +445,33 @@ module Permittable
225
445
  "expected #{label} #{value.inspect}, but the contract #{declared}"
226
446
  end
227
447
 
448
+ def negated_qualifier_message
449
+ "#{LABEL}: `not_to #{call_label}` cannot take qualifiers (here: #{descriptors.join(', ')}) — " \
450
+ "negating one is ambiguous, since it would pass both when #{path_label} is not declared and " \
451
+ "when it is declared differently. Assert what the contract does declare with " \
452
+ "the positive form, e.g. #{positive_example}, or drop the qualifiers to assert that " \
453
+ "#{path_label} is not declared at all."
454
+ end
455
+
456
+ # required/optional is the one qualifier with an obvious opposite;
457
+ # for any other the declared value is not known until the rule is
458
+ # read, so the example stays generic rather than guessing it.
459
+ def positive_example
460
+ case @expected
461
+ when { required: true } then "`to #{call_label}.optional`"
462
+ when { required: false } then "`to #{call_label}.required`"
463
+ else "`to #{call_label}` chained with the qualifiers it should have"
464
+ end
465
+ end
466
+
467
+ def call_label
468
+ "permit_param(#{path_label})#{".for_action(:#{@action})" if @action}"
469
+ end
470
+
471
+ def descriptors
472
+ @expected.filter_map { |key, value| describe_check(key, value) }
473
+ end
474
+
228
475
  def describe_check(key, value)
229
476
  case key
230
477
  when :type then "as :#{value}"
@@ -241,7 +488,186 @@ module Permittable
241
488
  end
242
489
 
243
490
  def path_label
244
- @path.include?(".") ? @path.inspect : ":#{@path}"
491
+ label_for(@path)
492
+ end
493
+
494
+ def label_for(path)
495
+ path.include?(".") ? path.inspect : ":#{path}"
496
+ end
497
+
498
+ def action_label
499
+ @action ? " for ##{@action}" : ""
500
+ end
501
+ end
502
+
503
+ # Runs a declared contract against a payload and asserts on the outcome —
504
+ # the behavioural counterpart to PermitParamMatcher, which asserts on the
505
+ # declaration. Shares its subject and rule resolution, so `for_action` picks
506
+ # the rule exactly as a request would and ambiguity fails loudly.
507
+ class ParamsBehaviourMatcher
508
+ def initialize(params, expect_accepted:)
509
+ @params = params
510
+ @expect_accepted = expect_accepted
511
+ @expected_violations = []
512
+ @action = nil
513
+ end
514
+
515
+ # -- chains -------------------------------------------------------------
516
+
517
+ def for_action(action)
518
+ @action = action.to_s
519
+ self
520
+ end
521
+
522
+ # accept_params only: assert the cast, defaulted, transformed output.
523
+ def returning(hash)
524
+ @returning = hash
525
+ self
526
+ end
527
+
528
+ # reject_params only: assert a particular violation is among those
529
+ # recorded. Repeatable; the code is optional.
530
+ def with_violation(param, code = nil)
531
+ @expected_violations << { param: param.to_s, code: code&.to_s }
532
+ self
533
+ end
534
+
535
+ # -- RSpec protocol -----------------------------------------------------
536
+
537
+ def matches?(subject)
538
+ @subject = resolve_subject(subject)
539
+ rule = resolve_rule
540
+ return false unless rule
541
+
542
+ @violations, @result = run(rule)
543
+ @expect_accepted ? accepted_ok? : rejected_ok?
544
+ end
545
+
546
+ def failure_message
547
+ return "expected #{subject_name} to #{description}, but it #{@problem}" if @problem
548
+
549
+ if @expect_accepted
550
+ return "expected #{subject_name} to #{description}, but it rejected them: #{summary(@violations)}" unless @violations.empty?
551
+
552
+ "expected #{subject_name} to #{description}, but it accepted them but returned #{@result.to_h.inspect}"
553
+ else
554
+ return "expected #{subject_name} to #{description}, but it accepted them, returning #{@result.to_h.inspect}" if @violations.empty?
555
+
556
+ "expected #{subject_name} to #{description}, but the violations were: #{summary(@violations)}"
557
+ end
558
+ end
559
+
560
+ def failure_message_when_negated
561
+ verb = @expect_accepted ? "accept" : "reject"
562
+ "expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
563
+ end
564
+
565
+ def description
566
+ label = @expect_accepted ? "accept those params" : "reject those params"
567
+ label += action_label
568
+ label += " with #{summary(@expected_violations)}" unless @expected_violations.empty?
569
+ label += " returning #{@returning.inspect}" if @returning
570
+ label
571
+ end
572
+
573
+ def supports_block_expectations?
574
+ false
575
+ end
576
+
577
+ private
578
+
579
+ def accepted_ok?
580
+ return false unless @violations.empty?
581
+
582
+ @returning.nil? || @result.to_h == ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h
583
+ end
584
+
585
+ def rejected_ok?
586
+ return false if @violations.empty?
587
+
588
+ @expected_violations.all? do |expected|
589
+ @violations.any? do |actual|
590
+ actual[:param] == expected[:param] && (expected[:code].nil? || actual[:code] == expected[:code])
591
+ end
592
+ end
593
+ end
594
+
595
+ # A throwaway host carrying just this rule, forced to :enforce. The
596
+ # question these matchers answer is what the CONTRACT says, not what the
597
+ # current rollout mode does with it — so a monitor-mode rule still reports
598
+ # its violations here.
599
+ #
600
+ # The host is a bare `include Permittable` class rather than a subclass
601
+ # of @subject's own class: @subject may be a real controller, and
602
+ # instantiating one outside the framework's own dispatch (no request,
603
+ # 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.
612
+ def run(rule)
613
+ host = Class.new do
614
+ include Permittable
615
+
616
+ attr_accessor :params
617
+ end
618
+ mirror_contract_unknown_check(host) if @subject.is_a?(Permittable::Contract)
619
+ host.permittable_contracts = [rule.merge(mode: :enforce).freeze]
620
+ instance = host.new
621
+ instance.params = @params
622
+ action = @action || rule[:actions].first || "call"
623
+ violations = instance.permittable_violations(action)
624
+ result = violations.empty? ? instance.permitted_params(action) : nil
625
+ [violations, result]
626
+ end
627
+
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
636
+ end
637
+
638
+ def resolve_rule
639
+ if @action
640
+ @subject.permit_rule_for(@action) || record_problem("has no contract covering ##{@action}")
641
+ else
642
+ contracts = @subject.permittable_contracts
643
+ case contracts.length
644
+ when 0 then record_problem("declares no contracts")
645
+ when 1 then contracts.first
646
+ else
647
+ raise ArgumentError, "#{LABEL}: #{subject_name} declares #{contracts.length} contracts — " \
648
+ "disambiguate with accept_params(...).for_action(:action)"
649
+ end
650
+ end
651
+ end
652
+
653
+ def record_problem(problem)
654
+ @problem = problem
655
+ nil
656
+ end
657
+
658
+ def resolve_subject(subject)
659
+ return subject if subject.respond_to?(:permit_rule_for)
660
+ return subject.class if subject.class.respond_to?(:permit_rule_for)
661
+
662
+ raise ArgumentError, "#{LABEL}: the subject of accept_params/reject_params must include Permittable (got #{subject.inspect})"
663
+ end
664
+
665
+ def summary(violations)
666
+ violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
667
+ end
668
+
669
+ def subject_name
670
+ (@subject.respond_to?(:name) && @subject.name) || "the contract"
245
671
  end
246
672
 
247
673
  def action_label
@@ -0,0 +1,34 @@
1
+ # Reports contract coverage across the whole route set: which routed actions
2
+ # validate their input, in which mode, guarded by which model — and which do
3
+ # not. The gap that matters is a POST/PUT/PATCH action with no contract, where
4
+ # untrusted input reaches the action unchecked.
5
+ #
6
+ # bin/rails permittable:audit # the table plus a summary
7
+ # bin/rails "permittable:audit[strict]" # ...and exit 1 on any unguarded
8
+ # # write action, as a CI gate
9
+ namespace :permittable do
10
+ desc "Report contract coverage across the route set (pass [strict] to fail on unguarded write actions)"
11
+ task :audit, [:strict] => :environment do |_t, task_args|
12
+ Rails.application.eager_load!
13
+
14
+ bases = []
15
+ bases << ActionController::Base if defined?(ActionController::Base)
16
+ bases << ActionController::API if defined?(ActionController::API)
17
+ # Every controller, not just the ones including Permittable — a controller
18
+ # that never included it is unguarded, which is exactly the finding.
19
+ controllers = bases.flat_map(&:descendants).uniq.select(&:name)
20
+ routes = Permittable::OpenAPI.rails_routes(Rails.application)
21
+
22
+ entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
23
+ stale = Permittable::Audit.stale(controllers: controllers, routes: routes)
24
+ print Permittable::Audit.format(entries, stale: stale)
25
+
26
+ next unless task_args[:strict]
27
+
28
+ gap = Permittable::Audit.summary(entries)[:uncovered_with_body]
29
+ next if gap.zero?
30
+
31
+ abort "\nPermittable: #{gap} routed action#{'s' unless gap == 1} " \
32
+ "accept#{'s' if gap == 1} a request body with no contract covering it."
33
+ end
34
+ end
@@ -1,6 +1,7 @@
1
1
  # Drafts Permittable contracts for controllers that don't declare one yet,
2
- # from each controller's model columns plus any params.permit calls in its
3
- # source. Drafts go to stdout (paste-ready); the summary goes to stderr.
2
+ # from each controller's model columns plus any params.permit or Rails 8
3
+ # params.expect calls in its source. Drafts go to stdout (paste-ready); the
4
+ # summary goes to stderr.
4
5
  #
5
6
  # bin/rails permittable:generate # every uncovered controller
6
7
  # bin/rails "permittable:generate[UsersController]" # one controller, even if covered
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.8.0".freeze
2
+ VERSION = "0.10.0".freeze
3
3
  end