diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index c0667250f1..4cd1658906 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -25,12 +25,13 @@ - Team role flags are normalized as owner > admin > editor > viewer. Owner/admin imply edit; any membership row implies read. - File/project role rows only count with a live team membership: the file/project permission queries require a matching `team_profile_rel` row, and leaving or removing a member deletes their team-scoped `file`/`project`/`team-project-profile-rel` rows (GHSA-v9r9-h77c-55m2). - File/project/comment checks are implemented in the owning command namespaces, often via helpers imported from `files`, `teams`, or `projects`; do not bypass those helpers with direct DB lookups unless preserving their not-found semantics. -- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries. +- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries. RPCs that accept `share-id` enforce the link's page scope before mutating (`share-link-pages` + `check-page-scope`): a thread or page outside the link raises `:not-found`, and that check runs before the ownership checks. `get-profiles-for-file-comments` filters its query by the same pages. `mark-all-threads-as-read` does not take `share-id`. ## Teams, projects, and invitations - Team/project commands mix DB changes, email, message bus notifications, media/storage cleanup, feature flags, quotas, and audit metadata. Keep mutations transactional when the existing command does so. - Invitation flows validate muted/bounced emails before sending and use tokenized invitation state. Accepting an invitation is tied to the invited member identity, not just possession of a token. +- The whole invitation family is team-admin only: creating, updating and deleting invitations, minting the invitation token (`get-team-invitation-token`) and listing the pending ones (`get-team-invitations`, which carries the invited email and the offered role). The token of an invitation that grants the `owner` role is restricted to the team owner, because an admin cannot create nor promote an owner invitation (`cant-promote-to-owner`). This family is the exception to the rule above: it answers `:validation` / `:insufficient-permissions` instead of `:not-found`, so the five commands speak one language. - Logical deletion is used for many product objects; prefer existing logical-deletion helpers over hard deletes unless the command already performs permanent cleanup. - Bounced/spam-complaint emails can mute/block a profile for login/registration and email sending. Devenv Mailpit is the normal local path for registration/email-flow testing. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index f7bdc3020a..5976fac9ad 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -84,6 +84,7 @@ - The `media-storage-bytes-per-team` quota check on `upload-file-media-object` is TEMPORARILY DISABLED (2026-10-19, kept commented in `app.rpc.commands.media` with its `sql:get-team-id-for-file` lookup still active for the audit props): the quote performs a full `SUM` over `storage_object` per upload and was too slow in production. The `upload-sessions-per-profile` quota is unaffected and stays active. Re-enable only with a cheaper approximation. - Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. - SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. +- The SVG sanitizer (`app.media.svg`) is a **blocklist**, not an allowlist: it drops dangerous elements and attributes by name and escapes every text and attribute value when it writes the tree back, so a payload hidden in a value cannot become live markup again. The allowlist rework is [#12190](https://github.com/penpot/penpot/issues/12190) and lands in media-processor, not here. `sanitize-svg` also tracks the namespace bindings through the recursion and drops any element that is not in the SVG namespace, so a document whose root is not an `svg` element in the SVG namespace is rejected and a child that rebinds `xmlns` to XHTML (`iframe`/`srcdoc`/`meta`) is dropped with its subtree; it compares the scheme of an URL the way the browser does (dropping tab/CR/LF/spaces), so `java script:` does not slip through the `javascript:` filter. The paths that store an uploaded SVG as its final object go through `app.media.svg/sanitize-svg-upload` (media upload, the URL download, both thumbnail writers and the organization logo); the three binfile imports go through `app.binfile.common/sanitize-imported-svg`, which calls `sanitize-svg` directly. The profile picture is not an SVG: `import-profile-picture` ignores an `image/svg+xml` download, because the avatar is always rendered as an image. The caller checks the size before calling it: the helper reads the whole file, and the size limit applies to the upload, not to the sanitized copy, which can be up to six times bigger. - Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. - Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. - Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts. diff --git a/.serena/memories/frontend/dashboard-viewer-subtleties.md b/.serena/memories/frontend/dashboard-viewer-subtleties.md index fd5df5962b..14471e7195 100644 --- a/.serena/memories/frontend/dashboard-viewer-subtleties.md +++ b/.serena/memories/frontend/dashboard-viewer-subtleties.md @@ -7,6 +7,7 @@ - Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications. - The import wizard runs the package as a job of the backend: it reads the manifest of the file in the main thread (`app.main.data.imports`, no web worker) and follows the job, so its header paints the milestone of the job (the stage and its counter) instead of the messages the old SSE stream sent; every file of a container reports the same milestone, so `jp/current-milestone-text` writes it once and the rows never repeat it. Before the job exists, the header paints a client-side `:upload` milestone built from the chunked upload (`uploads/upload-blob-chunked` `:on-progress`, chunks done/total), which never comes from the backend; the chunks go one at a time and it writes them as a percentage of the file, not as a counter. Once created and still waiting for a worker, no row says anything: the header says the import waits for a slot while no job of it has started. The bar of the header measures the whole import, never a file: indeterminate while checking, waiting for a slot or rebuilding, because the client cannot know a global index and total of operations. The upload is the exception — the chunked upload knows its chunks, so the bar is determinate while the containers are sent, weighted by their size (`imp/upload-pct` over `:container-size`, the only percentage the client really knows). The export modal follows the same one-job-per-file model, but every file is queued up front (even the ones whose job is created only after the previous one is over) and its queue ends only with a milestone: `:start`/`:retry` do not end it there. Neither modal paints a generic "…ing file…" line on the rows any more: the rows of the import wizard carry only what is not progress — the name, the size, `Ready` once their container finished uploading, and the error text —, and the button that drops a file only shows while the wizard still offers its import step (the confirmation screen). Above the rows, the import wizard paints a stepper (Check/Upload/Import) plus one phase line: `imp/import-step` derives the step from the dialog status and the entries (upload holds while any file is pending, either `:import-ready` or carrying its own `:upload` milestone), so Check covers analyze, Upload covers the chunked client-side upload, and Import covers queue plus job. A file of a version 3 package shows what it occupies inside its container, computed while analyzing (`imp/file-sizes`): its own `files/…` entries plus the storage objects its media and thumbnails reference, whose metadata is the only link to `objects/…`. The container's own size stays in `:container-size` (what the dropped file weighs) and is what the upload bar weighs, never what a row shows. A file the manifest lists but the container holds no entry of has no size at all, and its row then shows no subtitle: what the package weighs never stands in for it. The subtitle is the size alone, with no extension. While analyzing with no failures nothing renders below the phase header — no rows, no spinner (`imp/awaiting-analysis?`); the header text is the only "Checking…" message. Rows appear once the analysis resolves (one per file inside the package), and a failed analysis takes the error-summary path instead. The ready step and its Continue wait for the end of the analysis (`imp/ready-to-import?`, fed with the completion of `imp/analyze`): a ready file is not enough, because the files still to arrive would never be imported. While rebuilding, the phase title counts the files and `imp/imported-count` counts them done with the job's own `:files` counter, so the number moves while a container rebuilds its files one by one. The `:error` of an import entry is user-facing text (the backend hint, or a translated label), never an i18n key: the wizard renders it as-is, it does not translate it again. The link-later step takes the file id from the `:id` of its resolution entry, never from the key of the resolution map: the map comes from the job result, so what a key holds depends on the result schema of the job-def that wrote it, while the wizard indexes its selection by the id of the entry. The legacy SSE import stays in the backend for other clients. - File creation/duplication strips file `:data` before putting file summaries into dashboard state. +- The pending invitations are a team-admin only list (backend `:is-admin` already includes the owner). The dashboard hides the invitations entry from the header tabs and the team-options dropdown for non-admins, `invitations-section-content` keys the section on `:is-admin`, and `fetch-invitations` answers `insufficient-permissions` by emitting `invitations-fetched` with `[]`, so a demoted admin does not keep the cached emails and the header does not paint the invite button from a stale list. The organization `send-invitations` policy only gates inviting and resending, not the list. ## Viewer diff --git a/backend/src/app/media/svg.clj b/backend/src/app/media/svg.clj index 2ef7112815..6d38011b4d 100644 --- a/backend/src/app/media/svg.clj +++ b/backend/src/app/media/svg.clj @@ -12,8 +12,10 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] + [app.storage.tmp :as tmp] [clojure.xml :as xml] - [cuerdas.core :as str]) + [cuerdas.core :as str] + [datoteka.fs :as fs]) (:import clojure.lang.XMLHandler java.io.InputStream @@ -46,15 +48,85 @@ (xml/parse istream secure-parser-factory)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG SERIALIZATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private xml-declaration + "Declaration written at the top of every sanitized document." + "") + +(defn- escape-text + "Escape the characters that are special inside a XML text node." + [s] + (-> s + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">"))) + +(defn- escape-attr + "Escape the characters that are special inside a XML attribute value. + The value is written between double quotes, so the quote itself is + escaped too. + + Tabs and line breaks become spaces: that is what the XML parser does + with them on the next read, and it keeps the output stable." + [s] + (-> (str s) + (str/replace #"[\t\r\n]" " ") + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">") + (str/replace "\"" """) + (str/replace "'" "'"))) + +(defn- emit-element + "Serialize a parsed element, escaping every text and attribute value. + + The tag and attribute names come from the parser, so they are valid + XML names; only their values can carry markup. + + The attributes are sorted by name: the parser hands them over in the + reverse order of the document, so writing them as they come would + make the output change on every sanitization." + [{:keys [tag attrs content]}] + (let [attrs-str (apply str + (for [[k v] (sort-by (comp name key) attrs)] + (str " " (name k) "=\"" (escape-attr (str v)) "\""))) + content-str (apply str + (for [child content] + (if (map? child) + (emit-element child) + (escape-text child))))] + (if (seq content) + (str "<" (name tag) attrs-str ">" content-str "") + (str "<" (name tag) attrs-str "/>")))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SVG SANITIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private dangerous-attrs-pattern #"(?i)^on\w+$") (def ^:private javascript-href-pattern #"(?i)^javascript:") +(def ^:private url-ignored-chars + "Characters that the URL parser discards before it interprets the + scheme of an URL." + #"[\u0000-\u0020\u007f]") + +(defn- javascript-url? + "True when the value is a javascript: URL. + + The comparison drops the characters the URL parser drops (tab, CR, LF + and spaces), so a scheme split with one of them (`java script:`) + cannot slip through." + [v] + (let [v (str/replace (str v) url-ignored-chars "")] + (boolean (re-find javascript-href-pattern (str/trim v))))) + (def ^:private dangerous-tags #{"script" "foreignObject" "set" - "animate" "animateTransform" "animateColor" "animateMotion"}) + "animate" "animateTransform" "animateColor" "animateMotion" + "iframe" "meta" "embed" "link" "base"}) (defn- local-name "Return the local name of a parsed tag or attribute keyword. @@ -70,24 +142,82 @@ (last (str/split n ":")) n))) +(def ^:private svg-namespace "http://www.w3.org/2000/svg") + +(defn- namespace-bindings + "Return the namespace bindings in scope for an element: `::default` + plus the keyword prefixes declared in `attrs`, merged over the + inherited ones. The default binding is stored under a namespaced + keyword because a prefix cannot contain `/`, so no prefixed binding + can produce the same key. + + The XML parser does not process namespaces, so the declarations are + plain attributes (`:xmlns`, `:xmlns:svg`) and the prefix is part of + the tag name (`:svg:svg`)." + [attrs inherited] + (let [own (reduce-kv (fn [acc k v] + (let [n (name k)] + (cond + (= n "xmlns") (assoc acc ::default v) + (str/starts-with? n "xmlns:") (assoc acc (keyword (subs n 6)) v) + :else acc))) + {} + attrs)] + (merge inherited own))) + +(defn- element-namespace + "Return the namespace URI of `tag` under `bindings`: the default + namespace when the tag has no prefix, the prefixed binding otherwise. + + The prefix is part of the tag name (`:svg:svg`), so the binding key + is the prefix." + [tag bindings] + (let [n (name tag) + idx (str/last-index-of n ":")] + (if (nil? idx) + (::default bindings) + (get bindings (keyword (subs n 0 idx)))))) + +(defn- svg-root? + "True when the element is an `svg` element in the SVG namespace. + + The namespace is resolved with the same model as the recursion + (`namespace-bindings` + `element-namespace`), so the check does not + introduce a second rule." + [{:keys [tag attrs]}] + (let [bindings (namespace-bindings attrs {})] + (and (= "svg" (local-name tag)) + (= svg-namespace (element-namespace tag bindings))))) + (defn- sanitize-svg-element - "Recursively sanitize an SVG element by removing dangerous tags and attributes." - [{:keys [tag attrs content] :as element}] - (when (and (map? element) tag) - (when-not (contains? dangerous-tags (local-name tag)) - (let [clean-attrs (->> attrs - (remove (fn [[k v]] - (or (re-matches dangerous-attrs-pattern (local-name k)) - (and (= "href" (local-name k)) - (string? v) - (re-find javascript-href-pattern (str/trim v)))))) - (into {})) + "Recursively sanitize an SVG element by removing dangerous tags and + attributes. + + The namespace bindings are tracked through the recursion: an element + that is not in the SVG namespace is dropped with its whole subtree, + because the filter only knows about SVG elements and a child that + rebinds the namespace to XHTML can carry live HTML." + [element bindings] + (let [{:keys [tag attrs content]} element + bindings (namespace-bindings attrs bindings)] + (when (and (map? element) tag + (not (contains? dangerous-tags (local-name tag))) + (= svg-namespace (element-namespace tag bindings))) + (let [clean-attrs (->> attrs + (remove (fn [[k v]] + (let [n (name k)] + (or (and (not (str/starts-with? n "xmlns")) + (re-matches dangerous-attrs-pattern (local-name k))) + (and (= "href" (local-name k)) + (string? v) + (javascript-url? v)))))) + (into {})) clean-content (when content (->> content (filter #(or (string? %) (map? %))) (map (fn [child] (if (map? child) - (sanitize-svg-element child) + (sanitize-svg-element child bindings) child))) (filter some?) vec))] @@ -97,22 +227,65 @@ (defn sanitize-svg "Sanitize SVG content by removing dangerous elements and attributes. Removes " result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "foreignObject"))) - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "alert('xss')" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "alert('xss')" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "alert('xss')" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "foreignObject"))) - (t/is (not (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "javascript:"))) - (t/is (not (clojure.string/includes? result "alert"))) - (t/is (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "onload"))) - (t/is (not (clojure.string/includes? result "onclick"))) - (t/is (not (clojure.string/includes? result "alert"))) - (t/is (clojure.string/includes? result "" result (svg/sanitize-svg svg)] - (t/is (not (clojure.string/includes? result "javascript:"))) - (t/is (not (clojure.string/includes? result "alert"))) - (t/is (clojure.string/includes? result "hola" result (svg/sanitize-svg svg)] - (t/is (clojure.string/includes? result "xml:space")) - (t/is (clojure.string/includes? result "hola"))))) + (t/is (str/includes? result "xml:space")) + (t/is (str/includes? result "hola"))))) + +;; --- SVG sanitization: escaping on serialization +;; +;; The sanitizer filters dangerous elements and attributes by name, so the +;; escaping happens when the tree is written back. A payload hidden in an +;; attribute value or in a text node must not become live markup again. + +(def ^:private clean-svg + "") + +(def ^:private evil-attribute-svg + ""] + (with-redefs [app.config/config (assoc app.config/config :media-max-file-size 1)] + (let [params {::th/type :upload-organization-logo + ::rpc/profile-id (:id profile) + :organization-id (uuid/random) + :content {:filename "big.svg" + :path path + :mtype "image/svg+xml" + :size (fs/size path)}} + out (th/multipart-management-command! params)] + (t/is (th/ex-of-type? (:error out) :restriction)) + (t/is (th/ex-of-code? (:error out) :media-max-file-size-reached)))))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 3b42685b4d..428489e9a4 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -10,10 +10,12 @@ [app.db :as db] [app.http.client :as http] [app.media :as media] + [app.media.svg :as svg] [app.rpc :as-alias rpc] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] + [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io] [mockery.core :refer [with-mocks]]) @@ -23,6 +25,14 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(def ^:private evil-svg + "An SVG that smuggles an event handler inside an attribute value." + ""))) + (t/is (= "x' onload='alert(1)" (get (:attrs parsed) :id))) + (t/is (str/includes? content "'"))))))) + +(def ^:private xhtml-child-svg + "An SVG whose root is in the SVG namespace but whose child rebinds the + default namespace to XHTML and embeds live HTML." + "") + +(t/deftest create-file-media-object-from-url-sanitizes-xhtml-child + (let [prof (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + body (.getBytes ^String xhtml-child-svg "UTF-8")] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 200 + :headers {"content-type" "image/svg+xml" + "content-length" (str (alength ^bytes body))} + :body (java.io.ByteArrayInputStream. ^bytes body)}}] + (let [params {::th/type :create-file-media-object-from-url + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :url "https://example.com/evil.svg"} + out (th/command! params)] + (t/is (nil? (:error out))) + + (let [{:keys [media-id]} (:result out) + storage (:app.storage/storage th/*system*) + sobject (sto/get-object storage media-id) + content (slurp (sto/get-object-data storage sobject))] + (t/is (not (str/includes? content "iframe"))) + (t/is (not (str/includes? content "srcdoc"))) + (t/is (not (str/includes? content "http-equiv")))))))) + (t/deftest media-object-from-url-command (let [prof (th/create-profile* 1) proj (th/create-project* 1 {:profile-id (:id prof) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 277228ffcd..51a1e746e1 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -15,7 +15,7 @@ [app.db :as db] [app.email.blacklist :as email.blacklist] [app.http :as http] - [app.loggers.audit :as-alias audit] + [app.loggers.audit :as audit] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.teams :as teams] @@ -25,11 +25,23 @@ [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]] + [yetti.request])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +;; Minimal request stub, so the audit event can be built the way the backend +;; builds it: with the client ip in the event context. +(def ^:private http-request + (reify + yetti.request/IRequest + (get-header [_ header] + (case header + "x-forwarded-for" "127.0.0.44" + "x-real-ip" "127.0.0.43" + nil)))) + (t/deftest create-team-invitations (with-mocks [mock {:target 'app.email/send-template :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) @@ -479,26 +491,229 @@ (t/is (= (:id profile2) (:member-id claims)))))))) -(t/deftest get-team-invitation-token-requires-edition-permissions - (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)}) +(t/deftest get-team-invitation-token-requires-admin-permissions + ;; The token is a bearer credential: whoever holds it accepts the + ;; invitation with the role of the invitation row. Minting it takes the + ;; same permissions as the rest of the invitation commands, so an editor + ;; (the lowest role above viewer) must not get it. + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + editor (th/create-profile* 3 {:is-active true}) + admin (th/create-profile* 4 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) pool (:app.db/pool th/*system*)] (th/create-team-role* {:team-id (:id team) - :profile-id (:id profile2) + :profile-id (:id viewer) :role :viewer}) + (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 admin) + :role :admin}) (db/insert! pool :team-invitation {:team-id (:id team) :email-to "victim@example.com" :role "editor" + :created-by (:id owner) :valid-until (ct/in-future "48h")}) - (let [data {::th/type :get-team-invitation-token - ::rpc/profile-id (:id profile2) - :team-id (:id team) - :email "victim@example.com"} - out (th/command! data)] - (t/is (not (th/success? out))) - (t/is (= :not-found (-> out :error ex-data :type)))))) + (let [request (fn [profile-id] + {::th/type :get-team-invitation-token + ::rpc/profile-id (:id profile-id) + :team-id (:id team) + :email "victim@example.com"})] + (t/testing "a viewer cannot get the token" + (let [out (th/command! (request viewer))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an editor cannot get the token" + (let [out (th/command! (request editor))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an admin can get the token" + (let [out (th/command! (request admin))] + (t/is (th/success? out)) + (let [claims (tokens/decode th/*system* (-> out :result :token))] + (t/is (= :team-invitation (:iss claims))) + (t/is (= :editor (:role claims))) + (t/is (= (:id team) (:team-id claims))) + (t/is (= "victim@example.com" (:member-email claims)))))) + + (t/testing "the team owner can get the token" + (let [out (th/command! (request owner))] + (t/is (th/success? out))))))) + + +(t/deftest get-team-invitation-token-owner-invitation-requires-team-owner + ;; An admin cannot create nor promote an owner invitation, so it cannot + ;; mint the token of one either: only the team owner can. + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "new-owner@example.com" + :role "owner" + :created-by (:id owner) + :valid-until (ct/in-future "48h")}) + (let [request (fn [profile-id] + {::th/type :get-team-invitation-token + ::rpc/profile-id (:id profile-id) + :team-id (:id team) + :email "new-owner@example.com"})] + (t/testing "an admin cannot get the token of an owner invitation" + (let [out (th/command! (request admin))] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)))) + + (t/testing "the team owner can get the token of an owner invitation" + (let [out (th/command! (request owner))] + (t/is (th/success? out)) + (let [claims (tokens/decode th/*system* (-> out :result :token))] + (t/is (= :owner (:role claims))) + (t/is (= "new-owner@example.com" (:member-email claims))))))))) + + +(t/deftest get-team-invitation-token-emits-audit-event + ;; Minting the token is an action, not a read: the event is named as such + ;; and carries the granted role, so the extraction of the token does not go + ;; unnoticed in the audit log. + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + (let [invitation (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :created-by (:id owner) + :valid-until (ct/in-future "48h")})] + + (let [params {::rpc/profile-id (:id admin) + :team-id (:id team) + :email "victim@example.com"} + result (th/command-raw! (assoc params ::th/type :get-team-invitation-token)) + [mdata _] (get-in th/*system* [:app.rpc/methods :get-team-invitation-token]) + ;; The cfg of a command carries its type, the same way + ;; `resolve-methods` does it in production. + event (audit/prepare-rpc-event (assoc th/*system* ::rpc/module "main" + ::rpc/type "command") + mdata + (with-meta params {:app.http/request http-request}) + result)] + (t/is (= "create-team-invitation-token" (:name event))) + (t/is (= "command" (:type event))) + (t/is (= (:id admin) (:profile-id event))) + (t/is (= (:id team) (get-in event [:props :team-id]))) + (t/is (= "victim@example.com" (get-in event [:props :email]))) + (t/is (= :editor (get-in event [:props :role]))) + (t/is (= (:id invitation) (get-in event [:props :invitation-id]))) + (t/is (not (contains? (:props event) :token))))))) + + +(t/deftest get-team-invitations-requires-admin-permissions + ;; The pending invitations carry the email of the invited person and the + ;; role offered to them: the same data the rest of the invitation + ;; commands already require admin for. + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + editor (th/create-profile* 3 {:is-active true}) + admin (th/create-profile* 4 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + (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 admin) + :role :admin}) + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :created-by (:id owner) + :valid-until (ct/in-future "48h")}) + (let [request (fn [profile-id] + {::th/type :get-team-invitations + ::rpc/profile-id (:id profile-id) + :team-id (:id team)})] + (t/testing "a viewer cannot list the pending invitations" + (let [out (th/command! (request viewer))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an editor cannot list the pending invitations" + (let [out (th/command! (request editor))] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "an admin can list the pending invitations" + (let [out (th/command! (request admin))] + (t/is (th/success? out)) + (t/is (= 1 (count (:result out)))) + (t/is (= "victim@example.com" (:email (first (:result out))))))) + + (t/testing "the team owner can list the pending invitations" + (let [out (th/command! (request owner))] + (t/is (th/success? out)) + (t/is (= 1 (count (:result out))))))))) + + +(t/deftest invitation-permissions-deny-non-members + ;; A profile that is not a member of the team has no permissions row at + ;; all, so the check gets nil instead of a map of flags. That is also + ;; the case of an organization owner that is not a member of the team: + ;; the team permissions lookup is membership only, so they are left out + ;; of the invitation commands too. + ;; + ;; NOTE: the audit wrapper is installed when the system boots, and only + ;; when the audit log, webhooks or telemetry flags are on, none of them + ;; is on in the test environment. So there is no way to observe here + ;; whether a denied call emits an event: that is a property of the + ;; wrapper, shared by every command. + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + pool (:app.db/pool th/*system*)] + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :created-by (:id owner) + :valid-until (ct/in-future "48h")}) + + (t/testing "a non member cannot get the token" + (let [out (th/command! {::th/type :get-team-invitation-token + ::rpc/profile-id (:id outsider) + :team-id (:id team) + :email "victim@example.com"})] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))) + + (t/testing "a non member cannot list the pending invitations" + (let [out (th/command! {::th/type :get-team-invitations + ::rpc/profile-id (:id outsider) + :team-id (:id team)})] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (th/ex-of-code? (:error out) :insufficient-permissions)))))) (t/deftest accept-invitation-tokens diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index 3371dcd592..ff993be7d9 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -462,6 +462,18 @@ (rx/map #(partial fetched-comments %)) (rx/catch #(rx/throw {:type :comment-error}))))))) +;; Has to fit in the limit of the backend rpc +;; (backend/src/app/rpc/commands/comments.clj). +(def ^:private mark-all-threads-batch-size 500) + +(defn mark-all-threads-batches + "Splits the thread ids into the batches that the backend accepts on a + single call." + [thread-ids] + (->> thread-ids + (partition-all mark-all-threads-batch-size) + (mapv vec))) + (defn mark-all-threads-as-read "Mark all threads as read" [team-id] @@ -470,11 +482,23 @@ (-data [_] {}) ptk/WatchEvent (watch [_ state _] - (let [threads (-> state :comment-threads vals)] + (let [threads (-> state :comment-threads vals) + batches (mark-all-threads-batches (mapv :id threads)) + refresh-event (retrieve-unread-comment-threads team-id)] (rx/concat - (->> (rp/cmd! :mark-all-threads-as-read {:threads (mapv :id threads)}) - (rx/map #(retrieve-unread-comment-threads team-id)) - (rx/catch #(rx/throw {:type :comment-error}))) + (->> (rx/from batches) + (rx/mapcat (fn [batch] + (rp/cmd! :mark-all-threads-as-read {:threads batch}))) + (rx/reduce (fn [_ _] nil) nil) + (rx/map (fn [_] refresh-event)) + ;; Each batch commits on the server on its own, so a failure in + ;; a later batch leaves the previous ones already marked as + ;; read. The unread list is re-read anyway and the error is + ;; propagated afterwards, so the success toast below stays + ;; limited to a full success. + (rx/catch (fn [_] + (rx/concat (rx/of refresh-event) + (rx/throw {:type :comment-error}))))) (rx/of (ntf/show {:level :info :type :toast :content (tr "dashboard.mark-all-as-read.success") diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index e8afbfc994..fe2b796ec8 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -259,7 +259,20 @@ (watch [_ state _] (let [team-id (:current-team-id state)] (->> (rp/cmd! :get-team-invitations {:team-id team-id}) - (rx/map (partial invitations-fetched team-id))))))) + (rx/map (partial invitations-fetched team-id)) + (rx/catch (fn [cause] + (let [{:keys [type code]} (ex-data cause)] + (if (and (= :validation type) + (= :insufficient-permissions code)) + ;; The pending invitations are an admin only + ;; list, so a denied refetch means the cached + ;; copy is no longer valid: drop it instead of + ;; leaving stale emails around. + (do + (log/warn :hint "fetch-invitations: no permission, clearing" + :team-id (str team-id)) + (rx/of (invitations-fetched team-id []))) + (rx/throw cause)))))))))) (defn- team-initialized [team-id] diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs index 2124f5d673..521d98076c 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch_menus.cljs @@ -180,10 +180,12 @@ :data-testid "team-members"} (tr "labels.members")] - [:> dropdown-menu-item* {:on-click on-invitations-click - :class (stl/css :options-item) - :data-testid "team-invitations"} - (tr "labels.invitations")] + ;; The pending invitations are an admin only list. + (when (:is-admin permissions) + [:> dropdown-menu-item* {:on-click on-invitations-click + :class (stl/css :options-item) + :data-testid "team-invitations"} + (tr "labels.invitations")]) (when (contains? cf/flags :webhooks) [:> dropdown-menu-item* {:on-click on-webhooks-click diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 5629337572..4d74599557 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -70,6 +70,7 @@ invitations-section? (= section :dashboard-team-invitations) webhooks-section? (= section :dashboard-team-webhooks) permissions (:permissions team) + is-admin? (boolean (:is-admin permissions)) can-invite? (dnt/can-send-invitations? {:organization (:organization team) :profile-id (:id profile) @@ -110,10 +111,11 @@ [:a {:class (stl/css-case :dashboard-header-link true :active members-section?) :on-click on-nav-members} (tr "labels.members")]] - [:li - [:a {:class (stl/css-case :dashboard-header-link true - :active invitations-section?) - :on-click on-nav-invitations} (tr "labels.invitations")]] + (when is-admin? + [:li + [:a {:class (stl/css-case :dashboard-header-link true + :active invitations-section?) + :on-click on-nav-invitations} (tr "labels.invitations")]]) (when (contains? cfg/flags :webhooks) [:li [:a {:class (stl/css-case :dashboard-header-link true @@ -124,7 +126,9 @@ :active settings-section?) :on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} - (when (and (or invitations-section? members-section?) (not-empty invitations)) + (when (and is-admin? + (or invitations-section? members-section?) + (not-empty invitations)) (let [invite-button (mf/html [:> button* {:class (stl/css :invite-button) :variant "secondary" @@ -661,7 +665,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (mf/defc invitation-role-selector* - [{:keys [can-invite role status on-change]}] + [{:keys [role status on-change]}] (let [show? (mf/use-state false) label (cond (= role :owner) (tr "labels.owner") @@ -682,7 +686,7 @@ (on-change role event))))] [:* - (if (and can-invite (= status :pending)) + (if (= status :pending) [:button {:class (stl/css :rol-selector :has-priv) :type "button" :aria-haspopup "listbox" @@ -709,9 +713,16 @@ :on-click on-change'} (tr "labels.viewer")]]]])) +(defn invitation-link-copyable? + "The link of an owner invitation can only be copied by the team owner: an + admin cannot create nor promote an owner invitation, so the backend does + not mint its token for them." + [is-owner? role] + (or (not= :owner role) is-owner?)) + (mf/defc invitation-actions* {::mf/private true} - [{:keys [invitation team-id]}] + [{:keys [invitation team-id is-owner?]}] (let [email (:email invitation) copied* (mf/use-state false) copied? (deref copied*) @@ -757,15 +768,16 @@ (-> (dtm/copy-invitation-link params) (with-meta {::ev/origin :team}))))))] - [:> icon-button* {:variant "ghost" - :aria-label (tr "labels.copy-invitation-link") - :on-click on-copy - :icon (if copied? "tick" "clipboard")}])) + (when (invitation-link-copyable? is-owner? (:role invitation)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.copy-invitation-link") + :on-click on-copy + :icon (if copied? "tick" "clipboard")}]))) (mf/defc invitation-row* {::mf/wrap [mf/memo] ::mf/private true} - [{:keys [invitation can-invite team-id selected on-select-change]}] + [{:keys [invitation team-id selected on-select-change is-owner?]}] (let [expired? (:expired invitation) email (:email invitation) @@ -783,12 +795,11 @@ on-change (mf/use-fn - (mf/deps can-invite on-select-change) + (mf/deps on-select-change) (fn [event] - (when can-invite - (let [email (-> (dom/get-current-target event) - (dom/get-data "attr"))] - (on-select-change email))))) + (let [email (-> (dom/get-current-target event) + (dom/get-data "attr"))] + (on-select-change email)))) on-change-role (mf/use-fn @@ -800,26 +811,23 @@ [:div {:class (stl/css :table-row :table-row-invitations)} [:div {:class (stl/css :table-field :field-email)} - (if can-invite - [:div {:class (stl/css :input-wrapper)} - [:label - [:span {:class (stl/css-case :input-checkbox true - :global/checked (is-selected? email))} - deprecated-icon/status-tick] + [:div {:class (stl/css :input-wrapper)} + [:label + [:span {:class (stl/css-case :input-checkbox true + :global/checked (is-selected? email))} + deprecated-icon/status-tick] - [:input {:type "checkbox" - :id (dm/str "email-" email) - :data-attr email - :value email - :checked (is-selected? email) - :on-change on-change}] - email]] - [:div email])] + [:input {:type "checkbox" + :id (dm/str "email-" email) + :data-attr email + :value email + :checked (is-selected? email) + :on-change on-change}] + email]]] [:div {:class (stl/css :table-field :field-roles)} [:> invitation-role-selector* - {:can-invite can-invite - :role role + {:role role :status status :on-change on-change-role}]] @@ -827,14 +835,14 @@ [:& badge-notification {:type type :content badge-content}]] [:div {:class (stl/css :table-field :field-actions)} - (when ^boolean can-invite - [:> invitation-actions* - {:invitation invitation - :team-id team-id}])]])) + [:> invitation-actions* + {:invitation invitation + :team-id team-id + :is-owner? is-owner?}]]])) (mf/defc empty-invitation-table* {::mf/private true} - [{:keys [can-invite team]}] + [{:keys [team]}] (let [route (mf/deref refs/route) invite-email (-> route :query-params :invite-email) @@ -846,15 +854,12 @@ :invite-email invite-email}))))] [:div {:class (stl/css :empty-invitations)} [:div (tr "labels.no-invitations")] - (if ^boolean can-invite - [:* - [:div (tr "labels.no-invitations-gather-people")] - [:> button* {:variant "primary" - :class (stl/css :btn-empty-invitations) - :on-click on-invite-member - :data-testid "invite-member"} - (tr "dashboard.invite-profile")]] - [:div {:class (stl/css :no-permission-text)} (tr "dashboard.invitations.no-permission")])])) + [:div (tr "labels.no-invitations-gather-people")] + [:> button* {:variant "primary" + :class (stl/css :btn-empty-invitations) + :on-click on-invite-member + :data-testid "invite-member"} + (tr "dashboard.invite-profile")]])) (mf/defc invitation-modal {::mf/register modal/components @@ -1059,6 +1064,17 @@ :on-click on-confirm'} accept]]]]])) +(defn invitations-section-content + "What the invitations section shows: the list of the pending invitations + for the team admins, or just the permission notice. The pending invitations + are an admin only list, and `:is-admin` already includes the team owner. + The organization invite policy does not gate the list, only the invite + actions." + [permissions] + (if (:is-admin permissions) + :list + :no-permission)) + (mf/defc invitation-section* {::mf/private true} [{:keys [team profile]}] @@ -1072,6 +1088,8 @@ :profile-id (:id profile) :team-permissions permissions}) + is-owner? (boolean (:is-owner permissions)) + selected (mf/use-state #{}) ;; Sort state: {:field :status/:role, :direction :asc/:desc} @@ -1082,12 +1100,11 @@ on-select-change (mf/use-fn - (mf/deps can-invite? selected) + (mf/deps selected) (fn [email] - (when can-invite? - (if (contains? @selected email) - (swap! selected disj email) - (swap! selected conj email))))) + (if (contains? @selected email) + (swap! selected disj email) + (swap! selected conj email)))) on-confirm-delete (mf/use-fn @@ -1197,57 +1214,63 @@ (reset! sort-state {:field nil :direction :asc})) [:div {:class (stl/css :invitations)} - (when (and (not can-invite?) - (seq @invitations)) + ;; The pending invitations are an admin only list: a member that is not + ;; a team admin does not get them, so there is nothing to show but the + ;; permission notice. + (case (invitations-section-content permissions) + :no-permission [:div {:class (stl/css :empty-invitations)} [:div {:class (stl/css :no-permission-text)} - (tr "dashboard.invitations.no-permission")]]) - (when (and can-invite? - (> (count @selected) 0)) + (tr "dashboard.invitations.no-permission")]] + [:* - [:div {:class (stl/css :invitations-actions)} - [:div - (tr "team.invitations-selected" (i18n/c (count @selected)))] - [:div - [:> button* {:variant "secondary" - :type "button" - :on-click on-resend} - (tr "labels.resend-invitation")]] - [:> icon-button* {:on-click on-delete - :variant "destructive" - :aria-label (tr "labels.delete-invitation") - :icon "delete"}]]]) - [:div {:class (stl/css :table-header)} - [:div {:class (stl/css :title-field-name)} (tr "labels.invitations")] - [:div {:class (stl/css :title-field-role)} (tr "labels.role") - [:> icon-button* {:variant "action" - :class (stl/css-case :sort-active (= (:field @sort-state) :role) - :sort-inactive (not= (:field @sort-state) :role)) - :aria-label (tr "dashboard.order-invitations-by-role") - :icon (if (= (:field @sort-state) :role) - (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") - "arrow-down") - :on-click on-order-by-role}]] - [:div {:class (stl/css :title-field-status)} (tr "labels.status") - [:> icon-button* {:variant "action" - :class (stl/css-case :sort-active (= (:field @sort-state) :status) - :sort-inactive (not= (:field @sort-state) :status)) - :aria-label (tr "dashboard.order-invitations-by-status") - :icon (if (= (:field @sort-state) :status) - (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") - "arrow-down") - :on-click on-order-by-status}]]] - (if (empty? @invitations) - [:> empty-invitation-table* {:can-invite can-invite? :team team}] - [:div {:class (stl/css :table-rows)} - (for [invitation @invitations] - [:> invitation-row* - {:key (:email invitation) - :invitation invitation - :can-invite can-invite? - :team-id team-id - :selected selected - :on-select-change on-select-change}])])])) + (when (> (count @selected) 0) + [:div {:class (stl/css :invitations-actions)} + [:div + (tr "team.invitations-selected" (i18n/c (count @selected)))] + ;; Resending goes through the invite command, so it needs the + ;; organization invite policy; deleting only needs team admin. + (when can-invite? + [:div + [:> button* {:variant "secondary" + :type "button" + :on-click on-resend} + (tr "labels.resend-invitation")]]) + [:> icon-button* {:on-click on-delete + :variant "destructive" + :aria-label (tr "labels.delete-invitation") + :icon "delete"}]]) + [:div {:class (stl/css :table-header)} + [:div {:class (stl/css :title-field-name)} (tr "labels.invitations")] + [:div {:class (stl/css :title-field-role)} (tr "labels.role") + [:> icon-button* {:variant "action" + :class (stl/css-case :sort-active (= (:field @sort-state) :role) + :sort-inactive (not= (:field @sort-state) :role)) + :aria-label (tr "dashboard.order-invitations-by-role") + :icon (if (= (:field @sort-state) :role) + (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") + "arrow-down") + :on-click on-order-by-role}]] + [:div {:class (stl/css :title-field-status)} (tr "labels.status") + [:> icon-button* {:variant "action" + :class (stl/css-case :sort-active (= (:field @sort-state) :status) + :sort-inactive (not= (:field @sort-state) :status)) + :aria-label (tr "dashboard.order-invitations-by-status") + :icon (if (= (:field @sort-state) :status) + (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") + "arrow-down") + :on-click on-order-by-status}]]] + (if (empty? @invitations) + [:> empty-invitation-table* {:team team}] + [:div {:class (stl/css :table-rows)} + (for [invitation @invitations] + [:> invitation-row* + {:key (:email invitation) + :invitation invitation + :is-owner? is-owner? + :team-id team-id + :selected selected + :on-select-change on-select-change}])])])])) (mf/defc team-invitations-page* [{:keys [team profile]}] diff --git a/frontend/test/frontend_tests/data/comments_batches_test.cljs b/frontend/test/frontend_tests/data/comments_batches_test.cljs new file mode 100644 index 0000000000..7bab3fa964 --- /dev/null +++ b/frontend/test/frontend_tests/data/comments_batches_test.cljs @@ -0,0 +1,95 @@ +;; 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/. + +(ns frontend-tests.data.comments-batches-test + (:require + [app.common.uuid :as uuid] + [app.main.data.comments :as dcmt] + [app.main.data.notifications :as ntf] + [app.main.repo :as rp] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(defn- comment-threads-state + [n] + {:comment-threads (into {} (map (fn [_] [(uuid/next) {:id (uuid/next)}]) (range n)))}) + +(t/deftest mark-all-threads-batches-splits-the-ids + (t/is (= [] (dcmt/mark-all-threads-batches []))) + (t/is (= [[1 2 3]] (dcmt/mark-all-threads-batches [1 2 3]))) + (t/is (= [500 1] (mapv count (dcmt/mark-all-threads-batches (range 501)))))) + +(t/deftest ^:async mark-all-threads-as-read-sends-the-ids-in-batches + (let [calls (atom []) + state (comment-threads-state 1200) + event (dcmt/mark-all-threads-as-read (uuid/next))] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [cmd params] + (swap! calls conj [cmd params]) + (rx/of nil)))} + (await (async/observe (ptk/watch event state nil))))) + + (t/is (= 3 (count @calls))) + (t/is (every? #(= :mark-all-threads-as-read (first %)) @calls)) + (t/is (= [500 500 200] (mapv (comp count :threads second) @calls))))) + +(t/deftest ^:async mark-all-threads-as-read-sends-a-single-batch + (let [calls (atom []) + state (comment-threads-state 10) + event (dcmt/mark-all-threads-as-read (uuid/next))] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [cmd params] + (swap! calls conj [cmd params]) + (rx/of nil)))} + (await (async/observe (ptk/watch event state nil))))) + + (t/is (= 1 (count @calls))) + (t/is (= 10 (count (:threads (second (first @calls)))))))) + +(t/deftest ^:async mark-all-threads-as-read-refreshes-the-unread-list-after-a-batch-fails + (let [calls (atom []) + state (comment-threads-state 1200) + event (dcmt/mark-all-threads-as-read (uuid/next)) + seen (atom []) + errors (atom [])] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub + (fn [cmd params] + (swap! calls conj [cmd params]) + (if (= 2 (count @calls)) + (rx/throw (ex-info "boom" {})) + (rx/of nil))))} + (await (async/observe (ptk/watch event state nil) + :on-next #(swap! seen conj (ptk/type %)) + :on-error #(swap! errors conj %))))) + + (t/is (= 2 (count @calls)) + "the pipeline stops at the batch that fails") + (t/is (= [::dcmt/retrieve-unread-comment-threads] @seen) + "the unread list is re-read and no success toast is emitted") + (t/is (= [{:type :comment-error}] @errors)))) + +(t/deftest ^:async mark-all-threads-as-read-refreshes-and-shows-the-success-toast + (let [state (comment-threads-state 600) + event (dcmt/mark-all-threads-as-read (uuid/next)) + seen (atom [])] + + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of nil)))} + (await (async/observe (ptk/watch event state nil) + :on-next #(swap! seen conj (ptk/type %)))))) + + (t/is (= [::dcmt/retrieve-unread-comment-threads ::ntf/show] @seen)))) diff --git a/frontend/test/frontend_tests/data/team_test.cljs b/frontend/test/frontend_tests/data/team_test.cljs new file mode 100644 index 0000000000..9bf84efc79 --- /dev/null +++ b/frontend/test/frontend_tests/data/team_test.cljs @@ -0,0 +1,81 @@ +;; 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/. + +(ns frontend-tests.data.team-test + (:require + [app.common.uuid :as uuid] + [app.main.data.team :as dtm] + [app.main.repo :as rp] + [app.main.ui.dashboard.team :as dtui] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(defn- fetch-invitations-events + "Runs the watch of `fetch-invitations` with `rp/cmd!` answering with + `response` and resolves to the team id and the events it emits." + [response] + (let [team-id (uuid/next) + state {:current-team-id team-id} + emitted (atom [])] + (-> (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_cmd _params] response))} + (await (async/observe (ptk/watch (dtm/fetch-invitations) state nil) + :on-next #(swap! emitted conj %)))) + (.then (fn [_] {:team-id team-id :events @emitted}))))) + +(t/deftest ^:async fetch-invitations-clears-the-list-on-permission-error + ;; The pending invitations are an admin only list: a member that cannot + ;; invite gets an insufficient permissions error. That must not become an + ;; error toast, and the cached list must be dropped so the stale emails do + ;; not stay around. + (let [{:keys [team-id events]} + (await (fetch-invitations-events + (rx/throw (ex-info "insufficient permissions" + {:type :validation + :code :insufficient-permissions})))) + event (first events) + state (ptk/update event {:teams {team-id {:id team-id + :invitations [{:email "stale@example.com" + :role :editor}]}}})] + (t/is (= 1 (count events))) + (t/is (= ::dtm/invitations-fetched (ptk/type event))) + (t/is (= [] (get-in state [:teams team-id :invitations]))))) + +(t/deftest ^:async fetch-invitations-propagates-other-errors + ;; Only the permission failure is swallowed: any other error is a real + ;; problem and must reach the error reporting. + (let [outcome (await (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_cmd _params] + (rx/throw (ex-info "boom" {:type :server}))))} + (try + (await (async/observe (ptk/watch (dtm/fetch-invitations) + {:current-team-id (uuid/next)} + nil))) + :resolved + (catch js/Error _ :rejected))))] + (t/is (= :rejected outcome)))) + +(t/deftest ^:async fetch-invitations-emits-the-update-event + (let [{:keys [events]} (await (fetch-invitations-events + (rx/of [{:email "invited@example.com" :role :editor}])))] + (t/is (= 1 (count events))) + (t/is (= ::dtm/invitations-fetched (ptk/type (first events)))))) + +(t/deftest invitations-section-shows-the-list-only-to-the-team-admins + ;; The list is admin only, and the backend normalizes `:is-admin` to + ;; include the team owner. The organization invite policy does not gate it. + (t/is (= :list (dtui/invitations-section-content {:is-owner false :is-admin true}))) + (t/is (= :list (dtui/invitations-section-content {:is-owner true :is-admin true}))) + (t/is (= :no-permission (dtui/invitations-section-content {:is-owner false :is-admin false})))) + +(t/deftest owner-invitation-link-is-copyable-only-by-the-team-owner + ;; An admin cannot create nor promote an owner invitation, so the backend + ;; does not mint its token for them: the action is not offered either. + (t/is (true? (dtui/invitation-link-copyable? true :owner))) + (t/is (false? (dtui/invitation-link-copyable? false :owner))) + (t/is (true? (dtui/invitation-link-copyable? false :editor))) + (t/is (true? (dtui/invitation-link-copyable? false :viewer)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a9bf962423..135797b7f5 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -10,6 +10,7 @@ [frontend-tests.composable-tests.comp.sync-test] [frontend-tests.copy-as-svg-test] [frontend-tests.data.auth-test] + [frontend-tests.data.comments-batches-test] [frontend-tests.data.comments-filters-test] [frontend-tests.data.dashboard-test] [frontend-tests.data.event-test] @@ -30,6 +31,7 @@ [frontend-tests.data.repo-test] [frontend-tests.data.store-test] [frontend-tests.data.svg-upload-test] + [frontend-tests.data.team-test] [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] @@ -205,6 +207,7 @@ 'frontend-tests.composable-tests.comp.sync-test 'frontend-tests.copy-as-svg-test 'frontend-tests.data.auth-test + 'frontend-tests.data.comments-batches-test 'frontend-tests.data.comments-filters-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.event-test @@ -228,6 +231,7 @@ 'frontend-tests.worker.index-test 'frontend-tests.worker.trim-test 'frontend-tests.data.svg-upload-test + 'frontend-tests.data.team-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test