penpot/backend/test/backend_tests/http_websocket_test.clj
Andrey Antukh b252ab493e 🐛 Declare the nitrate client of the revocation watcher
The params schema of the ::revocation-watcher key named
:app.nitrate/client as a bare keyword. Inside [:map ...] a bare
keyword is not a key but a schema property, so malli resolved it
as a registry lookup. Nothing had registered that keyword, and
compiling the schema raised :malli.core/invalid-schema, which took
down the whole system build instead of one assertion.

Nothing caught it because the assertion only runs when
PENPOT_FLAGS carries enable-backend-asserts, so the suite passed
with the schema broken. With the flag the backend died before a
single test ran.

The client is declared as an explicit pair, the way
auth.oidc/schema:routes-params already does. It is optional
because init-key yields nil when the :admin-console flag is off,
and a required key would fail the assert on a normal boot.

The new test compiles the schema and pins both halves of that
contract, following the pattern already used for the three worker
schemas in backend-tests.worker-test.

AI-assisted-by: space-bunny-free
2026-10-08 14:07:39 +00:00

1129 lines
50 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.http-websocket-test
(:require
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.websocket :as ws]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.rpc.notifications :as ntf]
[app.util.websocket :as util-ws]
[backend-tests.helpers :as th]
[clojure.test :as t]
[promesa.exec.csp :as sp]))
(defn- with-clean-registry
"Runs `f` against an empty connection registry, restoring the previous
contents afterwards so tests cannot leak connections into each other."
[f]
(let [saved @ws/state]
(reset! ws/state {:connections {} :by-profile {}})
(try (f)
(finally (reset! ws/state saved)))))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset with-clean-registry)
(defn make-wsp
[profile-id state output-ch]
{::util-ws/id (uuid/next)
::util-ws/state state
::util-ws/output-ch output-ch
::ws/profile-id profile-id
::ws/session-id (uuid/next)})
(t/deftest subscribe-file-permission-check
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile1)
:project-id (:default-project-id profile1)})
cfg th/*system*
state (atom {})
output-ch (sp/chan :buf (sp/dropping-buffer 64))]
(t/testing "rejects unauthorized user"
(let [wsp (make-wsp (:id profile2) state output-ch)]
(t/is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"not found"
((get-method ws/handle-message :subscribe-file)
cfg wsp {:file-id (:id file)})))))
(t/testing "permission check passes for authorized user"
(t/is (nil? (files/check-read-permissions! cfg (:id profile1) (:id file)))))))
(t/deftest subscribe-team-permission-check
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
cfg th/*system*
state (atom {})
output-ch (sp/chan :buf (sp/dropping-buffer 64))]
(t/testing "rejects unauthorized user"
(let [wsp (make-wsp (:id profile2) state output-ch)]
(t/is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"not found"
((get-method ws/handle-message :subscribe-team)
cfg wsp {:team-id (:id team)})))))
(t/testing "permission check passes for authorized user"
(t/is (nil? (teams/check-read-permissions! cfg (:id profile1) (:id team)))))))
(defn- subscribed-topics
"Runs :subscribe-team for `profile-id` on `team-id` with `nitrate-call`
standing in for nitrate; returns the topics and the stored subscription."
[profile-id team-id nitrate-call]
(let [state (atom {})
output-ch (sp/chan :buf (sp/dropping-buffer 64))
wsp (make-wsp profile-id state output-ch)
calls (atom [])]
(with-redefs [nitrate/call nitrate-call
mbus/sub! (fn [_ & {:keys [topics]}]
(swap! calls conj topics))]
((get-method ws/handle-message :subscribe-team)
th/*system* wsp {:team-id team-id}))
(some-> @state ::ws/team-subscription :channel sp/close!)
{:topics @calls
:subscription (::ws/team-subscription @state)}))
(t/deftest subscribe-team-subscribes-to-team-organization
(let [profile (th/create-profile* 1 {:is-active true})
team-id (:id (th/create-team* 1 {:profile-id (:id profile)}))
org-id (uuid/next)]
(t/testing "adds the organization topic when the team has one"
(let [{:keys [topics subscription]}
(subscribed-topics (:id profile) team-id
(fn [_ method params]
(when (= :get-team-organization method)
{:id (:team-id params)
:organization {:id org-id}})))]
(t/is (= [[team-id org-id]] topics))
(t/is (= org-id (:organization-id subscription)))))
(t/testing "subscribes only to the team when it has no organization"
(let [{:keys [topics subscription]}
(subscribed-topics (:id profile) team-id (constantly nil))]
(t/is (= [[team-id]] topics))
(t/is (nil? (:organization-id subscription)))))
(t/testing "subscribes to the team when nitrate fails"
(let [{:keys [topics]}
(subscribed-topics (:id profile) team-id
(fn [& _] (throw (ex-info "nitrate down" {}))))]
(t/is (= [[team-id]] topics))))))
(t/deftest pointer-update-validates-file-id
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
cfg th/*system*
file-id (:id file)
sub-ch (sp/chan :buf (sp/dropping-buffer 64))
state (atom {::ws/file-subscription {:file-id file-id
:channel sub-ch
:topic file-id}})
output-ch (sp/chan :buf (sp/dropping-buffer 64))
wsp (make-wsp (:id profile) state output-ch)]
(t/testing "skips publish when file-id does not match subscription"
(let [wrong-msg {:type :pointer-update
:file-id (uuid/next)
:position {:x 10 :y 20}
:zoom 1.0}]
(t/is (nil?
((get-method ws/handle-message :pointer-update)
cfg wsp wrong-msg)))))
(t/testing "does nothing when no file subscription exists"
(let [empty-state (atom {})
empty-wsp (make-wsp (:id profile) empty-state output-ch)
msg {:type :pointer-update
:file-id file-id
:position {:x 10 :y 20}
:zoom 1.0}]
(t/is (nil?
((get-method ws/handle-message :pointer-update)
cfg empty-wsp msg)))))))
;; --- SUBSCRIPTION REVOCATION
;;
;; GHSA-m53j-2766-6jqw: a websocket subscription checks read permission
;; once, at subscribe time. Once the connection is subscribed to the
;; message-bus topic it keeps receiving live content for the rest of its
;; life, even after access is revoked.
(def ^:private revocation-timeout-ms
"How long to wait for a message that should never arrive. Long enough
to absorb the round trip through redis, short enough to keep the test
fast."
500)
(defn- poll-msg!
"Drains `ch` for up to `ms`, returning the first message whose `:type`
is `type`, or nil when none arrives.
Draining rather than reading a single message matters for the negative
assertions: a live subscription also carries presence traffic, so
\"no `:file-change` arrived\" is the property under test, not \"the
channel stayed empty\"."
[ch type ms]
(let [result (promise)
deadline (+ (System/currentTimeMillis) ms)]
(sp/go
(loop []
(let [remain (- deadline (System/currentTimeMillis))]
(when (pos? remain)
(let [[msg _] (sp/alts! [ch (sp/timeout-chan remain)])]
(if (= type (:type msg))
(deliver result msg)
(recur)))))))
(deref result (inc ms) nil)))
(defn- open-registered-connection
"Registers a connection for `profile-id` in the real registry, so the
revocation watcher can find it, and returns the pieces a test needs to
drive it and inspect what it did."
[profile-id]
(let [id (uuid/next)
output (sp/chan :buf (sp/dropping-buffer 64))
ws-state (atom {})
wsp (make-wsp profile-id ws-state output)]
(ws/register-connection id wsp)
{:id id :wsp wsp :state ws-state :output output}))
(defn- open-subscription!
"Opens a registered subscription for `profile-id` and returns the
output channel that receives whatever it forwards.
The connection goes through the real registry on purpose: these tests
assert the end-to-end path, where a mutation announces the change and
the watcher closes the subscription."
[handler profile-id params]
(let [{:keys [wsp output]} (open-registered-connection profile-id)]
((get-method ws/handle-message handler) th/*system* wsp params)
output))
(defn- wait-until
"Polls `pred` until it holds or `ms` elapses. Returns whether it held."
[pred ms]
(let [deadline (+ (System/currentTimeMillis) ms)]
(loop []
(cond
(pred) true
(>= (System/currentTimeMillis) deadline) false
:else (do (Thread/sleep 25) (recur))))))
(defn- subscribe
[{:keys [wsp] :as conn} handler params]
((get-method ws/handle-message handler) th/*system* wsp params)
conn)
(defn- subscribed-file [conn] (-> @(:state conn) ::ws/file-subscription :file-id))
(defn- subscribed-team [conn] (-> @(:state conn) ::ws/team-subscription :team-id))
(defn- publisher!
"Returns a fn that publishes `message` on `topic` through the system
msgbus, the same path `send-notifications!` uses."
[]
(let [msgbus (::mbus/msgbus th/*system*)]
(fn [topic message]
(mbus/pub! msgbus :topic topic :message message))))
;; --- CONNECTION REGISTRY
(t/deftest registry-indexes-connections-by-profile
(let [profile-a (uuid/next)
profile-b (uuid/next)
conn-a (uuid/next)
conn-b (uuid/next)
conn-c (uuid/next)]
(t/testing "a profile with no connections has an empty index"
(t/is (= #{} (ws/connections-for-profile profile-a))))
(ws/register-connection conn-a {::ws/profile-id profile-a})
(ws/register-connection conn-b {::ws/profile-id profile-a})
(ws/register-connection conn-c {::ws/profile-id profile-b})
(t/testing "connections are indexed under their profile"
(t/is (= #{conn-a conn-b} (ws/connections-for-profile profile-a)))
(t/is (= #{conn-c} (ws/connections-for-profile profile-b))))
(t/testing "connections are retrievable by id"
(t/is (= profile-a (::ws/profile-id (ws/get-connection conn-a))))
(t/is (nil? (ws/get-connection (uuid/next)))))
(t/testing "unregistering removes it from both the map and the index"
(ws/unregister-connection conn-b)
(t/is (= #{conn-a} (ws/connections-for-profile profile-a)))
(t/is (nil? (ws/get-connection conn-b))))
(t/testing "a profile whose last connection left is dropped from the index"
(ws/unregister-connection conn-a)
(t/is (= #{} (ws/connections-for-profile profile-a)))
(t/is (not (contains? (:by-profile @ws/state) profile-a)))
(t/is (= #{conn-c} (ws/connections-for-profile profile-b))))
(t/testing "unregistering an unknown connection is a no-op"
(ws/unregister-connection (uuid/next))
(t/is (= #{conn-c} (ws/connections-for-profile profile-b)))
(t/is (= conn-c (-> (:connections @ws/state) keys first))))))
(t/deftest close-file-subscription-drops-only-the-matching-file
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
other (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
output (sp/chan :buf (sp/dropping-buffer 64))
relay (sp/chan :buf (sp/dropping-buffer 64))
ws-state (atom {::ws/file-subscription
{:file-id (:id file)
:channel relay
:topic (:id file)}})
wsp (assoc (make-wsp (:id profile) ws-state output)
::ws/state ws-state)]
(t/testing "closing a file the connection is not subscribed to does nothing"
(with-redefs [mbus/pub! (fn [& _] (throw ::unexpected-publish))
mbus/purge! (fn [& _] (throw ::unexpected-purge))]
(t/is (nil? (ws/close-file-subscription th/*system* wsp (:id other))))
(t/is (some? (::ws/file-subscription @ws-state)))))
(t/testing "closing the subscribed file purges the bus"
(let [purged (atom [])]
(with-redefs [mbus/purge! (fn [_ chans] (swap! purged conj chans))]
(ws/close-file-subscription th/*system* wsp (:id file))
(t/is (= 1 (count @purged))))))
(t/testing "the relay channel is closed so the go-loop stops"
(t/is (nil? (sp/poll! relay)))
(t/is (true? (sp/closed? relay))))
(t/testing "the subscription is forgotten, so closing again is a no-op"
(t/is (nil? (::ws/file-subscription @ws-state)))
(with-redefs [mbus/pub! (fn [& _] (throw ::unexpected-publish))]
(t/is (nil? (ws/close-file-subscription th/*system* wsp (:id file))))))))
(t/deftest revoked-member-stops-receiving-file-changes
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project)})
file-id (:id file)
publish (publisher!)
change {:type :file-change
:file-id file-id
:revn 1
:changes [{:type :add :id (uuid/next)}]}]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id file-id}))
out (:output conn)]
(t/testing "member receives file changes before revocation"
(publish file-id change)
(t/is (some? (poll-msg! out :file-change
revocation-timeout-ms))))
(t/testing "owner removes the member from the team"
(let [result (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)})]
(t/is (th/success? result))))
(t/testing "the mutation alone drops the subscription"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000)))
(t/testing "revoked member stops receiving file changes"
(publish file-id (assoc change :revn 2))
(t/is (nil? (poll-msg! out :file-change
revocation-timeout-ms))))))))
(t/deftest revoked-member-stops-receiving-library-changes
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [team-id (:id team)
publish (publisher!)
change {:type :library-change
:team-id team-id
:file-id (uuid/next)
:revn 1
:changes [{:type :add :id (uuid/next)}]}]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-team {:team-id team-id}))
out (:output conn)]
(t/testing "member receives library changes before revocation"
(publish team-id change)
(t/is (some? (poll-msg! out :library-change
revocation-timeout-ms))))
(t/testing "member leaves the team"
(let [result (th/command! {::th/type :leave-team
::rpc/profile-id (:id editor)
:id team-id})]
(t/is (th/success? result))))
(t/testing "leaving drops the team subscription"
(t/is (wait-until #(nil? (subscribed-team conn)) 3000)))
(t/testing "revoked member stops receiving library changes"
(publish team-id (assoc change :revn 2))
(t/is (nil? (poll-msg! out :library-change
revocation-timeout-ms))))))))
(t/deftest role-downgrade-keeps-revocation-irrelevant
"A role change that does not remove read access must not be treated as
a revocation: `:viewer` can still read, so the subscription stays."
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project)})
file-id (:id file)
publish (publisher!)
change {:type :file-change :file-id file-id :revn 1 :changes []}
out (open-subscription! :subscribe-file (:id editor)
{:file-id file-id})]
(t/testing "editor is downgraded to viewer"
(let [result (th/command! {::th/type :update-team-member-role
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)
:role :viewer})]
(t/is (th/success? result))))
(t/testing "viewer keeps receiving file changes"
(publish file-id change)
(t/is (some? (poll-msg! out :file-change revocation-timeout-ms)))))))
;; --- REVOCATION WATCHER
;;
;; The registry is local to a backend instance while the message bus is
;; shared, so a revocation travels over the bus and each instance
;; re-verifies the connections it owns.
(defn- editor-in-team!
"Builds an owner, an editor with read access to `team`, and a project
and file inside it."
[owner-i editor-i]
(let [owner (th/create-profile* owner-i {:is-active true})
editor (th/create-profile* editor-i {:is-active true})
team (th/create-team* owner-i {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* owner-i {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* owner-i {:profile-id (:id editor)
:project-id (:id project)})]
{:owner owner :editor editor :team team :file file})))
(t/deftest revocation-closes-subscriptions-that-are-no-longer-authorized
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)})
(subscribe :subscribe-team {:team-id (:id team)}))]
(t/testing "both subscriptions are open before the revocation"
(t/is (= (:id file) (subscribed-file conn)))
(t/is (= (:id team) (subscribed-team conn))))
(t/testing "the member is removed from the team"
(let [result (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)})]
(t/is (th/success? result))))
(t/testing "revalidation closes the file and the team subscription"
(t/is (= 2 (ws/revalidate-profile-subscriptions
th/*system* (:id editor)))))
(t/testing "nothing is left subscribed to relay content"
(t/is (nil? (subscribed-file conn)))
(t/is (nil? (subscribed-team conn))))
(t/testing "revalidating again is a no-op"
(t/is (zero? (ws/revalidate-profile-subscriptions
th/*system* (:id editor))))))))
(t/deftest revocation-keeps-subscriptions-that-are-still-authorized
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)})
(subscribe :subscribe-team {:team-id (:id team)}))]
(t/testing "the editor is downgraded to viewer, which can still read"
(let [result (th/command! {::th/type :update-team-member-role
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)
:role :viewer})]
(t/is (th/success? result))))
(t/testing "revalidation closes nothing"
(t/is (zero? (ws/revalidate-profile-subscriptions
th/*system* (:id editor)))))
(t/testing "both subscriptions survive"
(t/is (= (:id file) (subscribed-file conn)))
(t/is (= (:id team) (subscribed-team conn)))))))
(t/deftest revocation-leaves-other-profiles-alone
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)
other-owner (th/create-profile* 3 {:is-active true})
other-editor (th/create-profile* 4 {:is-active true})
other-team (th/create-team* 3 {:profile-id (:id other-owner)})]
(th/create-team-role* {:team-id (:id other-team)
:profile-id (:id other-editor)
:role :editor})
(let [other-file (th/create-file* 3 {:profile-id (:id other-editor)
:project-id (:default-project-id
other-editor)})]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))
bystander (-> (open-registered-connection (:id other-editor))
(subscribe :subscribe-file {:file-id (:id other-file)}))]
(th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)})
(ws/revalidate-profile-subscriptions th/*system* (:id editor))
(t/testing "the revoked member's subscription is closed"
(t/is (nil? (subscribed-file conn))))
(t/testing "a member of another team keeps their subscription"
(t/is (= (:id other-file) (subscribed-file bystander))))
(t/testing "revalidating a profile with no connections is safe"
(t/is (zero? (ws/revalidate-profile-subscriptions
th/*system* (uuid/next)))))))))
(t/deftest watcher-consumes-revocation-events-from-the-bus
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))]
(th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)})
(t/testing "the event is not applied until it is announced"
(t/is (= (:id file) (subscribed-file conn))))
(ntf/notify-permissions-changed th/*system* (:id editor))
(t/testing "the watcher closes the subscription off the bus"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000))))))
(t/deftest internal-revocation-events-are-not-delivered-to-clients
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)
output (sp/chan :buf (sp/dropping-buffer 64))
wsp (assoc (make-wsp (:id profile) (atom {}) output)
::ws/id id)]
(ws/register-connection id wsp)
((get-method ws/handle-message :open) th/*system* wsp nil)
(t/testing "the client does receive its own profile traffic"
(mbus/pub! (::mbus/msgbus th/*system*)
:topic (:id profile)
:message {:type :notification :text "hello"})
(t/is (some? (poll-msg! output :notification revocation-timeout-ms))))
(t/testing "an internal revocation event never reaches the client"
(ntf/notify-permissions-changed th/*system* (:id profile))
(t/is (nil? (poll-msg! output :profile-permissions-changed
revocation-timeout-ms))))))
;; --- MUTATIONS THAT ANNOUNCE THE CHANGE
;;
;; The watcher only helps if the mutations that can revoke access
;; actually announce it. Each of these asserts the announcement reaches
;; the bus, and that a change which does not remove access announces
;; nothing harmful.
(defn- captured-events
"Runs `f` collecting the revocation events published on the internal
topic, while letting every other publish reach the real bus untouched."
[f]
(let [events (atom [])
real mbus/pub!]
(with-redefs [mbus/pub! (fn [bus & {:keys [topic message] :as opts}]
(when (= ntf/internal-revocation-topic topic)
(swap! events conj message))
(apply real bus (mapcat identity opts)))]
(f))
@events))
(defn- event-for
[events profile-id]
(first (filter #(= profile-id (:profile-id %)) events)))
(t/deftest removing-a-member-announces-the-revocation
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [events (captured-events
#(th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)}))]
(t/is (= {:type :profile-permissions-changed
:profile-id (:id editor)}
(event-for events (:id editor)))))))
(t/deftest a-role-change-announces-and-still-closes-nothing
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project)})
events (captured-events
#(th/command! {::th/type :update-team-member-role
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)
:role :viewer}))]
(t/testing "the change is announced so subscriptions get re-checked"
(t/is (some? (event-for events (:id editor)))))
(t/testing "a viewer can still read, so the subscription survives"
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))]
(t/is (zero? (ws/revalidate-profile-subscriptions
th/*system* (:id editor))))
(t/is (some? (subscribed-file conn))))))))
(t/deftest deleting-a-file-announces-to-the-whole-team
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project)})
events (captured-events
#(th/command! {::th/type :delete-file
::rpc/profile-id (:id owner)
:id (:id file)}))]
;; One event per team: the watcher resolves the members itself, so
;; a large team cannot overflow the bounded publish buffers.
(t/is (= [{:type :team-permissions-changed
:team-id (:id team)}]
events)))))
(t/deftest deleting-a-team-announces-once
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [events (captured-events
#(th/command! {::th/type :delete-team
::rpc/profile-id (:id owner)
:id (:id team)}))]
(t/is (= [{:type :team-permissions-changed
:team-id (:id team)}]
events)))))
(t/deftest team-event-closes-every-member-subscription
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)
other (th/create-profile* 3 {:is-active true})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id other)
:role :editor})
(let [other-project (th/create-project* 3 {:profile-id (:id other)
:team-id (:id team)})
other-file (th/create-file* 3 {:profile-id (:id other)
:project-id (:id other-project)})]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))
conn2 (-> (open-registered-connection (:id other))
(subscribe :subscribe-file {:file-id (:id other-file)}))]
(th/command! {::th/type :delete-team
::rpc/profile-id (:id owner)
:id (:id team)})
(t/testing "the announcement reaches the watcher off the bus"
(ntf/notify-team-permissions-changed th/*system* (:id team))
(t/is (wait-until #(nil? (subscribed-file conn)) 3000))
(t/is (wait-until #(nil? (subscribed-file conn2)) 3000)))))))
(t/deftest rolled-back-revocation-announces-nothing
(let [editor (th/create-profile* 2 {:is-active true})
events (captured-events
#(try
(db/tx-run! th/*system*
(fn [_cfg]
(ntf/notify-permissions-changed th/*system* (:id editor))
(throw (ex-info "boom" {}))))
(catch clojure.lang.ExceptionInfo _)))]
(t/is (empty? events))))
(t/deftest leaving-a-team-announces-and-tells-only-the-leaver
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
other (th/create-profile* 3 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(th/create-team-role* {:team-id (:id team)
:profile-id (:id other)
:role :editor})
(let [published (atom [])
real mbus/pub!]
(with-redefs [mbus/pub! (fn [bus & args]
(let [opts (apply hash-map args)]
(swap! published conj opts))
(apply real bus args))]
(th/command! {::th/type :leave-team
::rpc/profile-id (:id editor)
:id (:id team)}))
(t/testing "the leaver is told they were removed, so the client leaves"
(let [msg (->> @published
(filter #(= (:id editor) (:topic %)))
(map :message)
first)]
(t/is (= :team-membership-change (:type msg)))
(t/is (= :removed (:change msg)))
(t/is (= (:id team) (:team-id msg)))))
(t/testing "no other member is told they were removed"
(t/is (empty? (filter #(= (:id other) (:topic %)) @published)))))))
(t/deftest deleting-a-share-link-announces-nothing
;; A share-link visitor never passes the websocket subscribe check:
;; `check-read-permissions!` is called without a share-id, and that
;; arity only considers team membership. There is no subscription to
;; revoke, so announcing would be noise.
(let [owner (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})]
(th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:who-comment "none"
:who-inspect "none"
:pages #{}})
(let [link-id (:id (db/exec-one! th/*system*
["select id from share_link where file_id = ?"
(:id file)]))
events (captured-events
#(th/command! {::th/type :delete-share-link
::rpc/profile-id (:id owner)
:id link-id}))]
(t/is (some? link-id))
(t/is (empty? events)))))
;; --- PERIODIC REVALIDATION
;;
;; The announcement path is the primary mechanism. This interval is the
;; safety net for access changes that fire no command here, such as
;; Nitrate transferring the ownership of an organization, or rows
;; deleted straight over SQL.
(t/deftest revalidation-interval-defaults-to-five-minutes
(t/is (= (ct/duration {:minutes 5}) ws/default-revalidation-interval))
(t/is (= (ct/duration {:minutes 5}) (ws/revalidation-interval))))
(t/deftest revalidation-is-configurable
(with-redefs [cf/get (fn [k default] (if (= :subscription-revalidation-interval k)
(ct/duration {:seconds 30})
default))]
(t/is (= (ct/duration {:seconds 30}) (ws/revalidation-interval)))))
(t/deftest interval-recheck-drops-a-subscription-without-any-announcement
(let [{:keys [editor team file]} (editor-in-team! 1 2)]
(with-redefs [cf/get (fn [k default]
(if (= :subscription-revalidation-interval k)
(ct/duration {:millis 150})
default))]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))
out (:output conn)]
(t/testing "the subscription is live at first"
(t/is (some? (subscribed-file conn))))
;; Access is taken away the way an external change would take
;; it: a direct row delete fires no command and publishes no
;; announcement, so the interval is the only thing that can
;; close the subscription.
(db/exec! th/*system*
["delete from team_profile_rel where team_id = ? and profile_id = ?"
(:id team) (:id editor)])
(t/testing "the interval re-check drops it anyway"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000)))
(t/testing "and nothing reaches the client afterwards"
(mbus/pub! (::mbus/msgbus th/*system*)
:topic (:id file)
:message {:type :file-change :file-id (:id file) :revn 9})
(t/is (nil? (poll-msg! out :file-change
revocation-timeout-ms))))))))
(t/deftest interval-recheck-keeps-a-subscription-that-still-holds
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)]
(with-redefs [cf/get (fn [k default]
(if (= :subscription-revalidation-interval k)
(ct/duration {:millis 150})
default))]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))
out (:output conn)]
(t/testing "a downgrade to viewer still allows reading"
(t/is (th/success? (th/command! {::th/type :update-team-member-role
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)
:role :viewer}))))
;; Wait for at least two interval ticks, so a re-check that
;; wrongly closed the subscription would have run.
(Thread/sleep 500)
(t/testing "the subscription survives the interval"
(t/is (some? (subscribed-file conn))))
(t/testing "and traffic still flows"
(mbus/pub! (::mbus/msgbus th/*system*)
:topic (:id file)
:message {:type :file-change :file-id (:id file) :revn 3})
(t/is (some? (poll-msg! out :file-change
revocation-timeout-ms))))))))
(t/deftest interval-recheck-covers-the-team-subscription-too
(let [{:keys [editor team]} (editor-in-team! 1 2)]
(with-redefs [cf/get (fn [k default]
(if (= :subscription-revalidation-interval k)
(ct/duration {:millis 150})
default))]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-team {:team-id (:id team)}))]
(t/is (some? (subscribed-team conn)))
;; Same external-change shape as above: no command, no
;; announcement, only the interval.
(db/exec! th/*system*
["delete from team_profile_rel where team_id = ? and profile_id = ?"
(:id team) (:id editor)])
(t/testing "the team subscription is dropped by the interval"
(t/is (wait-until #(nil? (subscribed-team conn)) 3000)))))))
(t/deftest a-failing-check-keeps-the-subscription
(let [{:keys [editor file]} (editor-in-team! 1 2)]
(with-redefs [cf/get (fn [k default]
(if (= :subscription-revalidation-interval k)
(ct/duration {:millis 150})
default))]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))]
;; Access still holds, but the check itself blows up.
(with-redefs [files/has-read-permissions?
(fn [& _] (throw (RuntimeException. "db down")))]
(Thread/sleep 500)
(t/testing "a failing check keeps the subscription"
(t/is (some? (subscribed-file conn)))))))))
(t/deftest a-false-check-closes-the-subscription
(let [{:keys [editor file]} (editor-in-team! 1 2)]
(with-redefs [cf/get (fn [k default]
(if (= :subscription-revalidation-interval k)
(ct/duration {:millis 150})
default))]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))]
(with-redefs [files/has-read-permissions? (fn [& _] false)]
(t/testing "a check that returns false closes it"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000))))))))
(t/deftest resubscribe-stops-previous-revalidation
(let [profile (th/create-profile* 1 {:is-active true})
file1 (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
file2 (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)})]
(with-redefs [cf/get (fn [k default]
(if (= :subscription-revalidation-interval k)
(ct/duration {:millis 150})
default))]
(let [counts (atom {})
real files/has-read-permissions?]
(with-redefs [files/has-read-permissions?
(fn [cfg pid fid]
(swap! counts update fid (fnil inc 0))
(real cfg pid fid))]
(let [conn (-> (open-registered-connection (:id profile))
(subscribe :subscribe-file {:file-id (:id file1)}))]
(t/testing "the first subscription is re-checked on the interval"
(t/is (wait-until #(pos? (get @counts (:id file1) 0)) 3000)))
(t/testing "resubscribing moves the relay to the new file"
(subscribe conn :subscribe-file {:file-id (:id file2)})
(t/is (wait-until #(>= (get @counts (:id file2) 0) 2) 3000)))
(t/testing "the previous subscription stops revalidating"
(let [old (get @counts (:id file1) 0)]
(Thread/sleep 400)
(t/is (= old (get @counts (:id file1) 0)))))))))))
;; --- EFFECT TESTS
;;
;; Announcing is not the same as revoking: these assert the subscription
;; actually drops and the content stops flowing.
(t/deftest deleting-a-file-closes-open-subscriptions
(let [{:keys [owner editor file]} (editor-in-team! 1 2)
publish (publisher!)]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))
out (:output conn)]
(th/command! {::th/type :delete-file
::rpc/profile-id (:id owner)
:id (:id file)})
(t/testing "the file subscription drops without any manual publish"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000)))
(t/testing "no file content reaches the client afterwards"
(publish (:id file) {:type :file-change :file-id (:id file) :revn 9})
(t/is (nil? (poll-msg! out :file-change revocation-timeout-ms)))))))
(t/deftest deleting-a-project-closes-open-subscriptions
(let [{:keys [owner editor file]} (editor-in-team! 1 2)
project-id (:project-id file)]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))
out (:output conn)]
(th/command! {::th/type :delete-project
::rpc/profile-id (:id owner)
:id project-id})
(t/testing "the file subscription drops"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000)))
(t/testing "no file content reaches the client afterwards"
(mbus/pub! (::mbus/msgbus th/*system*)
:topic (:id file)
:message {:type :file-change :file-id (:id file) :revn 9})
(t/is (nil? (poll-msg! out :file-change revocation-timeout-ms)))))))
(t/deftest deleting-a-team-closes-open-subscriptions
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)})
(subscribe :subscribe-team {:team-id (:id team)}))]
(th/command! {::th/type :delete-team
::rpc/profile-id (:id owner)
:id (:id team)})
(t/testing "both subscriptions drop"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000))
(t/is (wait-until #(nil? (subscribed-team conn)) 3000))))))
(t/deftest moving-a-file-closes-source-team-subscriptions
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team-a (th/create-team* 1 {:profile-id (:id owner)})
team-b (th/create-team* 2 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team-a)
:profile-id (:id editor)
:role :editor})
(let [project-a (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team-a)})
project-b (th/create-project* 2 {:profile-id (:id owner)
:team-id (:id team-b)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project-a)})
conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))]
(th/command! {::th/type :move-files
::rpc/profile-id (:id owner)
:ids #{(:id file)}
:project-id (:id project-b)})
(t/testing "a member left behind in the source team loses the subscription"
(t/is (wait-until #(nil? (subscribed-file conn)) 3000))))))
(t/deftest close-team-subscription-is-idempotent
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile)})
other (th/create-team* 2 {:profile-id (:id profile)})
output (sp/chan :buf (sp/dropping-buffer 64))
relay (sp/chan :buf (sp/dropping-buffer 64))
ws-state (atom {::ws/team-subscription
{:team-id (:id team)
:channel relay
:topic (:id team)}})
wsp (assoc (make-wsp (:id profile) ws-state output)
::ws/state ws-state)]
(t/testing "closing a team the connection is not subscribed to does nothing"
(with-redefs [mbus/purge! (fn [& _] (throw ::unexpected-purge))]
(t/is (nil? (ws/close-team-subscription th/*system* wsp (:id other))))
(t/is (some? (::ws/team-subscription @ws-state)))))
(t/testing "closing the subscribed team purges the bus and the relay stops"
(let [purged (atom [])]
(with-redefs [mbus/purge! (fn [_ chans] (swap! purged conj chans))]
(ws/close-team-subscription th/*system* wsp (:id team))
(t/is (= 1 (count @purged)))))
(t/is (nil? (sp/poll! relay)))
(t/is (true? (sp/closed? relay))))
(t/testing "the subscription is forgotten, so closing again is a no-op"
(t/is (nil? (::ws/team-subscription @ws-state)))
(t/is (nil? (ws/close-team-subscription th/*system* wsp (:id team)))))))
(t/deftest losing-a-team-keeps-the-other-file-subscription
(let [{:keys [owner editor team]} (editor-in-team! 1 2)
other-team (th/create-team* 3 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id other-team)
:profile-id (:id editor)
:role :editor})
(let [other-project (th/create-project* 3 {:profile-id (:id editor)
:team-id (:id other-team)})
other-file (th/create-file* 3 {:profile-id (:id editor)
:project-id (:id other-project)})
conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-team {:team-id (:id team)})
(subscribe :subscribe-file {:file-id (:id other-file)}))]
(th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)})
(t/testing "the lost team subscription drops"
(t/is (wait-until #(nil? (subscribed-team conn)) 3000)))
(t/testing "the file subscription on the other team survives"
(t/is (some? (subscribed-file conn)))))))
;; The revocation watcher is the only integrant key here, so it is the
;; only one that compiles this schema at boot: a ref with no registered
;; schema crashes the boot with :malli.core/invalid-schema instead of
;; asserting. The test system builds the component, but assert-key only
;; runs its check with *assert* set, so pin the compilation here.
(t/deftest revocation-watcher-schema-resolves-every-ref
(t/testing "the schema compiles"
(t/is (sm/schema? (sm/schema @#'ws/schema:revocation-watcher))))
(t/testing "every ref the key injects is registered"
(t/is (= #{::db/pool ::mbus/msgbus}
(disj (set (sm/keys @#'ws/schema:revocation-watcher))
:app.nitrate/client))))
(t/testing "the nitrate client is optional, so a nil one passes"
(t/is (sm/valid? @#'ws/schema:revocation-watcher
{::db/pool (:app.db/pool th/*system*)
::mbus/msgbus (:app.msgbus/msgbus th/*system*)})))
(t/testing "a non-map nitrate client is rejected"
(t/is (not (sm/valid? @#'ws/schema:revocation-watcher
{::db/pool (:app.db/pool th/*system*)
::mbus/msgbus (:app.msgbus/msgbus th/*system*)
:app.nitrate/client "not-a-client"})))))
(t/deftest organization-owner-keeps-read-only-access
(let [{:keys [owner editor team file]} (editor-in-team! 1 2)]
(let [conn (-> (open-registered-connection (:id editor))
(subscribe :subscribe-file {:file-id (:id file)}))]
;; The admin-console flag is off in tests, so Nitrate never grants
;; this; stand in for an organization owner expelled from the team.
(with-redefs [nitrate/organization-owner-of-team? (fn [& _] true)]
(th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)})
(t/testing "revalidation closes nothing for an organization owner"
(t/is (zero? (ws/revalidate-profile-subscriptions
th/*system* (:id editor)))))
(t/testing "the read-only subscription survives"
(t/is (some? (subscribed-file conn))))))))