mirror of
https://github.com/penpot/penpot.git
synced 2026-10-03 01:06:14 +00:00
* ✨ Add size limits to profile props and plugin registry Bound the total serialized size of profile settings to 2 MiB (:profile-props-max-size), checked on the merged result before persisting, with a controlled :props-too-large error. Profiles that already exceed the limit can still shrink but cannot grow. Cap plugin registry entries in the shared schema (code 1 MiB, 50 plugins max, bounded name/host/description/icon) and restore rate limiting on the plugin RPCs (profile-mutations bucket, one write at a time per profile). The plugin manager now asks for confirmation before removal and ignores repeated clicks while a persist request is in flight. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Enforce plugin count cap, byte sizes and removal guard Enforce the declared 50-plugin cap in add-profile-plugin with a specific :too-many-plugins error (updates of existing entries still pass); the cap lives in a shared max-plugins constant. Measure profile props size in UTF-8 bytes instead of chars so multibyte content cannot slip past the limit. Cover install/remove persist logic with mocked-RPC frontend tests (release semantics, in-flight dedupe, validation vs rollback split) and add the missing boundary tests in common. Expose the in-flight persist set from the plugin registry and disable the remove button of entries being saved. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix rollback loops and restore paths in plugin registry Restore the previous plugin version instead of dropping the entry when a validation error rejects an update of an installed plugin. Make compensating writes one-shot with terminal callbacks so a persistent failure cannot ping-pong between install and remove. Restores keep the original list position; the unused public plugin-persisting? predicate is removed. Pin count-before-size precedence with a dedicated test and fix translation source refs to their canonical lines. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Guard notifications write and fix restore ordering Route update-profile-notifications through check-props-size! so oversized profiles cannot grow through that path; document the exempt system writers. Remove the duplicated stale entries in en.po, keeping the canonical translation refs. Restore rejected plugin updates at their original list position instead of leaving the optimistic move in place. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Skip no-op plugin removal and clarify size comments Return early from remove-profile-plugin when the id is absent: no wasted write, no size check, and no manufactured :plugins key that could spuriously fail on oversized profiles. Clarify that per-field string caps count chars while the byte budget is enforced by profile-props-max-size. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 📎 Fix formatting in rlimit.edn for profile operations Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📎 Fix formatting of import-binfile/global entry Signed-off-by: Andrey Antukh <niwi@niwi.nz> * ♻️ Simplify props size check and tighten plugin entry caps Measure props with transit bytes directly instead of the PGobject string roundtrip. Rename check-props-size! to check-props-size: single hard limit on the merged props, no growth comparison, and return props so writers thread the check into the update. Move the 2 MiB default into default-props-max-size on the profile namespace, still overridable with the optional :profile-props-max-size config entry. Tighten registry-entry :code and :icon to 500 chars: they hold manifest paths, not content. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix compatibility problems --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz> Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
384 lines
17 KiB
Clojure
384 lines
17 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 SUBSIDIARY SL
|
|
|
|
(ns backend-tests.rpc-plugins-test
|
|
(:require
|
|
[app.common.schema :as sm]
|
|
[app.common.types.plugins :as ctp]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.commands.profile :as profile]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.test :as t]))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(def ^:private plugin-id-1 (str (uuid/next)))
|
|
(def ^:private plugin-id-2 (str (uuid/next)))
|
|
|
|
(def ^:private valid-plugin
|
|
{:plugin-id plugin-id-1
|
|
:name "Test Plugin"
|
|
:description "A test plugin"
|
|
:host "https://example.com"
|
|
:code "(function() { console.log('hello'); })()"
|
|
:icon "icon.svg"
|
|
:permissions #{"content:read" "content:write"}})
|
|
|
|
(t/deftest add-profile-plugin-accepts-valid-permissions
|
|
(let [profile (th/create-profile* 1)
|
|
data {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin valid-plugin}
|
|
out (th/command! data)]
|
|
|
|
(t/is (nil? (:error out)))
|
|
(t/is (some? (:result out)))
|
|
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)
|
|
plugins (get-in props [:props :plugins])]
|
|
(t/is (= [plugin-id-1] (:ids plugins)))
|
|
(t/is (= valid-plugin (get-in plugins [:data plugin-id-1]))))))
|
|
|
|
(t/deftest add-profile-plugin-rejects-invalid-permissions
|
|
(let [profile (th/create-profile* 1)
|
|
plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"})
|
|
data {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin}
|
|
out (th/command! data)]
|
|
|
|
;; Schema validation catches invalid permissions before custom validation
|
|
(t/is (th/ex-info? (:error out)))
|
|
(t/is (th/ex-of-type? (:error out) :validation))
|
|
(t/is (th/ex-of-code? (:error out) :params-validation))
|
|
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)
|
|
plugins (get-in props [:props :plugins])]
|
|
(t/is (nil? plugins) "No plugins should be persisted when validation fails"))))
|
|
|
|
(t/deftest add-profile-plugin-updates-existing-plugin
|
|
(let [profile (th/create-profile* 1)
|
|
data1 {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin valid-plugin}
|
|
_ (th/command! data1)
|
|
|
|
updated-plugin (assoc valid-plugin :name "Updated Plugin")
|
|
data2 {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin updated-plugin}
|
|
out (th/command! data2)]
|
|
|
|
(t/is (nil? (:error out)))
|
|
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)
|
|
plugins (get-in props [:props :plugins])]
|
|
(t/is (= 1 (count (:ids plugins))) "Should still have only one plugin")
|
|
(t/is (= "Updated Plugin" (get-in plugins [:data plugin-id-1 :name]))))))
|
|
|
|
(t/deftest remove-profile-plugin-removes-plugin
|
|
(let [profile (th/create-profile* 1)
|
|
data1 {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin valid-plugin}
|
|
_ (th/command! data1)
|
|
|
|
data2 {::th/type :remove-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin-id (uuid/uuid plugin-id-1)}
|
|
out (th/command! data2)]
|
|
|
|
(t/is (nil? (:error out)))
|
|
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)
|
|
plugins (get-in props [:props :plugins])]
|
|
(t/is (= [] (:ids plugins)))
|
|
(t/is (empty? (:data plugins))))))
|
|
|
|
(t/deftest remove-profile-plugin-handles-nonexistent-plugin
|
|
(let [profile (th/create-profile* 1)
|
|
data {::th/type :remove-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin-id (uuid/next)}
|
|
out (th/command! data)]
|
|
|
|
(t/is (nil? (:error out)))
|
|
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)
|
|
plugins (get-in props [:props :plugins])]
|
|
(t/is (or (nil? plugins)
|
|
(and (empty? (:ids plugins))
|
|
(empty? (:data plugins))))
|
|
"Plugins should be nil or empty when no plugins exist"))))
|
|
|
|
(t/deftest add-profile-plugin-multiple-plugins
|
|
(let [profile (th/create-profile* 1)
|
|
plugin1 valid-plugin
|
|
plugin2 (assoc valid-plugin
|
|
:plugin-id plugin-id-2
|
|
:name "Second Plugin")
|
|
|
|
data1 {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin1}
|
|
_ (th/command! data1)
|
|
|
|
data2 {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin2}
|
|
_ (th/command! data2)]
|
|
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)
|
|
plugins (get-in props [:props :plugins])]
|
|
(t/is (= 2 (count (:ids plugins))))
|
|
(t/is (contains? (set (:ids plugins)) plugin-id-1))
|
|
(t/is (contains? (set (:ids plugins)) plugin-id-2))
|
|
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
|
|
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
|
|
|
|
(t/deftest add-profile-plugin-rejects-oversized-code
|
|
;; The merged props must not exceed :profile-props-max-size
|
|
(let [profile (th/create-profile* 1)
|
|
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))
|
|
data {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin}]
|
|
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
|
(let [out (th/command! data)]
|
|
(t/is (th/ex-info? (:error out)))
|
|
(t/is (th/ex-of-type? (:error out) :validation))
|
|
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
|
|
|
|
(t/deftest add-profile-plugin-rejects-oversized-code-path
|
|
;; :code holds a manifest path, not content: overlong values are
|
|
;; rejected by the entry schema before the props size check runs
|
|
(let [profile (th/create-profile* 1)
|
|
plugin (assoc valid-plugin :code (apply str (repeat 501 "x")))
|
|
data {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin}
|
|
out (th/command! data)]
|
|
(t/is (th/ex-info? (:error out)))
|
|
(t/is (th/ex-of-type? (:error out) :validation))
|
|
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
|
|
|
(t/deftest remove-profile-plugin-allowed-on-oversized-profile
|
|
;; Removal shrinks props, so it passes even under a tight limit
|
|
(let [profile (th/create-profile* 1)
|
|
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))]
|
|
;; Seed an oversized registry while the limit is high
|
|
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100000})]
|
|
(let [out (th/command! {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin})]
|
|
(t/is (nil? (:error out)))))
|
|
;; Removal under a tighter limit still passes: the seeded registry
|
|
;; is oversized against it, but the remaining props fit
|
|
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 300})]
|
|
(let [out (th/command! {::th/type :remove-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin-id (uuid/uuid plugin-id-1)})]
|
|
(t/is (nil? (:error out)))))))
|
|
|
|
(t/deftest add-profile-plugin-rejects-51st-plugin
|
|
;; The registry holds at most 50 plugins; the 51st (new id) must fail
|
|
(let [profile (th/create-profile* 1)]
|
|
;; Seed 50 plugins
|
|
(doseq [i (range 50)]
|
|
(let [plugin (assoc valid-plugin
|
|
:plugin-id (str (uuid/next))
|
|
:name (str "Plugin " i))
|
|
out (th/command! {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin})]
|
|
(t/is (nil? (:error out)) (str "seed plugin " i " should install"))))
|
|
;; The 51st must fail with a specific error
|
|
(let [extra (assoc valid-plugin
|
|
:plugin-id (str (uuid/next))
|
|
:name "One Too Many")
|
|
out (th/command! {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin extra})]
|
|
(t/is (th/ex-info? (:error out)))
|
|
(t/is (th/ex-of-type? (:error out) :validation))
|
|
(t/is (th/ex-of-code? (:error out) :too-many-plugins)))
|
|
;; And nothing extra was persisted
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)]
|
|
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
|
|
|
|
(t/deftest add-profile-plugin-updates-existing-at-limit
|
|
;; Re-adding an existing id at the limit is an update, not a new entry
|
|
(let [profile (th/create-profile* 1)
|
|
ids (mapv (fn [_] (str (uuid/next))) (range 50))]
|
|
(doseq [[i pid] (map-indexed vector ids)]
|
|
(th/command! {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin (assoc valid-plugin :plugin-id pid :name (str "Plugin " i))}))
|
|
(let [out (th/command! {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin (assoc valid-plugin :plugin-id (first ids) :name "Renamed")})]
|
|
(t/is (nil? (:error out)))
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)]
|
|
(t/is (= 50 (count (get-in props [:props :plugins :ids]))))
|
|
(t/is (= "Renamed" (get-in props [:props :plugins :data (first ids) :name])))))))
|
|
|
|
(t/deftest add-profile-plugin-full-registry-reports-too-many-before-size
|
|
;; A full registry plus oversized content reports the count guard,
|
|
;; which runs before the size check
|
|
(let [profile (th/create-profile* 1)]
|
|
;; Seed 50 plugins under a generous limit
|
|
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 1000000})]
|
|
(doseq [i (range 50)]
|
|
(let [plugin (assoc valid-plugin
|
|
:plugin-id (str (uuid/next))
|
|
:name (str "Plugin " i))
|
|
out (th/command! {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin plugin})]
|
|
(t/is (nil? (:error out)) (str "seed plugin " i " should install")))))
|
|
;; Tight limit + 51st small plugin: count wins over size
|
|
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
|
(let [extra (assoc valid-plugin
|
|
:plugin-id (str (uuid/next))
|
|
:name "One Too Many")
|
|
out (th/command! {::th/type :add-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin extra})]
|
|
(t/is (th/ex-info? (:error out)))
|
|
(t/is (th/ex-of-type? (:error out) :validation))
|
|
(t/is (th/ex-of-code? (:error out) :too-many-plugins))))
|
|
;; And nothing extra was persisted
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)]
|
|
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
|
|
|
|
(t/deftest remove-profile-plugin-noop-on-oversized-profile-without-plugins
|
|
;; Removing an absent id changes nothing: no write, no size failure,
|
|
;; and no :plugins key is manufactured
|
|
(let [profile (th/create-profile* 1)
|
|
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
|
|
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
|
|
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
|
|
(let [out (th/command! {::th/type :remove-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin-id (uuid/next)})]
|
|
(t/is (nil? (:error out)))))
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)]
|
|
(t/is (nil? (get-in props [:props :plugins])))
|
|
(t/is (= big (get props :props))))))
|
|
|
|
(t/deftest update-profile-props-rejects-plugins
|
|
(let [profile (th/create-profile* 1)
|
|
data {::th/type :update-profile-props
|
|
::rpc/profile-id (:id profile)
|
|
:props {:plugins {:ids ["test"] :data {"test" valid-plugin}}}}
|
|
out (th/command! data)]
|
|
|
|
(t/is (th/ex-info? (:error out)))
|
|
(t/is (th/ex-of-type? (:error out) :validation))
|
|
(t/is (th/ex-of-code? (:error out) :params-validation))
|
|
|
|
(let [saved (th/db-get :profile {:id (:id profile)})
|
|
props (profile/decode-row saved)]
|
|
(t/is (nil? (get-in props [:props :plugins]))
|
|
":plugins must not be writable via update-profile-props"))))
|
|
|
|
;; --- Stored registries over the caps
|
|
|
|
(defn- legacy-entry
|
|
[plugin-id & {:as attrs}]
|
|
(merge valid-plugin {:plugin-id plugin-id :code "plugin.js"} attrs))
|
|
|
|
(defn- legacy-registry
|
|
[entries]
|
|
{:ids (mapv :plugin-id entries)
|
|
:data (into {} (map (juxt :plugin-id identity)) entries)})
|
|
|
|
(defn- stored-props
|
|
"Props as stored in the database, without the read-time clamping."
|
|
[profile-id]
|
|
(db/decode-transit-pgobject (:props (th/db-get :profile {:id profile-id}))))
|
|
|
|
(t/deftest get-profile-clamps-legacy-registry
|
|
(let [profile (th/create-profile* 1)
|
|
big (apply str (repeat 10000 "x"))
|
|
;; "😀" is two chars, so char 500 is a high surrogate
|
|
emoji (str (apply str (repeat 499 "n")) "😀")
|
|
entries (into [(legacy-entry plugin-id-1
|
|
:name emoji
|
|
:description big
|
|
:host big
|
|
:code big
|
|
:icon big)]
|
|
(map #(legacy-entry (str "extra-" %)))
|
|
(range 60))
|
|
stored (-> (legacy-registry entries)
|
|
(update :ids conj "dangling"))]
|
|
(th/db-update! :profile {:props (db/tjson {:plugins stored :renderer :wasm})}
|
|
{:id (:id profile)})
|
|
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})
|
|
props (get-in out [:result :props])
|
|
plugins (:plugins props)
|
|
entry (get-in plugins [:data plugin-id-1])]
|
|
(t/is (nil? (:error out)))
|
|
(t/is (= :wasm (:renderer props)) "other props are kept")
|
|
(t/is (= (mapv :plugin-id (take ctp/max-plugins entries)) (:ids plugins))
|
|
"keeps the first plugins in registry order, drops ids without data")
|
|
(t/is (= (set (:ids plugins)) (set (keys (:data plugins)))))
|
|
(t/is (= (apply str (repeat 499 "n")) (:name entry)) "does not split a surrogate pair")
|
|
(doseq [k [:description :host :code :icon]]
|
|
(t/is (= (get ctp/registry-entry-max-lengths k) (count (get entry k)))
|
|
(str k " truncated to its cap")))
|
|
(t/is (sm/validate ctp/schema:plugin-registry plugins)))
|
|
(t/is (= stored (:plugins (stored-props (:id profile))))
|
|
"reading does not write")))
|
|
|
|
(t/deftest get-profile-keeps-valid-registry
|
|
(let [profile (th/create-profile* 1)
|
|
stored (legacy-registry [(legacy-entry plugin-id-1)])]
|
|
(th/db-update! :profile {:props (db/tjson {:plugins stored})} {:id (:id profile)})
|
|
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})]
|
|
(t/is (= stored (get-in out [:result :props :plugins]))))))
|
|
|
|
(t/deftest oversized-legacy-profile-recovers-on-write
|
|
;; A stored registry over the caps pushes props past the size limit;
|
|
;; the clamped read lets writes through and saves the clamped registry
|
|
(let [profile (th/create-profile* 1)
|
|
code (apply str (repeat 3000 "c"))
|
|
entries [(legacy-entry plugin-id-1 :code code)
|
|
(legacy-entry plugin-id-2 :code code)]]
|
|
(th/db-update! :profile {:props (db/tjson {:plugins (legacy-registry entries)})}
|
|
{:id (:id profile)})
|
|
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 5000})]
|
|
(t/is (thrown? Exception (profile/check-props-size (stored-props (:id profile))))
|
|
"the stored props exceed the limit")
|
|
(let [out (th/command! {::th/type :update-profile-props
|
|
::rpc/profile-id (:id profile)
|
|
:props {:workspace-visited true}})]
|
|
(t/is (nil? (:error out))))
|
|
(let [out (th/command! {::th/type :remove-profile-plugin
|
|
::rpc/profile-id (:id profile)
|
|
:plugin-id (uuid/uuid plugin-id-1)})]
|
|
(t/is (nil? (:error out)))))
|
|
(let [props (stored-props (:id profile))]
|
|
(t/is (true? (:workspace-visited props)))
|
|
(t/is (= [plugin-id-2] (get-in props [:plugins :ids])))
|
|
(t/is (= 500 (count (get-in props [:plugins :data plugin-id-2 :code])))
|
|
"the write saved the clamped registry"))))
|