penpot/frontend/test/frontend_tests/plugins/read_only_test.cljs
alonso.torres 642f35cf90 🐛 Let plugins write in Inspect mode and stop them as viewers
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.
2026-11-05 10:56:41 +01:00

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!))))