diff --git a/CHANGELOG.md b/CHANGELOG.md index 186b421..08ba21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,21 @@ Contracts that don't opt in are byte-for-byte unaffected: the default format is Other encodings are now **inspected, never converted**. A String whose bytes are not valid in its own encoding is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. A `:string` value is otherwise handed back exactly as it arrived, in its own encoding, so a `skip_parameter_encoding` controller behaves as it did before this release, except that its former crashes are now violations. The number, boolean and date types parse a UTF-8 copy of the text: UTF-16 `"12"` is `12`, and text with no UTF-8 reading (a byte Windows-1252 leaves undefined) is `invalid_type`. A normalizer that cannot handle a String's encoding (`:squish` on UTF-16) leaves the value as it is, and a `format:` pattern that cannot be applied to it (non-ASCII pattern, UTF-16 or binary bytes) reports `format`. Neither ever raises for such a String; on UTF-8 or ASCII-only text an app's own `normalize:` proc that raises still raises. The check sits in the one cast every scalar value goes through, so it covers `of:` elements, sub-fields of arrays of hashes and authored `default:`s. The codes are the existing `invalid_type` and `format`, so the `code` enumeration in the exported error schema is unchanged. A `:json` field's contents are deliberately not examined. The gem runs no string operation inside them, and walking every leaf on every request would add the cost the opaque type exists to avoid. - **An undeclared key that was not valid UTF-8 made the 422 itself fail to render.** Under `unknown: :error` the client's key was copied raw into the violation's `param`, so `"caf\xC3"` made `to_json` raise `JSON::GeneratorError`, and a UTF-16 key raised `Encoding::CompatibilityError` while its path was being built, under `unknown: :log` too. An undeclared key is now converted to UTF-8 for reporting (binary read as UTF-8, other encodings via `encode`), and whatever still cannot be read is replaced with U+FFFD, so `param`, the exception message and the log line are always valid UTF-8, nested keys (`user.x\uFFFD`) included. Only undeclared keys are converted; the declared keys a request walks through are the contract's own names and cost nothing extra. - **A client-sent key could forge a log entry.** The `unknown: :log` warn line, the monitor-mode warn line and `InvalidParameters#message` all interpolate the names of the keys a request sent, raw — and 0.8.0's prose bounds capped how *many* and how *long*, but escaped nothing. A key of `"evil\nE, [2026-09-23] ERROR -- : forged admin login"` therefore wrote a second, entirely fake `ERROR` line into the log. A name containing an unsafe character is now printed quoted and escaped: `"evil\nE, [2026-09-23] ERROR -- : forged admin login"`, with `\n`/`\r`/`\t` short and anything else as `\uXXXX` (`\u{XXXXX}` beyond the BMP). Unsafe means, by Unicode property: every control character (`Cc` — C0, DEL and C1, which holds NEL and the 8-bit terminal CSI); the line and paragraph separators (`Zl`, `Zp`); every format character (`Cf` — the bidi embeddings, overrides and isolates, which can visually reorder a line and hide where a quoted name ends, and the zero-width and marker characters such as U+200B, U+200E/U+200F, U+061C and U+FEFF, which make two names print identically); and every space other than U+0020 (`Zs`), so `x,and 49990 more` cannot pass for a separator. A name that only *looks* like prose structure is quoted too, its characters printed as they are: one containing any Unicode `Quotation_Mark` (`"`, the fullwidth `"`, curly quotes, guillemets, and the CJK corner brackets `「`/`」`, real quotation marks in Japanese and Chinese text that an earlier, narrower `Pi`/`Pf`-only check missed); one containing the list separator `, ` or a fullwidth, small, ideographic or small-form-ideographic comma (`,` `﹐` `、` `﹑`), so it cannot pass for two names; and one beginning `and N more` in any letter case (`And`/`AND` reads identically once rendered), so two keys `b` and `and 49990 more` — or `And 49990 more` — cannot fake the overflow count. That last check still only catches the literal word: it defeats case and punctuation lookalikes, not a homoglyph substitution such as Cyrillic `а` (U+0430) for Latin `a`, and no general Unicode confusable-detection is attempted here — nobody should rely on this guard as a complete defense against a determined lookalike. Inside a quoted name the quote and backslash are escaped. Only the client-sent name is ever escaped: a field's `message:` (or its I18n copy) is the developer's text and is printed as is, so a YAML `|` message ending in a newline does not quote the names it follows. A name is judged by the part the 120-character truncation leaves visible, so a control character past the cut quotes nothing. A quoted name is cut only when it does not fit the limit on its own, between whole escapes and with the `...` outside the closing quote; a quoted name that fits stays whole and its suffix is cut instead — to nothing, if need be, in which case the `...` marking the cut may run up to three characters past the limit. Only such names change: an ordinary name — non-ASCII, backslashes and a bare comma included — prints as before, now always as UTF-8. A key in a legacy encoding is transcoded character by character: what maps is converted (a Windows-1252 `é` prints as `é`, a Latin-1 `0x85` as its NEL escape), and only a byte that has no mapping — Windows-1252 leaves `0x81`, `0x8D`, `0x8F`, `0x90` and `0x9D` undefined — prints as `\xNN`, as do bytes that are not valid UTF-8. The prose list is always joined from UTF-8 items, so names in mixed encodings no longer raise `Encoding::CompatibilityError` there (joining a nested key's path is covered by #61). Only a bounded prefix of each name is read, so a 1 MB name costs no more than a short one. **`InvalidParameters#message` is also what API clients read** — the envelope's `message` and the problem+json `detail` — so a client that sent such a key sees the escaped rendering there too. The machine-readable channels — `details`, the problem+json `errors`, and the instrumentation payload — are data, not prose: an unknown key that is not valid UTF-8 is reported there as `Coercion.reportable_text` reads it (scrubbed to U+FFFD, per #61), so they stay JSON-safe rather than as legible as this escaping can make it, and prose (the log line and the exception message alike) reads the raw key itself, so a client sees the finer `\xNN` transcoding in the message even though `details` shows the plainer, scrubbed form for the same violation. +- **`in:` members are now cast with the field's own type, at class load.** The runtime compared the *cast* request value against the members *as authored*, so `in: %i[draft published]` on a `:string` field compared `"draft"` with `:draft` and answered `inclusion` to **every** request — while the exported schema, which stringifies Symbols, advertised `"enum": ["draft", "published"]`, the very values the server refused. `in: %w[1 2 3]` on an `:integer` field rejected every value the same way. Each member of a **list** — an `Array`, `Set`, `Enumerator` (`Lazy` included, forced once rather than cast per request), or a `Hash` read as its keys, which is what `Hash#include?` always asked about — now goes through the same cast a request value does: a Symbol is read as its String, and `normalize:` is not applied, since it rewrites what a client sent rather than what the contract says. The contract stores the cast members frozen and deduplicated; a `Set` or a `Hash`'s keys are stored as a `Set`, so membership stays O(1) per request. The Rails enum idiom `in: Post.statuses` therefore keeps working, and satisfies `check_column_types`' enum rule, which reads the stored keys. Request-time matching, the exported `enum` (which for `%w[1 2 3]` on an `:integer` is now `[1, 2, 3]`, not `["1", "2", "3"]`), the column guard and the RSpec matcher all read that one list; `within` reads its own argument with the same two functions, and compares lists as sets, so `within(%i[draft published])` can repeat the declaration as written. A `nil` member is dropped on a `nullable:` field, where an explicit null is accepted before `in:` is consulted. A `Time` or `DateTime` member of a `:date` field is read as its date when it is exactly midnight UTC — the one instant ActiveSupport ever found equal to a date. +- **Any other object answering `include?` is still used exactly as given**, Enumerable or not — an app's own case-insensitive allowlist, or a DB-backed registry, is never enumerated at class load nor replaced by an exact-match copy. This includes a `Hash`/`Array`/`Set` **subclass that overrides `include?`**: `case allowed; when Hash ...` matches with `===`, which for a Class is `is_a?`, so a subclass first matched its ancestor's branch and had its override silently discarded, read for its raw keys/elements instead — inverting which values it actually accepted, with no error at class load. Only a plain `Array`, `Set`, `Hash`, or `Enumerator` (an unoverridden `include?`) is now read as a list; `ActiveSupport::HashWithIndifferentAccess` is kept as one anyway, since its own override only canonicalises the argument before the same key lookup, and it is what a Rails enum's own reader (`Post.statuses`) actually returns. Any other override is not cast, and is exported as `x-permittable-custom-validation` rather than an `enum` it cannot list. +- **Every exported `:date`/`:datetime` member is one the server accepts.** A member written as a String is published as written, and a `Time`/`DateTime`/`TimeWithZone` member is published with as many fractional-second digits as it has (up to nine). Re-encoding printed whole seconds, so `in: ["2026-09-05T10:00:00.25Z"]` published `"2026-09-05T10:00:00Z"`, a value the server refused. The same encoding applies to an exported `:datetime` `default:`/`example:`. A spec sends every exported member back through the contract. +- **`in:` can no longer be a `String`.** The only check was `respond_to?(:include?)`, which a `String` passes — and `String#include?` is a substring test, so `in: "free pro"` accepted `"e"`, `"fr"` and `"ee p"` as plans. A `String` now fails at class load, as anything that is neither a `Range` nor answers `include?` already did. +- **An `in:` `Range` the field's values cannot be compared with fails at class load.** `in: "1".."5"` on an `:integer`, or `in: 1..5` on a `:string`, made `cover?` answer `false` for every value. A Range is deliberately **not** cast — casting would change what it means (`0..Float::INFINITY` on a `:float` and `1.5..3` on an `:integer` are real bounds no cast accepts, and a `:decimal`'s `0..100` would export its `minimum` as the string `"0.0"`) — so it is kept exactly as written, and refused only when its endpoints cannot be compared with a value of the field's type, asked the way `cover?` itself asks. + +**A list is now a snapshot taken at class load.** Until now an `in:` Array was read live, so a constant mutated after the class loaded — `PLANS << "gold"` in an initializer — was seen by later requests. It is now cast and frozen once, so that mutation is not seen. Declare the full list before the contract loads, or pass an object of your own answering `include?`, which is still read on every request. + +The cast only **loosens** what a request is judged against: no request a contract accepted before is refused now. These declarations used to boot and now fail at class load, each because something in it could never match — though a list's other, valid members did match before, so a contract such as `in: [1, 2, "three"]` was partly working, not wholly broken: +- a `String` `in:` — it matched substrings; write it as a list, `in: %w[free pro]`; +- a list member the field's type cannot cast (`"three"` in `in: [1, 2, "three"]` on an `:integer`), including `nil` on a field that isn't `nullable:` (the error names `nullable: true`), and a `Time`/`DateTime` member of a `:date` field that is not exactly midnight UTC; +- `in: [nil]` on a `nullable:` field, which lists nothing once the `nil` is dropped (an explicit null needs no `in:`); +- a `Range` whose endpoints the field's values cannot be compared with (`in: "1".."5"` on an `:integer`) — this one did reject every value. + +One exported-docs change for lists that already worked: the `enum` now carries the field's own encoding, so `in: [1.5]` on a `:decimal` exports `"1.5"` (the same precision-safe string its `default:` already exports) and `in: [1, 2]` on a `:float` exports `1.0, 2.0`. ## 0.8.0 (2026-09-19) diff --git a/README.md b/README.md index 6cec00d..440c0fc 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Which options are legal depends on the field kind — anything else raises at cl | Option | Scalar | Array | Nested | Meaning | |---|:---:|:---:|:---:|---| -| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` | +| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field | | `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` | | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) | | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent | @@ -910,7 +910,7 @@ RSpec.describe UsersController do end ``` -Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`). +Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`, cast by the field's type just as the contract's list is, so `within(%i[draft published])` repeats the declaration as written), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`). The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`. @@ -1020,7 +1020,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way | `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` | | `:date` / `:datetime` | `string` + `format: date` / `date-time` | | `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) | -| `in:` Array / numeric Range | `enum` / `minimum` + `maximum` (exclusive ends honoured) | +| `in:` list / numeric Range | `enum` of the cast members (a `:date`/`:datetime` member written as a String is published as written) / `minimum` + `maximum` (exclusive ends honoured). An `in:` object that only answers `include?` is flagged `x-permittable-custom-validation` | | `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays | | `format:` | `pattern`, valid under the `u` flag Ajv compiles with: `\A`/`\z` become `^`/`$`, `\s` and `.` are spelled out as the classes they are in Ruby (ECMA-262's `\s` also matches NBSP and U+2028; its `.` also stops at `\r`), and redundant escapes like `\-` and `\#` are written bare | | `default:` / `desc:` / `example:` | `default` / `description` / `examples` | @@ -1088,7 +1088,8 @@ A bad contract is a programmer error, so it fails when the class loads — never - An unknown `normalize:` or `format:` preset, listing the presets - A `format:` that is neither a `Regexp` nor a preset name - `format:`, `length:`, or `normalize:` on a non-`:string` field -- `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that doesn't respond to `include?` +- `length:` that isn't a non-negative `Integer` or a `Range`; an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?` +- An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion` - A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`) - `validate:` or `transform:` that isn't callable - A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept diff --git a/lib/permittable.rb b/lib/permittable.rb index 623dc45..81dd57f 100644 --- a/lib/permittable.rb +++ b/lib/permittable.rb @@ -865,6 +865,109 @@ def absent_value?(value) value.nil? || (value.is_a?(String) && value.empty?) end + # An `in:` list as the runtime holds it: every member cast by the field's + # own type, because included_in? compares the CAST request value against + # it. Comparing against the members as authored meant `in: %i[draft + # published]` on a :string field (and `in: %w[1 2 3]` on an :integer one) + # held values no cast could ever produce, and rejected every request. + # + # `normalize:` is deliberately not applied — it rewrites what a client + # sent, not what the contract author wrote. Duplicates the cast collapses + # ("1" and 1 on an :integer) are dropped, and a Set stays a Set, so an + # author who chose one for its O(1) include? keeps it. A nil member is + # dropped on a nullable field, where an explicit null is accepted before + # in: is ever consulted; anywhere else it is a member no value can equal, + # and is an error like any other. + # + # `members` is what in_list returned. Returns [:ok, cast, published] — + # `published` being what an exported enum lists, see published_in_member + # — or [:error, offending_member, code]. Shared by ContractBuilder and the + # RSpec matcher's `within` chain so the two cannot read a list differently. + def cast_in_members(type, members, nullable: false) + pairs = [] + members.each do |member| + next if member.nil? && nullable + + status, value = cast_in_member(type, member) + return [:error, member, value] unless status == :ok + + pairs << [value, published_in_member(type, member, value)] + end + pairs = pairs.uniq(&:first) + cast_members = pairs.map(&:first) + [:ok, members.is_a?(Set) ? cast_members.to_set : cast_members, pairs.map(&:last)] + end + + # The members of an `in:` that is a LIST, or nil when it is not one. + # Only Array, Set, Hash and Enumerator count, and only when the object's + # OWN class provides the collection's ordinary include? — not a Hash, + # Array or Set SUBCLASS overriding it (a case-insensitive allowlist, a + # fuzzy Set, a registry matching some other way entirely). `case allowed; + # when Hash ...` matches with ===, which for a Class is is_a?, so a + # subclass would otherwise match its ancestor's branch and have its + # override silently discarded — read for its raw keys/elements instead, + # which can invert which values it actually accepts. It is left opaque + # instead, exactly like any other object whose include? is the point + # (see resolve_in!) and enumerating it may be expensive (a DB-backed + # registry). + # + # A Hash lists its KEYS, which is what Hash#include? asks about — the + # Rails enum idiom, `in: Post.statuses` — and, like a Set, is stored as a + # Set, so membership stays O(1) per request. + # ActiveSupport::HashWithIndifferentAccess is the one Hash subclass + # accepted anyway: its include? override only canonicalises the argument + # (String/Symbol) before the SAME key lookup, so its keys are still + # exactly its members — and it is what a Rails enum's own reader + # (`Post.statuses`) actually returns. + # Enumerator::Lazy is the same story on the Enumerator side: Lazy + # overrides chain methods like map and select, but not include?, so it + # is still read as a list — and forced to an Array here, once, since + # left lazy it would be cast on every request instead of at class load. + def in_list(allowed) + case allowed + when Hash then allowed.keys.to_set if plain_hash?(allowed) + when Set then allowed if allowed.instance_of?(Set) + when Array then allowed.to_a if allowed.instance_of?(Array) + when Enumerator then allowed.to_a if allowed.method(:include?).owner == Enumerable + end + end + + def plain_hash?(allowed) + allowed.instance_of?(Hash) || allowed.instance_of?(ActiveSupport::HashWithIndifferentAccess) + end + + # A Symbol is read as its String: it is how Ruby spells a constant + # string, and a request never carries one, so no cast accepts it as is. + def cast_in_member(type, member) + member = member.to_s if member.is_a?(Symbol) + return instant_as_date(member) if type == :date && (member.is_a?(Time) || member.is_a?(DateTime)) + + cast(type, member) + end + + # A Time or DateTime member of a :date field. ActiveSupport compares one + # with a Date as INSTANTS, the Date standing for its midnight UTC, so + # that instant is the only one that ever equalled a request's date. It + # is read as that UTC date; any other instant never matched anything, + # and is refused like any member no request could equal. (cast_date + # would keep a DateTime whole — it IS a Date — and refuse a Time.) + def instant_as_date(member) + utc = member.to_time.getutc + return [:error, "not midnight UTC, so it never equals a date"] unless utc == utc.beginning_of_day + + [:ok, utc.to_date] + end + + # What an exported enum lists for one member: the cast value, re-encoded + # as JSON — except a :date/:datetime member authored as a String, which + # is published AS WRITTEN. Re-encoding a cast Time prints whole seconds, + # so "2026-09-05T10:00:00.25Z" was published as "…10:00:00Z", a value + # the server refuses. The authored String went through the very cast a + # request does, so the server accepts it by construction. + def published_in_member(type, member, value) + member.is_a?(String) && %i[date datetime].include?(type) ? member : value + end + # Range#include? walks discrete ranges; cover? is the O(1) bounds check # and the right semantics for validation. def included_in?(allowed, value) @@ -888,6 +991,13 @@ class ContractBuilder ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message desc example nullable].freeze + # One value of each scalar type as a cast produces it, for asking whether + # an `in:` Range's endpoints can be compared with that type at all. + RANGE_PROBES = { + string: "", integer: 0, float: 0.0, decimal: BigDecimal("0"), boolean: true, + date: Date.new(2000, 1, 1), datetime: Time.utc(2000) + }.freeze + attr_reader :finalizer def initialize @@ -1050,14 +1160,7 @@ def validate_scalar_opts!(field) raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)" end - if field.key?(:in) - unless field[:in].respond_to?(:include?) - raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)" - end - - assert_satisfiable!(name, :in, field[:in]) - end - + resolve_in!(field) if field.key?(:in) validate_string_only_opts!(field) validate_length!(name, field[:length]) if field.key?(:length) validate_required_length!(field) @@ -1070,6 +1173,96 @@ def validate_scalar_opts!(field) validate_message!(field) end + # `in:` is a Range (bounds-checked with cover?), a list of values, or an + # object of the host's own that answers include? — kept exactly as given, + # since nothing here can know what it accepts. It used to be anything + # answering include?, which let a String through, and String#include? is + # a SUBSTRING test: `in: "free pro"` accepted "e", "fr" and "ee p". A + # String is refused here, along with anything answering neither. + # + # A list (see Coercion.in_list — a Hash lists its keys) is stored cast by + # the field's type (see Coercion.cast_in_members), so request-time + # matching, the exported enum, the RSpec matcher and the column guard's + # enum rule all read the members the runtime compares against. A member + # no request value could ever equal is a contract mistake, and fails here + # rather than as an `inclusion` on every request. + def resolve_in!(field) + name = field[:name] + allowed = field[:in] + if allowed.is_a?(Range) + assert_comparable_range!(field, allowed) + elsif (members = Coercion.in_list(allowed)) + cast_in_members!(field, members) + elsif allowed.is_a?(String) || !allowed.respond_to?(:include?) + raise ArgumentError, "#{LABEL}: :in for field :#{name} must be a Range, a list of values (an Array, Set, " \ + "or a Hash read as its keys), or an object answering include? " \ + "(got #{allowed.inspect})#{string_in_hint(allowed)}" + end + assert_satisfiable!(name, :in, field[:in]) + end + + def string_in_hint(allowed) + return "" unless allowed.is_a?(String) + + " — String#include? would accept any substring; list the values instead, e.g. in: %w[#{allowed}]" + end + + # `published` is stored only where it differs from the cast members (a + # String-authored :date/:datetime member), so it is read as an override. + def cast_in_members!(field, members) + status, cast, published = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable]) + unless status == :ok + # cast is the offending member here, and published its error code. + # nil is the one member written on purpose, meaning "null is allowed" + # — but an absent value never reaches in:, so the fix is worth naming. + hint = cast.nil? ? " — an absent value never reaches in:; declare nullable: true to accept an explicit null" : "" + raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} contains #{cast.inspect}, " \ + "which is not a valid :#{field[:type]} (#{published})#{hint}" + end + + field[:in] = freeze_in_members(cast) + field[:in_published] = freeze_authored(published) unless published == cast.to_a + end + + def freeze_in_members(members) + members.is_a?(Set) ? members.to_set { |member| freeze_authored(member) }.freeze : freeze_authored(members) + end + + # A Range is kept exactly as written, unlike a list: casting its + # endpoints would change what it means. `0..Float::INFINITY` on a :float + # and `1.5..3` on an :integer are real bounds whose endpoints no cast + # accepts, and a :decimal's `0..100` would become BigDecimal endpoints + # that export as the STRING "0.0" where `minimum` needs a number. + # + # What does fail every request is an endpoint the cast value cannot be + # compared with — `"1".."5"` on an :integer, `1..5` on a :string, + # `.."9.99"` on a :decimal. cover? then answers false for every value, so + # that is caught here. The probe asks exactly what cover? will — begin + # <=> value, then value <=> end — so whatever the host's own <=> allows + # (ActiveSupport lets a Date range bound a :datetime) is allowed here too. + def assert_comparable_range!(field, range) + probe = RANGE_PROBES.fetch(field[:type]) + # A NaN endpoint compares to nothing, by design, whatever it stands + # beside — not evidence of a wrong-TYPED bound (a String range on an + # :integer), which is what this check exists to catch. It is left + # alone here exactly as an infinite endpoint already is (INFINITY + # compares fine); the exporter separately omits it, since it is + # never `finite?`. + # Wrapped in an Array so a `false` endpoint still reads as found. + stray = if !range.begin.nil? && !nan?(range.begin) && (range.begin <=> probe).nil? then [range.begin] + elsif !range.end.nil? && !nan?(range.end) && (probe <=> range.end).nil? then [range.end] + end + return unless stray + + raise ArgumentError, "#{LABEL}: :in for field :#{field[:name]} is a Range of #{stray.first.class} " \ + "(#{range.inspect}), which a :#{field[:type]} value cannot be compared with — " \ + "no value could satisfy it; write the bounds as :#{field[:type]} values" + end + + def nan?(value) + value.respond_to?(:nan?) && value.nan? + end + def validate_json_opts!(field) name = field[:name] if field[:required] && field.key?(:default) diff --git a/lib/permittable/json_schema.rb b/lib/permittable/json_schema.rb index 26609ab..1bd54a0 100644 --- a/lib/permittable/json_schema.rb +++ b/lib/permittable/json_schema.rb @@ -117,7 +117,7 @@ def nullify!(schema, field) def scalar_schema(field) schema = SCALAR_SCHEMAS.fetch(field[:type]).dup apply_format_name!(schema, field) - apply_in!(schema, field[:in], type: field[:type]) + apply_in!(schema, field) apply_string_bounds!(schema, field) apply_pattern!(schema, field[:format]) schema @@ -155,11 +155,18 @@ def array_schema(field, unknown:) schema end - def apply_in!(schema, allowed, type:) + # A list is stored cast by the field's type, so its enum is what the + # runtime compares against; `in_published` overrides the members an + # exact re-encoding would get wrong (see Coercion.published_in_member). + # An object that only answers include? says nothing a schema can list — + # annotate flags it as custom validation instead. + def apply_in!(schema, field) + allowed = field[:in] return unless allowed + return if opaque_in?(allowed) unless allowed.is_a?(Range) - schema["enum"] = allowed.map { |v| json_value(v) } + schema["enum"] = (field[:in_published] || allowed).map { |v| json_value(v) } return end # Runtime bounds-checks Ranges with cover?; numeric endpoints map onto @@ -169,6 +176,7 @@ def apply_in!(schema, allowed, type:) schema["x-permittable-range"] = allowed.inspect return end + type = field[:type] min = json_bound(allowed, "minimum", type) if allowed.begin schema["minimum"] = min if min keyword = allowed.exclude_end? ? "exclusiveMaximum" : "maximum" @@ -260,6 +268,12 @@ def admitted_extreme(keyword, type, bound) keyword == "exclusiveMaximum" ? bound.to_f.prev_float : bound end + # A host's own include?-answering object, kept by the contract as given — + # the same predicate the contract used to decide it was not a list. + def opaque_in?(allowed) + !allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil? + end + def apply_string_bounds!(schema, field) return unless field[:type] == :string @@ -321,7 +335,7 @@ def annotate(schema, field) schema["writeOnly"] = true schema["x-permittable-sensitive"] = true end - schema["x-permittable-custom-validation"] = true if field[:validate] + schema["x-permittable-custom-validation"] = true if field[:validate] || opaque_in?(field[:in]) schema["x-permittable-transformed"] = true if field[:transform] apply_normalize!(schema, field) schema @@ -351,13 +365,23 @@ def json_value(value) # the same re-encoding as any other authored scalar. when Hash then value.to_h { |k, v| [k.to_s, json_value(v)] } when BigDecimal then value.to_s("F") - when Time then value.utc.iso8601 + when Time then exact_iso8601(value.getutc) # DateTime subclasses Date, so it must match first. - when DateTime then value.to_time.utc.iso8601 + when DateTime then exact_iso8601(value.to_time.getutc) when Date then value.iso8601 when Symbol then value.to_s else value end end + + # iso8601 prints whole seconds unless told otherwise, and a sub-second + # instant re-encoded that way names a DIFFERENT instant — one an `in:` + # listing the original refuses. So as many fractional digits as the + # value has, up to the nanoseconds Time#nsec can report. + def exact_iso8601(time) + nsec = time.nsec + digits = nsec.zero? ? 0 : 9 - nsec.to_s.rjust(9, "0")[/0*\z/].length + time.iso8601(digits) + end end end diff --git a/lib/permittable/rspec.rb b/lib/permittable/rspec.rb index 7c765ad..f10e9fe 100644 --- a/lib/permittable/rspec.rb +++ b/lib/permittable/rspec.rb @@ -309,6 +309,8 @@ def check_mismatch(field, key, value) when :of then of_mismatch(field, value) when :required then required_mismatch(field, value) when :format then format_mismatch(field, value) + # Compared cast, but reported as written. + when :in then option_mismatch(field, :in, value) unless field.key?(:in) && same_in?(field[:in], cast_in(field, value)) when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key] else option_mismatch(field, key, value) end @@ -351,6 +353,28 @@ def format_mismatch(field, expected) "expected format: :#{expected}, but the contract #{declared_format(field)}" end + # A contract stores an `in:` list cast by the field's type, so + # `within(%i[draft published])` — the declaration repeated as written — + # is read the same way before comparing, by the same two functions the + # contract uses: what counts as a list (a Hash as its keys), then the + # cast. Anything that is not a list, or does not cast, is compared as + # given — a Range and a host's own allowlist are stored as given too. + def cast_in(field, expected) + members = field[:kind] == :scalar && Coercion.in_list(expected) + return expected unless members + + status, cast = Coercion.cast_in_members(field[:type], members, nullable: field[:nullable]) + status == :ok ? cast : expected + end + + # A list's order and container say nothing about what it allows: + # `in: Post.statuses` is stored as a Set, and `within(%w[draft + # published])` names exactly its values. + def same_in?(declared, expected) + lists = [declared, expected].all? { |list| list.is_a?(Array) || list.is_a?(Set) } + lists ? declared.to_set == expected.to_set : declared == expected + end + def declared_format(field) return "declares format: :#{field[:format_name]}" if field[:format_name] return "declares format: #{field[:format].inspect}" if field[:format] diff --git a/spec/json_schema_spec.rb b/spec/json_schema_spec.rb index 65266bf..1501dfc 100644 --- a/spec/json_schema_spec.rb +++ b/spec/json_schema_spec.rb @@ -77,6 +77,86 @@ def quietly expect(property("pct") { optional :pct, :integer, in: 0...100 }).to include("minimum" => 0, "exclusiveMaximum" => 100) end + # The enum is built from the members as the RUNTIME holds them — cast by + # the field's own type at class load — so it cannot advertise a value the + # server refuses, nor publish "1" for a field whose JSON type is integer. + it "exports the cast members, in the field's own JSON type" do + expect(property("status") { optional :status, :string, in: %i[draft published] }["enum"]).to eq(%w[draft published]) + expect(property("n") { optional :n, :integer, in: %w[1 2 3] }["enum"]).to eq([1, 2, 3]) + expect(property("day") { optional :day, :date, in: [Date.new(2026, 9, 5)] }["enum"]).to eq(["2026-09-05"]) + expect(property("status") { optional :status, :string, in: { draft: 0, published: 1 } }["enum"]).to eq(%w[draft published]) + end + + # Re-encoding a cast Time drops what iso8601 does not print: a member + # written with fractional seconds exported as the whole second, which + # the server then refused. A String member is therefore published as + # written, and every published member must be one the server accepts. + it "exports a String-authored :date/:datetime member as written, and the server accepts each one" do + contract = Permittable::Contract.define do + optional :at, :datetime, in: ["2026-09-05T10:00:00.25Z", "2026-09-05T15:00:00+05:00", Time.utc(2026, 1, 1)] + optional :day, :date, in: ["Sep 5, 2026", Date.new(2026, 9, 6)] + end + props = described_class.rule(contract.permittable_contracts.first)["properties"] + expect(props["at"]["enum"]).to eq(["2026-09-05T10:00:00.25Z", "2026-09-05T15:00:00+05:00", "2026-01-01T00:00:00Z"]) + expect(props["day"]["enum"]).to eq(["Sep 5, 2026", "2026-09-06"]) + props.each do |name, schema| + schema["enum"].each do |member| + expect(contract.call(name => member).violations).to be_empty, "#{name}: #{member.inspect} was refused" + end + end + end + + # A Time/DateTime/TimeWithZone member is re-encoded, so it must keep the + # sub-second digits it has — whole seconds named an instant the server + # refused. + it "exports a sub-second Time-like :datetime member with its fractional digits, and the server accepts it" do + members = [Time.utc(2026, 9, 5, 10, 0, Rational(1, 4)), DateTime.new(2026, 9, 5, 11, 0, Rational(123_456_789, 10**9)), + Time.utc(2026, 9, 5, 12).in_time_zone("Tokyo") + Rational(1, 1000), Time.utc(2026, 9, 5, 13)] + contract = Permittable::Contract.define { optional :at, :datetime, in: members } + enum = described_class.rule(contract.rule)["properties"]["at"]["enum"] + expect(enum).to eq(["2026-09-05T10:00:00.25Z", "2026-09-05T11:00:00.123456789Z", + "2026-09-05T12:00:00.001Z", "2026-09-05T13:00:00Z"]) + enum.each { |member| expect(contract.call(at: member).violations).to be_empty, "#{member} was refused" } + end + + it "exports an :in that only answers include? as custom validation, not as an enum" do + allowlist = Object.new + def allowlist.include?(_value) = true + prop = property("sku") { optional :sku, :string, in: allowlist } + expect(prop).not_to have_key("enum") + expect(prop["x-permittable-custom-validation"]).to be(true) + + plans = Class.new do + include Enumerable + + def each(&) = %w[free pro].each(&) + def include?(value) = %w[free pro].include?(value.to_s.downcase) + end.new + prop = property("plan") { optional :plan, :string, in: plans } + expect(prop).not_to have_key("enum") + expect(prop["x-permittable-custom-validation"]).to be(true) + end + + # A Hash/Array/Set subclass overriding include? is opaque exactly like + # the plain-Object and Enumerable allowlists above — its raw contents + # (keys, elements) are not what it actually matches, so no enum can + # honestly be published for it. + it "exports a Hash/Array/Set subclass overriding include? as custom validation too" do + # Non-empty: assert_satisfiable! reads any object's own empty? at + # class load, and this Hash subclass inherits Hash's — unrelated to + # its overridden include?, but a truly empty one would already fail + # that check on its own, before ever reaching the list/opaque split. + registry = Class.new(Hash) { def include?(value) = value.to_s.start_with?("custom-") }.new + registry[:unrelated] = 1 + allowlist = Class.new(Array) { def include?(value) = any? { |c| c.to_s.casecmp?(value.to_s) } }.new(%w[pro]) + fuzzy = Class.new(Set) { def include?(value) = any? { |c| c.to_s.include?(value.to_s) } }.new(%w[pro]) + [registry, allowlist, fuzzy].each do |allowed| + prop = property("sku") { optional :sku, :string, in: allowed } + expect(prop).not_to have_key("enum") + expect(prop["x-permittable-custom-validation"]).to be(true) + end + end + it "emits BigDecimal bounds as JSON numbers, which is all minimum/maximum may be" do # The natural way to bound a price. Routed through the authored-value # re-encoding, the bounds came out as the strings "0.01" / "999.99" — diff --git a/spec/matchers_spec.rb b/spec/matchers_spec.rb index 269efc2..7b4f21e 100644 --- a/spec/matchers_spec.rb +++ b/spec/matchers_spec.rb @@ -65,6 +65,46 @@ def failure_of expect(message).to include("in: 1..5") end + # The contract stores in: members cast by the field's type, so the chain + # casts its own argument the same way: `within` can repeat the declaration + # as written, or name the values the runtime actually holds. + it "checks within against the cast in: members, casting its own argument the same way" do + contract = Permittable::Contract.define do + optional :status, :string, in: %i[draft published] + optional :n, :integer, in: %w[1 2 3] + end + expect(contract).to permit_param(:status).within(%i[draft published]) + expect(contract).to permit_param(:status).within(%w[draft published]) + expect(contract).to permit_param(:n).within([1, 2, 3]) + expect(contract).to permit_param(:n).within(%w[1 2 3]) + + message = failure_of { expect(contract).to permit_param(:n).within(%w[1 2]) } + expect(message).to include('expected in: ["1", "2"], but the contract declares in: [1, 2, 3]') + message = failure_of { expect(contract).to permit_param(:n).within(%w[one]) } + expect(message).to include("declares in: [1, 2, 3]") + end + + it "reads within's argument exactly as the contract reads in: — a Hash as its keys, an allowlist as itself" do + allowlist = Object.new + def allowlist.include?(_value) = true + registry = Class.new(Hash) { def include?(value) = value.to_s.start_with?("custom-") }.new + registry[:unrelated] = 1 + contract = Permittable::Contract.define do + optional :status, :string, in: { draft: 0, published: 1 } + optional :sku, :string, in: allowlist + optional :tier, :string, in: [nil, "pro"], nullable: true + optional :code, :string, in: registry + end + expect(contract).to permit_param(:status).within({ draft: 0, published: 1 }) + expect(contract).to permit_param(:status).within(%w[draft published]) + expect(contract).to permit_param(:sku).within(allowlist) + expect(contract).to permit_param(:tier).within([nil, "pro"]) + # A Hash subclass overriding include? is opaque, so within compares it + # as given — not by casting its keys, which would silently accept the + # wrong values. + expect(contract).to permit_param(:code).within(registry) + end + it "checks required and optional" do expect(controller).to permit_param(:email).for_action(:create).required expect(controller).to permit_param(:age).for_action(:create).optional diff --git a/spec/permittable_spec.rb b/spec/permittable_spec.rb index a8cdc00..a399cae 100644 --- a/spec/permittable_spec.rb +++ b/spec/permittable_spec.rb @@ -87,9 +87,218 @@ def recording_notifications end end - it "rejects :in that does not respond to include?" do + it "rejects an :in that answers neither cover? nor include?" do expect { permittable_class { permit_params(:create) { required :a, :integer, in: 5 } } } - .to raise_error(ArgumentError, /:in for field :a must respond to include\?/) + .to raise_error(ArgumentError, /:in for field :a must be a Range, a list of values .*\(got 5\)/) + end + + # String#include? is a substring test: in: "free pro" accepted "e", "fr" + # and "ee p" as plans. + it "rejects a String :in, which would have matched any substring" do + expect { permittable_class { permit_params(:create) { optional :plan, :string, in: "free pro" } } } + .to raise_error(ArgumentError, /:in for field :plan must be a Range, a list of values .*\(got "free pro"\).*substring/) + end + + # The Rails enum idiom: `in: Post.statuses` is a HashWithIndifferentAccess + # of name => stored value, and Hash#include? asks about its keys. + it "reads a Hash :in as its keys, cast like any list" do + statuses = ActiveSupport::HashWithIndifferentAccess.new(draft: 0, published: 1) + decl = proc do + permit_params(:create) do + optional :status, :string, in: statuses + optional :tier, :string, in: { free: "f", pro: "p" } + end + end + expect(permit({ status: "published", tier: "pro" }, &decl).to_h).to eq("status" => "published", "tier" => "pro") + expect(violations_for({ status: "0", tier: "f" }, &decl).details) + .to eq([{ param: "status", code: "inclusion" }, { param: "tier", code: "inclusion" }]) + # A Set, so membership stays O(1) per request as Hash#include? was. + ins = permittable_class(&decl).permit_rule_for(:create)[:fields].map { |f| f[:in] } + expect(ins).to eq([Set["draft", "published"], Set["free", "pro"]]) + expect(ins).to all(be_frozen) + end + + it "keeps an :in that only answers include? exactly as given, uncast" do + allowlist = Object.new + def allowlist.include?(value) = value.to_s.start_with?("sku-") + decl = proc { permit_params(:create) { optional :sku, :string, in: allowlist } } + expect(permittable_class(&decl).permit_rule_for(:create)[:fields].first[:in]).to be(allowlist) + expect(permit({ sku: "sku-1" }, &decl)[:sku]).to eq("sku-1") + expect(violations_for({ sku: "abc" }, &decl).details).to eq([{ param: "sku", code: "inclusion" }]) + end + + # Only Array, Set, Hash and Enumerator are lists. An app's own Enumerable + # with its own include? — a case-insensitive allowlist, a DB-backed + # registry — is used as given: never enumerated at class load, never + # replaced by an exact-match copy. + it "keeps an app's own Enumerable that defines include? as given, never enumerating it" do + plans = Class.new do + include Enumerable + + def each = raise("enumerated at class load") + def include?(value) = %w[free pro].include?(value.to_s.downcase) + end.new + decl = proc { permit_params(:create) { optional :plan, :string, in: plans } } + expect(permittable_class(&decl).permit_rule_for(:create)[:fields].first[:in]).to be(plans) + expect(permit({ plan: "PRO" }, &decl)[:plan]).to eq("PRO") + expect(violations_for({ plan: "gold" }, &decl).details).to eq([{ param: "plan", code: "inclusion" }]) + end + + # A Hash/Array/Set SUBCLASS overriding include? is the same story as the + # Enumerable above, by CLASS rather than by module: `case allowed; when + # Hash ...` matches with ===, which for a Class is is_a? — so a subclass + # matched the branch for its ancestor and had its override silently + # discarded, reading its raw contents (keys, elements) instead and + # inverting which values it actually accepts. Each is kept exactly as + # given, like any other object whose include? is the point. + it "keeps a Hash subclass's own include?, not its keys, when the override differs from Hash's" do + registry = Class.new(Hash) do + def include?(value) = value.to_s.start_with?("custom-") + end.new + registry[:unrelated] = 1 + decl = proc { permit_params(:create) { optional :sku, :string, in: registry } } + expect(permittable_class(&decl).permit_rule_for(:create)[:fields].first[:in]).to be(registry) + expect(permit({ sku: "custom-1" }, &decl)[:sku]).to eq("custom-1") + expect(violations_for({ sku: "unrelated" }, &decl).details).to eq([{ param: "sku", code: "inclusion" }]) + end + + it "keeps an Array subclass's own include?, not its elements" do + allowlist = Class.new(Array) do + def include?(value) = any? { |candidate| candidate.to_s.casecmp?(value.to_s) } + end.new(%w[free pro]) + decl = proc { permit_params(:create) { optional :plan, :string, in: allowlist } } + expect(permittable_class(&decl).permit_rule_for(:create)[:fields].first[:in]).to be(allowlist) + expect(permit({ plan: "PRO" }, &decl)[:plan]).to eq("PRO") + expect(violations_for({ plan: "gold" }, &decl).details).to eq([{ param: "plan", code: "inclusion" }]) + end + + it "keeps a Set subclass's own include?, not its elements" do + fuzzy = Class.new(Set) do + def include?(value) = any? { |candidate| candidate.to_s.include?(value.to_s) } + end.new(%w[free pro]) + decl = proc { permit_params(:create) { optional :plan, :string, in: fuzzy } } + expect(permittable_class(&decl).permit_rule_for(:create)[:fields].first[:in]).to be(fuzzy) + expect(permit({ plan: "p" }, &decl)[:plan]).to eq("p") + expect(violations_for({ plan: "gold" }, &decl).details).to eq([{ param: "plan", code: "inclusion" }]) + end + + # A plain Hash's keys are still cast to a Set for O(1) membership, and + # HashWithIndifferentAccess — a Hash SUBCLASS — is the one deliberate + # exception to the rule above: its include? override only canonicalises + # the argument (String/Symbol) before the same key lookup, so its keys + # are still exactly its members. It is what a Rails enum's own reader + # (`Post.statuses`) actually returns. + it "still reads a plain Hash and a HashWithIndifferentAccess as their keys" do + decl = proc do + permit_params(:create) do + optional :status, :string, in: { draft: 0, published: 1 } + optional :tier, :string, in: ActiveSupport::HashWithIndifferentAccess.new(free: "f", pro: "p") + end + end + fields = permittable_class(&decl).permit_rule_for(:create)[:fields] + expect(fields.map { |f| f[:in] }).to eq([Set["draft", "published"], Set["free", "pro"]]) + end + + # The approved snapshot: a list is cast once, so a later `PLANS << "gold"` + # is not seen. An app that needs a live list passes its own include? + # object, which is read on every request. + it "snapshots an Array :in at class load" do + plans = %w[free pro] + klass = permittable_class { permit_params(:create) { optional :plan, :string, in: plans } } + plans << "gold" + e = klass.new(params: { plan: "gold" }) + e.define_singleton_method(:action_name) { "create" } + expect(e.permittable_violations).to eq([{ param: "plan", code: "inclusion" }]) + end + + # A lazy list left lazy was cast per request, and the cast's early return + # escaped its block there as a LocalJumpError — a 500. + it "forces a lazy :in to a list once, at class load" do + decl = proc { permit_params(:create) { optional :n, :integer, in: %w[1 2 3].lazy.map(&:itself) } } + field = permittable_class(&decl).permit_rule_for(:create)[:fields].first + expect(field[:in]).to eq([1, 2, 3]).and be_frozen + expect(permit({ n: "2" }, &decl)[:n]).to eq(2) + expect(violations_for({ n: "4" }, &decl).details).to eq([{ param: "n", code: "inclusion" }]) + expect { permittable_class { permit_params(:create) { optional :n, :integer, in: %w[1 x].lazy.map(&:itself) } } } + .to raise_error(ArgumentError, /:in for field :n contains "x"/) + end + + # ActiveSupport compares a Time (or DateTime) with a Date as instants, + # the Date standing for its midnight UTC — so that instant was the only + # one that ever matched. It is read as that UTC date; any other instant + # never matched a request, and fails like any never-matching member. + it "reads a Time or DateTime member of a :date field as its date only at midnight UTC" do + decl = proc do + permit_params(:create) do + optional :day, :date, in: [Time.utc(2026, 9, 5), DateTime.new(2026, 9, 6, 5, 0, 0, "+05:00")] + end + end + expect(permittable_class(&decl).permit_rule_for(:create)[:fields].first[:in]) + .to eq([Date.new(2026, 9, 5), Date.new(2026, 9, 6)]) + expect(permit({ day: "2026-09-05" }, &decl)[:day]).to eq(Date.new(2026, 9, 5)) + expect(permit({ day: "2026-09-06" }, &decl)[:day]).to eq(Date.new(2026, 9, 6)) + + [Time.utc(2026, 9, 5, 10), Time.new(2026, 9, 5, 0, 0, 0, "+05:00"), DateTime.new(2026, 9, 6, 23)].each do |member| + expect { permittable_class { permit_params(:create) { optional :day, :date, in: [member] } } } + .to raise_error(ArgumentError, /:in for field :day contains .*, which is not a valid :date \(not midnight UTC/) + end + end + + # On a nullable field an explicit null is accepted before in: is ever + # consulted, so a nil member only restates that; elsewhere it is a member + # no request could equal. + it "drops a nil :in member on a nullable field, and refuses it on any other" do + decl = proc { permit_params(:create) { optional :tier, :string, in: [nil, "pro"], nullable: true } } + expect(permittable_class(&decl).permit_rule_for(:create)[:fields].first[:in]).to eq(["pro"]) + expect(permit({ tier: "pro" }, &decl)[:tier]).to eq("pro") + expect(permit({ tier: nil }, &decl).to_h).to eq("tier" => nil) + expect { permittable_class { permit_params(:create) { optional :tier, :string, in: [nil, "pro"] } } } + .to raise_error(ArgumentError, /:in for field :tier contains nil.*declare nullable: true/) + end + + it "rejects an :in member that the field's own type cannot cast" do + expect { permittable_class { permit_params(:create) { optional :n, :integer, in: %w[1 two] } } } + .to raise_error(ArgumentError, /:in for field :n contains "two", which is not a valid :integer \(invalid_type\)/) + expect { permittable_class { permit_params(:create) { optional :day, :date, in: ["2026-02-30"] } } } + .to raise_error(ArgumentError, /:in for field :day contains "2026-02-30", which is not a valid :date/) + end + + it "rejects an :in Range whose endpoints the field's values cannot be compared with" do + expect { permittable_class { permit_params(:create) { optional :n, :integer, in: "1".."5" } } } + .to raise_error(ArgumentError, /:in for field :n is a Range of String \("1"\.\."5"\), which a :integer value cannot be compared/) + expect { permittable_class { permit_params(:create) { optional :s, :string, in: 1..5 } } } + .to raise_error(ArgumentError, /:in for field :s is a Range of Integer/) + expect { permittable_class { permit_params(:create) { optional :price, :decimal, in: .."9.99" } } } + .to raise_error(ArgumentError, /:in for field :price is a Range of String/) + end + + it "accepts a Range whose endpoints compare with the field's values, without rewriting it" do + decl = proc do + permit_params(:create) do + optional :ratio, :float, in: 0..Float::INFINITY + optional :price, :decimal, in: 0..100 + optional :n, :integer, in: 1.5..3 + optional :day, :date, in: (Date.new(2026, 1, 1)..) + # ActiveSupport teaches Date#<=> to compare with a Time. + optional :at, :datetime, in: (Date.new(2026, 1, 1)..) + end + end + fields = permittable_class(&decl).permit_rule_for(:create)[:fields] + expect(fields.map { |f| f[:in] }) + .to eq([0..Float::INFINITY, 0..100, 1.5..3, (Date.new(2026, 1, 1)..), (Date.new(2026, 1, 1)..)]) + end + + # A NaN endpoint compares to nothing, by design — whatever it stands + # beside, not just the field's own values — so it is not evidence of a + # wrong-TYPED bound (a String range on an :integer) the way this check + # otherwise exists to catch. It loads exactly like an infinite endpoint + # already does; the exporter separately omits it, since it is never + # `finite?`. + it "accepts a NaN endpoint rather than reading it as an incomparable type" do + expect { permittable_class { permit_params(:create) { optional :x, :float, in: Float::NAN.. } } } + .not_to raise_error + expect { permittable_class { permit_params(:create) { optional :x, :decimal, in: ..BigDecimal("NaN") } } } + .not_to raise_error end it "rejects a bound no value could satisfy, rather than failing every request" do @@ -789,6 +998,41 @@ def in_encoding(bytes, encoding) = bytes.dup.force_encoding(encoding) expect(e.details.first[:code]).to eq("inclusion") end + # The members used to be compared as authored against the CAST value, so + # a :string field listing Symbols rejected every request — while its + # exported enum, which stringifies Symbols, advertised the very values it + # refused. + it "casts in: members with the field's own type, so Symbols work on a :string field" do + decl = proc { permit_params(:create) { optional :status, :string, in: %i[draft published], default: "draft" } } + expect(permit({ status: "published" }, &decl)[:status]).to eq("published") + expect(permit({}, &decl)[:status]).to eq("draft") + expect(violations_for({ status: "archived" }, &decl).details).to eq([{ param: "status", code: "inclusion" }]) + end + + it "casts String in: members on an :integer field, and any listed spelling on a :date field" do + decl = proc { permit_params(:create) { optional :n, :integer, in: %w[1 2 3] } } + expect(permit({ n: "2" }, &decl)[:n]).to eq(2) + expect(permit({ n: 3 }, &decl)[:n]).to eq(3) + expect(violations_for({ n: "4" }, &decl).details).to eq([{ param: "n", code: "inclusion" }]) + + decl = proc { permit_params(:create) { optional :day, :date, in: ["2026-09-05", Date.new(2026, 9, 6)] } } + expect(permit({ day: "Sep 5, 2026" }, &decl)[:day]).to eq(Date.new(2026, 9, 5)) + expect(permit({ day: "2026-09-06" }, &decl)[:day]).to eq(Date.new(2026, 9, 6)) + end + + it "stores the cast members frozen, deduplicated, and in the container they were given in" do + decl = proc do + permit_params(:create) do + optional :n, :integer, in: ["1", 1, "01", 2] + optional :tier, :string, in: Set[:free, :pro] + end + end + n, tier = permittable_class(&decl).permit_rule_for(:create)[:fields] + expect(n[:in]).to eq([1, 2]).and be_frozen + expect(tier[:in]).to eq(Set["free", "pro"]).and be_frozen + expect(tier[:in]).to all(be_frozen) + end + it "checks format on strings" do decl = proc { permit_params(:create) { required :zip, :string, format: /\A\d{5}\z/ } } expect(permit({ zip: "12345" }, &decl)[:zip]).to eq("12345") @@ -2691,6 +2935,15 @@ def duck_model(columns, enums: nil) expect(&declaring(m) { optional :status, :string, in: %w[pending] }).not_to raise_error end + # The guard reads the in: the contract stores — a Hash already read + # as its keys, Symbols already cast to the Strings a request sends — + # so it agrees with what the field will actually accept. + it "accepts the enum's own mapping, or its names as Symbols, as the in:" do + m = enum_model + expect(&declaring(m) { optional :status, :string, in: m.statuses }).not_to raise_error + expect(&declaring(m) { optional :status, :string, in: %i[pending shipped] }).not_to raise_error + end + it "requires the in: — without it, an unknown name would pass and then raise on assignment" do expect(&declaring(enum_model) { optional :status, :string }).to raise_error(ArgumentError) do |e| expect(e.message).to match(/'status' is an enum on EnumThing/) diff --git a/spec/schema_conformance_spec.rb b/spec/schema_conformance_spec.rb index 50a8cd5..51b85c5 100644 --- a/spec/schema_conformance_spec.rb +++ b/spec/schema_conformance_spec.rb @@ -115,6 +115,31 @@ module SchemaConformance [{ "plan" => nil }, :null_is_absence] ] }, + "enums authored in another type than the field's" => { + # Symbols on a :string field and Strings on an :integer field. Both used + # to reject EVERY request while the exported enum advertised values + # the server refused — the exact disagreement this spec exists to catch. + contract: proc { + optional :status, :string, in: %i[draft published] + optional :n, :integer, in: %w[1 2 3] + }, + payloads: [ + [{ "status" => "draft" }, :agree], + [{ "status" => "archived" }, :agree], + [{ "n" => 2 }, :agree], + [{ "n" => 4 }, :agree], + [{ "n" => "2" }, :coerced_encoding] + ] + }, + "a :datetime enum written with fractional seconds" => { + # Re-encoding the cast Time printed whole seconds, publishing a member + # the server refused; the String is now published as written. + contract: proc { optional :at, :datetime, in: ["2026-09-05T10:00:00.25Z"] }, + payloads: [ + [{ "at" => "2026-09-05T10:00:00.25Z" }, :agree], + [{ "at" => "2026-09-05T10:00:00Z" }, :agree] + ] + }, "an exclusive range" => { contract: proc { optional :pct, :integer, in: 0...100 }, payloads: [[{ "pct" => 0 }, :agree], [{ "pct" => 99 }, :agree], [{ "pct" => 100 }, :agree]]