penpot/backend/test/backend_tests/rpc_plugins_test.clj
Andrey Antukh 57922e0058 🐛 Add dedicated RPC methods for plugin registry operations
Add `add-profile-plugin` and `remove-profile-plugin` RPC methods for
atomic plugin registry operations, preventing manipulation via the
broader `update-profile-props` endpoint.

- Close the `:plugins` field in `update-profile-props` schema to
  eliminate the mass assignment attack vector for plugin data.
- Define `valid-permissions` and a closed `schema:permissions` enum to
  restrict plugin permissions to known values.
- Migrate the frontend to use the new granular RPC methods with
  optimistic updates and rollback on failure.
- Add comprehensive backend tests covering valid/invalid permissions,
  updates, removal, and rejection via old endpoint.

AI-assisted-by: qwen3.7-plus
2026-08-08 16:01:21 +02:00

163 lines
6.1 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 Sucursal en España SL
(ns backend-tests.rpc-plugins-test
(:require
[app.common.uuid :as uuid]
[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 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"))))