Andrey Antukh 937b3fc65f
🐛 Add missing permission checks to plugin API (tokens, shapes, variants, flows, layouts, user identity) (#11139)
* 🐛 Add content:write permission checks to Design Tokens plugin API

The Design Tokens API (tokens.cljs) had zero permission checks, allowing
any plugin to create, modify, and delete tokens, sets, and themes
regardless of granted permissions.

Add r/check-permission checks to all 22 write operations across:
- token-proxy: name, value, description, duplicate, remove, applyToken
- token-set-proxy: name, active, toggleActive, addToken, duplicate, remove
- token-theme-proxy: group, name, active, toggleActive, addSet, removeSet,
  duplicate, remove
- tokens-catalog: addTheme, addSet

Follows the established pattern from comments.cljs, file.cljs, page.cljs.

Closes #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add permission checks to shape proxy interactions, detach, export, and variants

The shape proxy (shape.cljs) had multiple operations missing permission
checks, plus a cond ordering bug that bypassed the existing content:write
check for text shapes in commit-fills!.

Fix commit-fills! cond ordering: move permission check before the
text-shape branch so text shapes are also protected.

Add content:write permission checks to:
- interaction-proxy: :trigger, :delay, :action setters, :remove method
- shape-proxy: :addInteraction, :removeInteraction, :detach
- shape-proxy: :applyToken, :switchVariant, :combineAsVariants

Add content:read permission check to:
- shape-proxy: :export (read/extraction operation)

Follows the established pattern from :resize, :rotate, :blocked setters.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add library:write permission checks to variant plugin API

The library.cljs variant operations (variant-proxy and
lib-component-proxy) had seven mutating operations that
did not check the library:write permission, allowing
any plugin to create, modify, and delete component
variants regardless of granted permissions.

Add r/check-permission checks to all 7 operations:
- variant-proxy: addVariant, addProperty,
  removeProperty, renameProperty
- lib-component-proxy: transformInVariant, addVariant,
  setVariantProperty

Follows the established pattern from the :name and
:path setters in the same file.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add content:write permission checks to flow and flex layout plugin API

Add permission checks to prototype flow and flex layout operations
that were missing them, allowing plugins to modify flows and layout
structure without explicit user permission.

Changes:
- page.cljs: Add content:write checks to flow-proxy (name,
  startingBoard setters, remove) and page-proxy (createFlow,
  removeFlow)
- flex.cljs: Add content:write checks to flex-layout-proxy
  (remove, appendChild)

Follows the established pattern from tokens.cljs, shape.cljs,
and library.cljs.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Add user:read permission checks to plugin API

Add permission checks to user identity accessors that were bypassing
the consent model, allowing plugins to access user data regardless
of whether the user granted user:read permission.

Changes:
- api.cljs: Add user:read checks to getCurrentUser and getActiveUsers
- comments.cljs: Add user:read checks to comment-proxy and
  comment-thread-proxy owner/user getters
- file.cljs: Add user:read check to file-version-proxy createdBy getter

When user:read permission is not granted:
- getCurrentUser() returns null
- getActiveUsers() returns empty array
- owner/user/createdBy getters return null

Follows the established pattern from other permission checks in the
plugin API.

Relates to #11137

AI-assisted-by: qwen3.7-plus

* 🐛 Fix problem with token API

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-08 09:24:45 +02:00

241 lines
10 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 frontend-tests.plugins.page-test
(:require
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as cths]
[app.common.uuid :as uuid]
[app.main.data.workspace.pages :as dwpg]
[app.main.store :as st]
[app.plugins.api :as api]
[app.plugins.page :as page]
[app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.utils :as u]
[app.util.object :as obj]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]
[potok.v2.core :as ptk]))
(t/use-fixtures :each
{:before (fn [] (thw/setup-wasm-mocks!))
:after thw/teardown-wasm-mocks!})
(defn- setup
"Creates a file with two pages (page1 as current) and a plugin context."
[]
(let [file (-> (cthf/sample-file :file1 :page-label :page1)
(cthf/add-sample-page :page2)
(cthf/switch-to-page :page1))
store (ths/setup-store file)
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
_ (ptk/emit! store #(assoc-in % [:plugins :flags "00000000-0000-0000-0000-000000000000" :throw-validation-errors] true))
context (api/create-context "00000000-0000-0000-0000-000000000000")]
{:file file :store store :context context}))
(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
(defn- setup-with-board
"Creates a file with a board on the current page and a plugin context."
[]
(let [file (-> (cthf/sample-file :file1 :page-label :page1)
(cths/add-sample-shape :board1 :type :frame))
store (ths/setup-store file)
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
context (api/create-context plugin-id)]
{:file file :store store :context context :board-id (thi/id :board1)}))
(t/deftest test-create-flow-starting-board-is-valid
;; Regression: the flow proxy returned by createFlow (and obtained later via
;; page.flows) must expose a valid board proxy for `startingBoard`, carrying
;; the plugin id rather than a corrupted handle. See issue #10203.
(let [result (setup-with-board)
^js context (:context result)
board-id (:board-id result)
^js page (.-currentPage context)
^js board (.getShapeById page (str board-id))
^js flow (.createFlow page "flow1" board)
^js sb (.-startingBoard flow)]
(t/is (shape/shape-proxy? sb))
(t/is (= (str board-id) (.-id sb)))
(t/is (= plugin-id (obj/get sb "$plugin")))
;; Re-fetching the flow through page.flows must yield the same valid board
(let [^js flow2 (aget (.-flows page) 0)
^js sb2 (.-startingBoard flow2)]
(t/is (shape/shape-proxy? sb2))
(t/is (= (str board-id) (.-id sb2)))
(t/is (= plugin-id (obj/get sb2 "$plugin"))))))
(defn- mock-page-initialized
"Simulates the two effects of initialize-page* without routing:
updates current-page-id in state, then emits the public ::dwpg/initialized event."
[store page-id]
(ptk/emit! store #(assoc % :current-page-id page-id))
(ptk/emit! store (ptk/data-event ::dwpg/initialized page-id)))
(t/deftest test-open-page-returns-promise
(let [^js context (:context (setup))
^js pages (.. context -currentFile -pages)
^js page2 (aget pages 1)]
(t/is (instance? js/Promise (.openPage context page2)))))
(t/deftest test-open-page-new-window-returns-promise
(let [^js context (:context (setup))
^js pages (.. context -currentFile -pages)
^js page2 (aget pages 1)]
(t/is (instance? js/Promise (.openPage context page2 true)))))
(t/deftest test-open-page-invalid-arg-throws
;; With throwValidationErrors enabled an invalid argument surfaces as an
;; exception instead of being silently logged.
(let [^js context (:context (setup))]
(t/is (thrown? js/Error (.openPage context "not-a-page")))))
(t/deftest test-open-page-resolves-when-page-changes
(t/async done
(let [result (setup)
store (:store result)
^js context (:context result)
^js pages (.. context -currentFile -pages)
^js page2 (aget pages 1)
page2-id (obj/get page2 "$id")]
(-> (.openPage context page2)
(.then (fn [_]
(t/is (= (:current-page-id @store) page2-id))
(done))))
(mock-page-initialized store page2-id))))
(t/deftest test-flows-returns-empty-array-when-no-flows
;; page.flows must always return an array, even when the page has no flows
(let [^js context (:context (setup))
^js pages (.. context -currentFile -pages)
^js page1 (aget pages 0)
^js flows (.-flows page1)]
(t/is (array? flows))
(t/is (= 0 (.-length flows)))))
(t/deftest test-open-page-does-not-resolve-for-wrong-page
;; Promise should not resolve when a different page is initialized
(t/async done
(let [result (setup)
store (:store result)
^js context (:context result)
^js pages (.. context -currentFile -pages)
^js page1 (aget pages 0)
^js page2 (aget pages 1)
page1-id (obj/get page1 "$id")
page2-id (obj/get page2 "$id")
resolved? (atom false)]
(-> (.openPage context page2)
(.then (fn [_] (reset! resolved? true))))
;; Initialize page1 (wrong page) — promise should not resolve
(mock-page-initialized store page1-id)
;; Give microtasks a chance to run, then verify promise is still pending
(js/setTimeout
(fn []
(t/is (not @resolved?))
;; Now initialize the correct page and confirm it resolves
(-> (.openPage context page2)
(.then (fn [_]
(t/is (= (:current-page-id @store) page2-id))
(done))))
(mock-page-initialized store page2-id))
0))))
;; ---------------------------------------------------------------------------
;; Permission checks (T9-F-03)
;; ---------------------------------------------------------------------------
(t/deftest flow-name-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
(set! (.-name proxy) "new-name")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest flow-starting-board-setter-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop
shape/shape-proxy? (constantly true)]
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
(set! (.-startingBoard proxy) #js {})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :startingBoard "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest flow-remove-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)]
(.remove proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest create-flow-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop
shape/shape-proxy? (constantly true)]
(let [proxy (page/page-proxy plugin-id file-id page-id)
frame #js {"$id" (uuid/next)}]
(.createFlow proxy "flow-name" frame)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :createFlow "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest remove-flow-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
flow-id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop
page/flow-proxy? (constantly true)]
(let [proxy (page/page-proxy plugin-id file-id page-id)]
(.removeFlow proxy (page/flow-proxy plugin-id file-id page-id flow-id))
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :removeFlow "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))