Skip to content

Fix accept_params/reject_params missing a standalone Contract's unknown: strictness - #72

Merged
VSN2015 merged 1 commit into
masterfrom
fix/matcher-unknown-key-parity
Sep 27, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/matcher-unknown-key-parity

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

The false positive

ParamsBehaviourMatcher#run (accept_params/reject_params's engine) replays the rule against a brand-new, generic throwaway host — Class.new { include Permittable; attr_accessor :params } — instead of one that behaves like @subject itself.

That matters for a standalone Permittable::Contract. Contract#initialize builds its own internal host with a deliberate override:

def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:)
  super(fields, hash, path: path, unknown: unknown, top_level: false, violations: violations)
end

— "standalone input has no router and no request... gets no exemption from unknown: checking." The matcher's generic host has no such override, so it falls through to the module default, which exempts routing/form keys (controller, action, format, ...) whenever top_level is true — true for any rootless rule, the common case for a Contract.

contract = Permittable::Contract.define(unknown: :error) { required :a, :integer }

contract.call(a: "1", controller: "x").invalid?              # => true  (real behaviour: rejects)
expect(contract).to accept_params(a: "1", controller: "x")   # => passed (matcher: WRONGLY accepted)

So accept_params/reject_params could disagree with contract.call on exactly the routing-shaped keys a standalone Contract is documented to not exempt — a false "accept" (or false negative on reject_params) in the one place a spec exists to catch it.

The fix

ParamsBehaviourMatcher#run still builds the same minimal generic host for the general case — it is not safe to subclass @subject's own class, since @subject may be a real controller, and instantiating one outside the framework's own dispatch (no request, no response, whatever its before_actions assume) isn't something the matcher can do reliably. @subject.class also isn't usable for a Contract, since Permittable::Contract doesn't itself include Permittable — it wraps a private internal host that does.

So the one override Contract makes is replicated directly on the throwaway host, only when @subject is a Permittable::Contract:

mirror_contract_unknown_check(host) if @subject.is_a?(Permittable::Contract)

The controller path is untouched — it still gets the plain generic host, exactly as before.

Verification

  • New spec in spec/matchers_spec.rb reproduces the false positive against a rootless unknown: :error Contract with a controller key; confirmed it fails on master before the fix
  • 849 examples, 0 failures (bundle exec rspec)
  • RuboCop clean on the touched files

🤖 Generated with Claude Code

…n: strictness

ParamsBehaviourMatcher replayed a rule against a brand-new generic host
instead of one that mirrors Permittable::Contract's own override, which
forces top_level: false because standalone input has no router. The
matcher's generic host fell through to the module default instead, which
exempts routing keys (controller, action, ...) at the top level — so
accept_params/reject_params could wrongly agree a routing-shaped key was
accepted by a rootless unknown: :error Contract that actually rejects it.
Mirror that one override on the throwaway host when @subject is a
Contract, leaving the controller path untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit 08efe0f into master Sep 27, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant