AK b8e3089ae7
Make plugin API validation errors precise and crash-safe (#10667)
Schema validation reported a generic "Invalid data" message. Report the expected schema and the received value, with a bounded cycle-safe renderer so the error path cannot itself crash.

Fixes #10072

Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
2026-07-22 09:29:19 +02:00

292 lines
14 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.plugins.utils-test
(:require
[app.common.files.tokens :as cfo]
[app.common.schema :as sm]
[app.plugins.utils :as plugins.utils]
[app.util.i18n :as i18n]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]))
;; The plugin error renderer goes through `tr`, which returns the bare
;; translation code when no locale data is loaded. Load the strings this
;; namespace asserts on so the assertions exercise the real rendering.
(i18n/set-default-translations
#js {"plugins.validation.message" "Field %s is invalid: %s"
"plugins.validation.invalid-value" "expected %s, got %s (%s)"
"plugins.validation.received-value" "got %s (%s)"
"errors.invalid-data" "Invalid data"
"errors.field-missing" "Missing field"
"errors.tokens.empty-input" "Empty input"})
;; Access the private flattener for direct testing.
(def ^:private flatten-error-map @#'plugins.utils/flatten-error-map)
(t/deftest test-flatten-error-map-flat-input
;; Regression test for issue #9417.
;;
;; When a malli error path has a single element, `interpret-schema-problem`
;; produces a flat map. The flattener must pass that through unchanged.
(let [result (flatten-error-map {:group {:message "must be string"}})]
(t/is (= [["group" "must be string"]] result))))
(t/deftest test-flatten-error-map-nested-input
;; Regression test for issue #9417.
;;
;; When a malli error path has multiple elements, `interpret-schema-problem`
;; produces a nested map via `(assoc-in acc path …)`. The old plugin
;; consumer destructured assuming a flat shape, so the nested case rendered
;; the message text as the field name (`Field message is invalid`) instead
;; of the real validation reason. The flattener must descend until it finds
;; a leaf carrying a `:message`.
(let [explain {:sets {0 {:name {:message "must not be empty"}}}}
result (set (flatten-error-map explain))]
(t/is (= #{["sets.0.name" "must not be empty"]} result))))
(t/deftest test-flatten-error-map-multiple-fields
;; Multiple validation problems on the same explain produce multiple
;; entries, none of which clobber each other.
(let [explain {:group {:message "must be string"}
:sets {0 {:message "set not found"}}}
result (set (flatten-error-map explain))]
(t/is (= #{["group" "must be string"]
["sets.0" "set not found"]} result))))
(t/deftest test-flatten-error-map-mixed-key-types
;; Numeric indices (from vector positions in the malli path) must render
;; cleanly; string keys must also be accepted alongside keywords.
(let [explain {:items {2 {"label" {:message "invalid label"}}}}
[[path message]] (flatten-error-map explain)]
(t/is (= "items.2.label" path))
(t/is (= "invalid label" message))))
(t/deftest test-flatten-error-map-empty
;; No validation errors -> no output (callers join with ". " and would
;; otherwise emit an empty string, which is fine).
(t/is (empty? (flatten-error-map {}))))
;; ---------------------------------------------------------------------
;; Issue #9692 — `handle-error` must surface a useful message instead of a
;; bare "Value not valid. Code: :error".
;;
;; `not-valid` is redefined to capture the rendered message directly, so the
;; assertions don't depend on `st/state` or the console.
(t/deftest test-handle-error-plain-js-error
;; A plain JS error has no `::sm/explain` and CLJS `ex-data` returns nil, so
;; the handler must fall back to the error's own message rather than nil.
(let [captured (atom ::none)]
(with-redefs [plugins.utils/not-valid (fn [_plugin-id _code value]
(reset! captured value) nil)]
((plugins.utils/handle-error #uuid "00000000-0000-0000-0000-000000000000")
(js/Error. "boom: not a function")))
(t/is (= "boom: not a function" @captured))))
(t/deftest test-handle-error-empty-explain
;; An explain whose errors don't render any message must not produce an
;; empty string; the handler falls back to the raw explain.
(let [captured (atom ::none)
cause (ex-info "invalid" {:app.common.schema/explain {:errors [] :value 1}})]
(with-redefs [plugins.utils/not-valid (fn [_plugin-id _code value]
(reset! captured value) nil)]
((plugins.utils/handle-error #uuid "00000000-0000-0000-0000-000000000000") cause))
(t/is (string? @captured))
(t/is (not= "" @captured))))
(t/deftest test-error-messages-empty-returns-nil
;; `error-messages` returns nil (not "") on an explain with no mappable
;; errors, so `handle-error` can distinguish "no message" from a real one.
(t/is (nil? (plugins.utils/error-messages {:errors []}))))
;; ---------------------------------------------------------------------
;; Issue #10072 — schema validation errors must say what was expected and
;; what was received, instead of collapsing into a generic "Invalid data".
(t/deftest test-error-messages-reports-expected-and-received
(let [explain (sm/explain [:map [:value :string]] {:value 16})
message (plugins.utils/error-messages explain)]
(t/is (= "Field value is invalid: expected string, got number (16)" message))))
(t/deftest test-error-messages-reports-nested-field-path
(let [explain (sm/explain [:map [:sets [:vector [:map [:name :string]]]]]
{:sets [{:name true}]})
message (plugins.utils/error-messages explain)]
(t/is (= "Field sets.0.name is invalid: expected string, got boolean (true)" message))))
(t/deftest test-error-messages-honors-custom-schema-message
;; A schema that declares its own `:error/message` must keep it; the
;; expected/received rendering is only a fallback.
(let [explain (sm/explain [:map [:value [:string {:error/message "must not be empty"}]]]
{:value 16})
message (plugins.utils/error-messages explain)]
(t/is (= "Field value is invalid: must not be empty" message))))
(t/deftest test-error-messages-missing-key
;; Missing keys keep the "field missing" message: there is no received
;; value to report.
(let [explain (sm/explain [:map [:value :string]] {})
message (plugins.utils/error-messages explain)]
(t/is (= "Field value is invalid: Missing field" message))))
(t/deftest test-error-messages-never-generic
(let [explain (sm/explain [:map [:value :string]] {:value 16})
message (plugins.utils/error-messages explain)]
(t/is (not (str/includes? message "Invalid data")))))
(t/deftest test-error-messages-truncates-long-values
(let [value (apply str (repeat 500 "a"))
explain (sm/explain [:map [:value :int]] {:value value})
message (plugins.utils/error-messages explain)]
(t/is (str/includes? message "got string"))
(t/is (< (count message) 200))))
(t/deftest test-error-messages-unicode-value
(let [explain (sm/explain [:map [:value :int]] {:value "ñ😀"})
message (plugins.utils/error-messages explain)]
(t/is (str/includes? message "ñ😀"))))
(t/deftest test-error-messages-collection-value
(let [explain (sm/explain [:map [:value :string]] {:value [1 2]})
message (plugins.utils/error-messages explain)]
(t/is (str/includes? message "got array"))))
(defn- proxy? [value] (and (map? value) (contains? value :proxy)))
(defn- well-formed?
"False when the string contains a lone UTF-16 surrogate."
[s]
(try
(js/encodeURIComponent s)
true
(catch :default _ false)))
(t/deftest test-error-messages-union-reports-every-branch
;; Malli emits one problem per `:or` branch, all sharing the same path.
;; Reporting a single branch as if it were the only requirement is worse
;; than saying nothing: `plugins.tokens` declares the token value setter
;; as `[:or :string base]`, so a plugin author would be told a vector is
;; required when a plain string is equally valid.
(let [explain (sm/explain [:map [:value [:or [:vector :string] :string]]] {:value 16})
message (plugins.utils/error-messages explain)]
(t/is (= "Field value is invalid: expected [:vector :string] or string, got number (16)"
message))))
(t/deftest test-error-messages-union-reports-every-branch-nested
(let [explain (sm/explain [:map [:sets [:vector [:or :string :int]]]] {:sets [true]})
message (plugins.utils/error-messages explain)]
(t/is (= "Field sets.0 is invalid: expected string or int, got boolean (true)"
message))))
(t/deftest test-error-messages-never-leaks-function-objects
;; `[:fn pred]` schemas (used by the token-set arguments) render their form
;; as `#object[app$plugins$tokens$token_set_proxy_QMARK_]`. That must never
;; reach a plugin author.
(let [explain (sm/explain [:map [:value [:or [:fn proxy?] :string]]] {:value 16})
message (plugins.utils/error-messages explain)]
(t/is (not (str/includes? message "#object")))
(t/is (= "Field value is invalid: got number (16)" message))))
(t/deftest test-error-messages-function-schema-falls-back-to-custom-message
;; When one branch of the union carries its own message, that message wins
;; over the un-renderable `[:fn …]` branch.
(let [explain (sm/explain [:map [:value [:or [:fn proxy?] ::sm/uuid]]] {:value "nope"})
message (plugins.utils/error-messages explain)]
(t/is (not (str/includes? message "#object")))
(t/is (= "Field value is invalid: should be an uuid" message))))
(t/deftest test-error-messages-truncation-keeps-surrogate-pairs
;; Truncating at a UTF-16 code-unit boundary splits astral characters and
;; produces mojibake in a message whose whole purpose is legibility.
(let [value (apply str (repeat 200 "😀"))
explain (sm/explain [:map [:value :int]] {:value value})
message (plugins.utils/error-messages explain)]
(t/is (well-formed? message))
(t/is (str/includes? message "…"))))
(t/deftest test-error-messages-truncation-counts-code-points
;; 60 emoji are 120 UTF-16 code units but only 60 code points: below the
;; limit, so the value must be reported whole.
(let [value (apply str (repeat 60 "😀"))
explain (sm/explain [:map [:value :int]] {:value value})
message (plugins.utils/error-messages explain)]
(t/is (well-formed? message))
(t/is (str/includes? message value))))
;; ---------------------------------------------------------------------
;; The value reported back to the plugin author is arbitrary JS data, so
;; rendering it must terminate and must not run plugin code.
(t/deftest test-error-messages-self-referencing-object
;; A plugin argument is a native JS value, and an object graph with a back
;; reference (`a.parent.child === a`) is ordinary. `pr-str` walks it forever,
;; so the error renderer would throw a RangeError from inside the error
;; handler itself: the author gets a stack overflow instead of a validation
;; error.
(let [a #js {}
b #js {}
_ (unchecked-set a "parent" b)
_ (unchecked-set b "child" a)
explain (sm/explain [:map [:value :string]] {:value a})
message (plugins.utils/error-messages explain)]
(t/is (str/includes? message "got object"))))
(t/deftest test-error-messages-object-with-getters
;; Penpot's own proxies expose their contents through getters that read the
;; application state and may throw for a stale id. Rendering a value must
;; never invoke them.
(let [called (atom 0)
value (js/Object.defineProperty
#js {:id "x"} "children"
#js {:enumerable true
:get (fn [] (swap! called inc) (throw (js/Error. "boom")))})
explain (sm/explain [:map [:value :string]] {:value value})
message (plugins.utils/error-messages explain)]
(t/is (str/includes? message "got object"))
(t/is (zero? @called))))
(t/deftest test-error-messages-deeply-nested-object
;; A deep graph must not produce an unbounded message.
(let [value (reduce (fn [acc _] #js {:child acc}) #js {:leaf 1} (range 200))
explain (sm/explain [:map [:value :string]] {:value value})
message (plugins.utils/error-messages explain)]
(t/is (str/includes? message "got object"))
(t/is (< (count message) 200))))
;; ---------------------------------------------------------------------
;; Issue #10072 — the reported example: `addToken` with a wrong token value.
;;
;; The token value schemas declare an `:error/fn` that only speaks about empty
;; values, so it returns nil for a wrong-typed one and `interpret-schema-problem`
;; degrades to the generic "Invalid data". A message that says nothing must not
;; win over one that says what was expected and what was received.
(t/deftest test-error-messages-token-value-numeric
(let [schema [:map [:value (cfo/make-token-value-schema :font-size)]]
message (plugins.utils/error-messages (sm/explain schema {:value 16}))]
(t/is (= "Field value is invalid: expected string, got number (16)" message))))
(t/deftest test-error-messages-token-value-opacity
(let [schema [:map [:value (cfo/make-token-value-schema :opacity)]]
message (plugins.utils/error-messages (sm/explain schema {:value true}))]
(t/is (= "Field value is invalid: expected string, got boolean (true)" message))))
(t/deftest test-error-messages-token-value-font-family
;; Both branches of the union are printable, so both are reported.
(let [schema [:map [:value (cfo/make-token-value-schema :font-family)]]
message (plugins.utils/error-messages (sm/explain schema {:value 16}))]
(t/is (= (str "Field value is invalid: "
"expected [:vector :app.common.schema/text] or TokenRef, got number (16)")
message))))
(t/deftest test-error-messages-token-value-empty-keeps-custom-message
;; When the schema's own `:error/fn` does render a message, it still wins.
(let [schema [:map [:value (cfo/make-token-value-schema :font-size)]]
message (plugins.utils/error-messages (sm/explain schema {:value ""}))]
(t/is (= "Field value is invalid: Empty input" message))))