mirror of
https://github.com/penpot/penpot.git
synced 2026-11-06 10:00:38 +00:00
Plugin writes failed whenever the workspace read-only flag was set. The Inspect tab sets it too, although it only hides the editing tools, so an MCP agent could not edit while the user was inspecting. A user who became a viewer had the flag cleared, so plugin writes did not throw and silently did nothing. Writes now fail on what makes them unsafe, not on the flag: failing saves, a user who cannot edit, a version preview or a lost render context. The error says which one, and the MCP instructions tell the agent which of them pass by waiting.
232 lines
9.1 KiB
Clojure
232 lines
9.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 SUBSIDIARY SL
|
|
|
|
(ns frontend-tests.plugins.read-only-test
|
|
"Plugin writes in a read-only workspace."
|
|
(:require
|
|
[app.common.test-helpers.files :as cthf]
|
|
[app.common.uuid :as uuid]
|
|
[app.main.data.helpers :as dsh]
|
|
[app.main.data.workspace.media :as dwm]
|
|
[app.main.repo :as rp]
|
|
[app.main.store :as st]
|
|
[app.plugins.api :as api]
|
|
[app.plugins.register :as r]
|
|
[beicon.v2.core :as rx]
|
|
[cljs.test :as t :include-macros true]
|
|
[frontend-tests.helpers.async :as async]
|
|
[frontend-tests.helpers.mock :as mock]
|
|
[frontend-tests.helpers.state :as ths]
|
|
[frontend-tests.helpers.wasm :as thw]))
|
|
|
|
;; Its validation flag is off, so only errors that always throw reach it
|
|
(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
|
|
|
|
(defn- set-can-edit
|
|
[store can-edit?]
|
|
(swap! store assoc-in [:permissions :can-edit] can-edit?))
|
|
|
|
(defn- shape-count
|
|
[store]
|
|
(count (dsh/lookup-page-objects @store)))
|
|
|
|
(defn- with-context
|
|
"Calls `f` with a store holding a sample file and a plugin context on it."
|
|
[f]
|
|
(thw/with-wasm-mocks*
|
|
(fn []
|
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
|
|
(set! st/state store)
|
|
(f store (api/create-context plugin-id))))))
|
|
|
|
(t/deftest writes-throw-in-a-read-only-workspace
|
|
(with-context
|
|
(fn [store ^js context]
|
|
(let [^js board (.createBoard context)]
|
|
(set-can-edit store false)
|
|
|
|
(t/testing "a shape setter"
|
|
(t/is (thrown-with-msg? js/Error #"read-only" (set! (.-name board) "Renamed")))
|
|
(t/is (not= "Renamed" (.-name board))))
|
|
|
|
(t/testing "a shape method"
|
|
(t/is (thrown-with-msg? js/Error #"read-only" (.bringToFront board))))
|
|
|
|
(t/testing "a context method without a permission check"
|
|
(let [before (shape-count store)]
|
|
(t/is (thrown-with-msg? js/Error #"read-only" (.createRectangle context)))
|
|
(t/is (= before (shape-count store)))))
|
|
|
|
(t/testing "a context method with a permission check"
|
|
(t/is (thrown-with-msg? js/Error #"read-only" (.createPage (.-currentFile context)))))))))
|
|
|
|
(t/deftest writes-work-again-once-the-workspace-is-editable
|
|
(with-context
|
|
(fn [store ^js context]
|
|
(let [^js board (.createBoard context)]
|
|
(set-can-edit store false)
|
|
(set-can-edit store true)
|
|
(set! (.-name board) "Renamed")
|
|
(t/is (= "Renamed" (.-name board)))))))
|
|
|
|
(t/deftest the-inspect-tab-does-not-block-writes
|
|
(with-context
|
|
(fn [store ^js context]
|
|
(let [^js board (.createBoard context)]
|
|
(swap! store update :workspace-global assoc
|
|
:read-only? true
|
|
:options-mode :inspect)
|
|
(set! (.-name board) "Renamed")
|
|
(t/is (= "Renamed" (.-name board)))))))
|
|
|
|
(t/deftest each-read-only-source-says-why
|
|
(doseq [[path value reason] [[[:permissions :can-edit] false #"cannot edit this file"]
|
|
[[:workspace-global :preview-id] (random-uuid) #"version is being previewed"]
|
|
[[:render-state :lost] true #"canvas stopped rendering"]]]
|
|
(with-context
|
|
(fn [store ^js context]
|
|
(swap! store assoc-in path value)
|
|
(t/testing (str path)
|
|
(t/is (thrown-with-msg? js/Error reason (.createBoard context))))))))
|
|
|
|
(t/deftest a-save-failure-lock-says-why-the-workspace-is-read-only
|
|
(with-context
|
|
(fn [store ^js context]
|
|
(swap! store update :workspace-global assoc
|
|
:read-only? true
|
|
:persistence-lock {:read-only? false})
|
|
(t/is (thrown-with-msg? js/Error #"cannot be saved" (.createBoard context))))))
|
|
|
|
(t/deftest reads-and-undo-blocks-work-in-a-read-only-workspace
|
|
(with-context
|
|
(fn [store ^js context]
|
|
(let [^js board (.createBoard context)]
|
|
(set-can-edit store false)
|
|
(t/is (string? (.-name board)))
|
|
(let [block (.undoBlockBegin (.-history context))]
|
|
(t/is (some? block))
|
|
(t/is (nil? (.undoBlockFinish (.-history context) block))))))))
|
|
|
|
(t/deftest read-only-keeps-permission-checks-of-reads
|
|
(with-context
|
|
(fn [store _]
|
|
(set-can-edit store false)
|
|
(t/is (true? (r/check-permission plugin-id "content:read")))
|
|
(t/is (true? (r/has-permission? plugin-id "content:write")))
|
|
(t/is (thrown? js/Error (r/check-permission plugin-id "library:write"))))))
|
|
|
|
;; Writes that wait for the server before they change the file
|
|
|
|
(defn- settled
|
|
"Resolves with `{:resolved value}` or `{:rejected cause}` once `p` settles,
|
|
or with `{:timeout true}` when it stays pending for 2 seconds."
|
|
[p]
|
|
(js/Promise.race
|
|
#js [(.then p (fn [v] {:resolved v}) (fn [e] {:rejected e}))
|
|
(js/Promise. (fn [resolve] (js/setTimeout #(resolve {:timeout true}) 2000)))]))
|
|
|
|
(defn- lock-saves
|
|
"Locks `store` as a failing save does."
|
|
[store]
|
|
(swap! store update :workspace-global assoc
|
|
:read-only? true
|
|
:persistence-lock {:read-only? false}))
|
|
|
|
(defn- held-rpc
|
|
"An `rp/cmd!` that records each command in `calls` and answers with the
|
|
next value pushed to `answer`."
|
|
[calls answer]
|
|
(mock/stub (fn [cmd _]
|
|
(swap! calls conj cmd)
|
|
(->> answer (rx/take 1) (rx/observe-on :async)))))
|
|
|
|
(def ^:private media-object
|
|
{:id (uuid/next) :name "image" :width 10 :height 10 :mtype "image/png"})
|
|
|
|
(def ^:private svg-with-image
|
|
(str "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">"
|
|
"<image href=\"data:image/png;base64,iVBORw0KGgo=\" width=\"10\" height=\"10\"/>"
|
|
"</svg>"))
|
|
|
|
(defn- parsed-svg-with-image
|
|
"What `dwm/svg->clj` gives for `svg-with-image`. The test build cannot
|
|
load the XML parser, a CommonJS module."
|
|
[[name _]]
|
|
(rx/of {:tag :svg
|
|
:name name
|
|
:attrs {:xmlns "http://www.w3.org/2000/svg" :width "10" :height "10"}
|
|
:content [{:tag :image
|
|
:attrs {:href "data:image/png;base64,iVBORw0KGgo=" :width "10" :height "10"}
|
|
:content []}]}))
|
|
|
|
(defn- read-only-error?
|
|
[cause]
|
|
(boolean (some->> cause ex-message (re-find #"cannot be saved"))))
|
|
|
|
;; Scenario: an SVG with an image is created; while its image uploads a
|
|
;; save fails and locks the workspace. Proves: the shape is not added and
|
|
;; the promise rejects with the read-only error.
|
|
(t/deftest ^:async an-svg-whose-upload-ends-after-a-save-failure-is-refused
|
|
(thw/setup-wasm-mocks!)
|
|
(try
|
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))
|
|
calls (atom [])
|
|
answer (rx/subject)]
|
|
(await
|
|
(mock/with-mocks*
|
|
{st/state store
|
|
rp/cmd! (held-rpc calls answer)
|
|
dwm/valid-svg-string? (constantly true)
|
|
dwm/svg->clj (mock/stub parsed-svg-with-image)}
|
|
(let [^js context (api/create-context plugin-id)
|
|
before (shape-count store)
|
|
result (settled (.createShapeFromSvgWithImages context svg-with-image))]
|
|
(await (async/wait-for #(= [:upload-file-media-object] @calls) "the image upload starts"))
|
|
(lock-saves store)
|
|
(rx/push! answer media-object)
|
|
(let [{:keys [rejected]} (await result)]
|
|
(t/is (read-only-error? rejected))
|
|
(await (async/settle))
|
|
(t/is (= before (shape-count store)) "no shape is added"))))))
|
|
(finally
|
|
(thw/teardown-wasm-mocks!))))
|
|
|
|
;; Uploads `upload` with the context; while the request for `cmd` runs, a
|
|
;; save fails and locks the workspace.
|
|
(defn- ^:async upload-during-a-save-failure
|
|
[upload cmd]
|
|
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))
|
|
calls (atom [])
|
|
answer (rx/subject)]
|
|
(await
|
|
(mock/with-mocks*
|
|
{st/state store
|
|
rp/cmd! (held-rpc calls answer)}
|
|
;; Bound one by one: a nested call with a local fn would compile to
|
|
;; an awaited IIFE, which waits for the upload itself
|
|
(let [context (api/create-context plugin-id)
|
|
result (settled (upload context))]
|
|
(await (async/wait-for #(= [cmd] @calls) "the upload starts"))
|
|
(lock-saves store)
|
|
(rx/push! answer media-object)
|
|
(let [{:keys [rejected]} (await result)]
|
|
(t/is (read-only-error? rejected) (str cmd))))))))
|
|
|
|
;; Scenario: an image is uploaded from data and from a URL; while each
|
|
;; upload runs a save fails and locks the workspace. Proves: the promise
|
|
;; rejects with the read-only error. The media object stays on the server.
|
|
(t/deftest ^:async media-uploaded-after-a-save-failure-is-refused
|
|
(thw/setup-wasm-mocks!)
|
|
(try
|
|
(await (upload-during-a-save-failure
|
|
#(.uploadMediaData ^js % "image" (js/Uint8Array. #js [1 2 3]) "image/png")
|
|
:upload-file-media-object))
|
|
(await (upload-during-a-save-failure
|
|
#(.uploadMediaUrl ^js % "image" "https://example.com/image.png")
|
|
:create-file-media-object-from-url))
|
|
(finally
|
|
(thw/teardown-wasm-mocks!))))
|