mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 10:56:20 +00:00
1825 lines
98 KiB
Clojure
1825 lines
98 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
|
|
|
|
(ns backend-tests.rpc-management-nitrate-test
|
|
(:require
|
|
[app.auth.oidc :as oidc]
|
|
[app.common.data :as d]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.email :as eml]
|
|
[app.http :as-alias http]
|
|
[app.msgbus :as mbus]
|
|
[app.nitrate :as nitrate]
|
|
[app.rpc :as-alias rpc]
|
|
[app.worker :as wrk]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.set :as set]
|
|
[clojure.test :as t]
|
|
[cuerdas.core :as str]
|
|
[mockery.core :refer [with-mocks]]))
|
|
|
|
(t/use-fixtures :once (t/compose-fixtures
|
|
(partial th/init-config [:enable-nitrate])
|
|
th/init-system))
|
|
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(t/deftest authenticate-success
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [profile (th/create-profile* 1 {:is-active true
|
|
:fullname "Nitrate User"})
|
|
out (th/management-command! {::th/type :authenticate
|
|
::rpc/profile-id (:id profile)})]
|
|
(t/is (th/success? out))
|
|
(t/is (= (:id profile) (-> out :result :id)))
|
|
(t/is (= "Nitrate User" (-> out :result :name)))
|
|
(t/is (= (:email profile) (-> out :result :email)))
|
|
(t/is (= (:created-at profile) (-> out :result :created-at)))
|
|
(t/is (nil? (-> out :result :photo-url))))))
|
|
|
|
(t/deftest authenticate-requires-authentication
|
|
(let [out (th/management-command! {::th/type :authenticate})]
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :authentication (th/ex-type (:error out))))
|
|
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
|
|
|
(t/deftest create-and-update-organization-invitations-audit-props
|
|
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
|
audit-mock {:target 'app.loggers.audit/submit :return nil}
|
|
nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(binding [cf/flags (conj cf/flags :email-verification)]
|
|
(let [owner (th/create-profile* 101 {:is-active true})
|
|
invitee (th/create-profile* 102 {:is-active true})
|
|
organization {:id (uuid/random)
|
|
:name "Acme"
|
|
:initials "AC"
|
|
:logo nil
|
|
:avatar-bg-url nil}
|
|
params {::th/type :invite-to-organization
|
|
::rpc/profile-id (:id owner)
|
|
:email (:email invitee)
|
|
:organization organization}
|
|
create-out (th/management-command! params)
|
|
update-out (th/management-command! params)
|
|
external-out (th/management-command! (assoc params :email "external@example.com"))
|
|
events (mapv second (:call-args-list @audit-mock))
|
|
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
|
|
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
|
|
external-event
|
|
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
|
|
(t/is (th/success? create-out))
|
|
(t/is (th/success? update-out))
|
|
(t/is (th/success? external-out))
|
|
|
|
(doseq [event [create-event update-event]]
|
|
(t/is (not (contains? (:props event) :event-origin)))
|
|
(t/is (= (str (:id owner))
|
|
(get-in event [:props :user-who-send-invitation])))
|
|
(t/is (= (:id organization)
|
|
(get-in event [:props :organization-id])))
|
|
(t/is (= (:email invitee)
|
|
(get-in event [:props :member-email])))
|
|
(t/is (= (:id invitee)
|
|
(get-in event [:props :member-id]))))
|
|
|
|
(t/is (not (contains? (:props external-event) :member-id)))))))
|
|
|
|
(t/deftest get-penpot-version
|
|
(let [out (th/management-command! {::th/type :get-penpot-version})
|
|
version (-> out :result :version)]
|
|
(t/is (th/success? out))
|
|
(t/is (= #{:full :branch :base :main :major :minor :patch :modifier :commit :commit-hash}
|
|
(set (keys version))))
|
|
(doseq [k [:full :branch :base :main :major :minor :patch :modifier :commit :commit-hash]]
|
|
(t/is (or (nil? (get version k))
|
|
(string? (get version k)))))
|
|
(t/is (= cf/version version))))
|
|
|
|
(t/deftest get-teams-returns-only-owned-non-default-non-deleted
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
other (th/create-profile* 2 {:is-active true})
|
|
owned-team (th/create-team* 1 {:profile-id (:id profile)})
|
|
deleted-team (th/create-team* 2 {:profile-id (:id profile)})
|
|
_ (th/db-update! :team
|
|
{:deleted-at (ct/now)}
|
|
{:id (:id deleted-team)})
|
|
other-team (th/create-team* 3 {:profile-id (:id other)})
|
|
_ (th/create-team-role* {:team-id (:id other-team)
|
|
:profile-id (:id profile)
|
|
:role :editor})
|
|
out (th/management-command! {::th/type :get-teams
|
|
::rpc/profile-id (:id profile)})]
|
|
(t/is (th/success? out))
|
|
(t/is (= #{(:id owned-team)}
|
|
(->> out :result (map :id) set)))
|
|
(t/is (= #{(:name owned-team)}
|
|
(->> out :result (map :name) set))))))
|
|
|
|
(t/deftest notify-team-change-publishes-event
|
|
(let [team-id (uuid/random)
|
|
organization-id (uuid/random)
|
|
organization {:id organization-id
|
|
:name "Acme Inc"
|
|
:slug "acme-inc"
|
|
:owner-id (uuid/random)
|
|
:avatar-bg-url "http://example.com/avatar.svg"}
|
|
calls (atom [])
|
|
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
|
(swap! calls conj {:topic topic
|
|
:message message}))]
|
|
(th/management-command! {::th/type :notify-team-change
|
|
:id team-id
|
|
:is-your-penpot false
|
|
:organization organization}))]
|
|
(t/is (th/success? out))
|
|
(t/is (= 1 (count @calls)))
|
|
(t/is (= uuid/zero (-> @calls first :topic)))
|
|
(let [msg (-> @calls first :message)]
|
|
(t/is (= :team-organization-change (:type msg)))
|
|
(t/is (= nil (:notification msg)))
|
|
(t/is (= team-id (-> msg :team :id)))
|
|
(t/is (= false (-> msg :team :is-your-penpot)))
|
|
(t/is (= (:id organization) (-> msg :team :organization :id)))
|
|
(t/is (= (:name organization) (-> msg :team :organization :name)))
|
|
(t/is (= (:slug organization) (-> msg :team :organization :slug)))
|
|
(t/is (= (:owner-id organization) (-> msg :team :organization :owner-id)))
|
|
(t/is (= (:avatar-bg-url organization) (str (-> msg :team :organization :avatar-bg-url)))))))
|
|
|
|
(t/deftest notify-user-added-to-organization-creates-default-organization-team
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_ m _]
|
|
(case m
|
|
:set-team-organization {:success true}
|
|
nil))}]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
before-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile)
|
|
:is-owner true})
|
|
(map :team-id)
|
|
set)
|
|
out (th/management-command! {::th/type :notify-user-added-to-organization
|
|
:profile-id (:id profile)
|
|
:organization-id (uuid/random)
|
|
:role "owner"})
|
|
after-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile)
|
|
:is-owner true})
|
|
(map :team-id)
|
|
set)
|
|
new-team-id (first (set/difference after-teams before-teams))
|
|
new-team (th/db-get :team {:id new-team-id})]
|
|
(t/is (th/success? out))
|
|
(t/is (= 1 (count (set/difference after-teams before-teams))))
|
|
(t/is (= "Your Penpot" (:name new-team)))
|
|
(t/is (true? (:is-default new-team))))))
|
|
|
|
(t/deftest get-managed-profiles-returns-unique-members-for-owned-teams
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [owner (th/create-profile* 1 {:is-active true})
|
|
member1 (th/create-profile* 2 {:is-active true})
|
|
member2 (th/create-profile* 3 {:is-active true})
|
|
team1 (th/create-team* 1 {:profile-id (:id owner)})
|
|
team2 (th/create-team* 2 {:profile-id (:id owner)})
|
|
_ (th/create-team-role* {:team-id (:id team1)
|
|
:profile-id (:id member1)
|
|
:role :editor})
|
|
_ (th/create-team-role* {:team-id (:id team1)
|
|
:profile-id (:id member2)
|
|
:role :editor})
|
|
_ (th/create-team-role* {:team-id (:id team2)
|
|
:profile-id (:id member1)
|
|
:role :editor})
|
|
out (th/management-command! {::th/type :get-managed-profiles
|
|
::rpc/profile-id (:id owner)})]
|
|
(t/is (th/success? out))
|
|
(t/is (= #{(:id member1) (:id member2)}
|
|
(->> out :result (map :id) set)))
|
|
(t/is (= #{(:email member1) (:email member2)}
|
|
(->> out :result (map :email) set))))))
|
|
|
|
(t/deftest get-teams-summary-returns-teams-and-files-count
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team1 (th/create-team* 1 {:profile-id (:id profile)})
|
|
team2 (th/create-team* 2 {:profile-id (:id profile)})
|
|
proj1 (th/create-project* 1 {:profile-id (:id profile)
|
|
:team-id (:id team1)})
|
|
proj2 (th/create-project* 2 {:profile-id (:id profile)
|
|
:team-id (:id team2)})
|
|
_ (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:id proj1)})
|
|
_ (th/create-file* 2 {:profile-id (:id profile)
|
|
:project-id (:id proj2)})
|
|
out (th/management-command! {::th/type :get-teams-summary
|
|
::rpc/profile-id (:id profile)
|
|
:ids [(:id team1) (:id team2)]})]
|
|
(t/is (th/success? out))
|
|
(t/is (= 2 (-> out :result :num-files)))
|
|
(t/is (= #{(:id team1) (:id team2)}
|
|
(->> out :result :teams (map :id) set))))))
|
|
|
|
(t/deftest get-teams-detail-last-activity-reflects-file-modifications
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-summary @organization-summary-ref
|
|
nil))}]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team (th/create-team* 1 {:profile-id (:id profile)})
|
|
organization-id (uuid/random)
|
|
organization-summary {:id organization-id
|
|
:teams [{:id (:id team)}]}
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
params {::th/type :get-teams-detail
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id}
|
|
call! (fn [] (th/management-command! params))
|
|
|
|
empty-out (call!)
|
|
empty-team (-> empty-out :result first)
|
|
|
|
project (th/create-project* 1 {:profile-id (:id profile)
|
|
:team-id (:id team)})
|
|
file (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:id project)})
|
|
file-after-create (th/db-get :file {:id (:id file)})
|
|
project-after-create (th/db-get :project {:id (:id project)})
|
|
expected-activity-create (if (.isAfter (:modified-at file-after-create)
|
|
(:modified-at project-after-create))
|
|
(:modified-at file-after-create)
|
|
(:modified-at project-after-create))
|
|
|
|
with-file-out (call!)
|
|
with-file (-> with-file-out :result first)
|
|
|
|
new-activity (ct/in-future "1h")
|
|
_ (th/db-update! :file
|
|
{:modified-at new-activity}
|
|
{:id (:id file)})
|
|
file-after-update (th/db-get :file {:id (:id file)})
|
|
|
|
updated-out (call!)
|
|
updated-team (-> updated-out :result first)]
|
|
|
|
(t/is (th/success? empty-out))
|
|
(t/is (= (:id team) (:id empty-team)))
|
|
(t/is (nil? (:last-activity-at empty-team)))
|
|
|
|
(t/is (th/success? with-file-out))
|
|
(t/is (= (:id team) (:id with-file)))
|
|
(t/is (= expected-activity-create (:last-activity-at with-file)))
|
|
|
|
(t/is (th/success? updated-out))
|
|
(t/is (= (:id team) (:id updated-team)))
|
|
(t/is (= (:modified-at file-after-update) (:last-activity-at updated-team)))
|
|
(t/is (not= (:last-activity-at with-file) (:last-activity-at updated-team)))))))
|
|
|
|
(t/deftest notify-organization-deletion-prefixes-teams-and-publishes-organization-deleted-event
|
|
;; --- Deferred organization-summary: nil during setup, filled before RPC ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: return nil during profile/team creation,
|
|
;; then serve the computed organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method params]
|
|
(if @organization-summary-ref
|
|
@organization-summary-ref
|
|
nil))}
|
|
;; --- Worker mock: capture delete-task submission ---
|
|
wrk-mock {:target 'app.worker/submit! :return nil}
|
|
;; --- Message bus mock: capture published events ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create a profile with two teams ---
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
;; --- Team with files: should be kept and renamed ---
|
|
team-with-files (th/db-get :team {:id (:default-team-id profile)})
|
|
project (th/create-project* 1 {:profile-id (:id profile)
|
|
:team-id (:id team-with-files)})
|
|
_ (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:id project)})
|
|
;; --- Empty team: should be soft-deleted ---
|
|
empty-team (th/create-team* 1 {:profile-id (:id profile)})
|
|
|
|
;; --- Data needed by the RPC call ---
|
|
organization-id (uuid/random)
|
|
organization-name "Acme / Design"
|
|
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
|
;; --- Org-summary that nitrate would return ---
|
|
organization-summary {:id organization-id
|
|
:name organization-name
|
|
:teams [{:id (:id team-with-files)
|
|
:is-your-penpot true}
|
|
{:id (:id empty-team)
|
|
:is-your-penpot true}]}
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: notify Penpot that an organization is deleted ---
|
|
out (th/management-command! {::th/type :notify-organization-deletion
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id})
|
|
|
|
;; --- Fetch teams post-deletion to verify mutations ---
|
|
updated-with-files (th/db-get :team {:id (:id team-with-files)} {::db/remove-deleted false})
|
|
updated-empty (th/db-get :team {:id (:id empty-team)} {::db/remove-deleted false})]
|
|
|
|
;; --- Verify: nitrate was queried for the organization summary ---
|
|
(let [[_ method params] (:call-args @nitrate-mock)]
|
|
(t/is (= :get-organization-summary method))
|
|
(t/is (= {:organization-id organization-id} params)))
|
|
|
|
;; --- Verify: RPC returns success with no result payload ---
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:result out)))
|
|
|
|
;; --- Verify: team with files is kept, default flag removed, name prefixed ---
|
|
(t/is (false? (:is-default updated-with-files)))
|
|
(t/is (str/starts-with? (:name updated-with-files) expected-start))
|
|
(t/is (nil? (:deleted-at updated-with-files)))
|
|
|
|
;; --- Verify: empty team is soft-deleted and a background delete task is enqueued ---
|
|
(t/is (some? (:deleted-at updated-empty)))
|
|
(t/is (:called? @wrk-mock))
|
|
(t/is (= 1 (:call-count @wrk-mock)))
|
|
|
|
;; --- Verify: exactly one organization-deleted event is published on the message bus ---
|
|
(t/is (:called? @mbus-mock))
|
|
(let [msg (apply hash-map (rest (:call-args @mbus-mock)))]
|
|
(t/is (= uuid/zero (:topic msg)))
|
|
(t/is (= :organization-deleted (:type (:message msg))))
|
|
(t/is (= organization-id (:organization-id (:message msg))))
|
|
(t/is (= organization-name (:organization-name (:message msg))))
|
|
(t/is (= #{(:id team-with-files) (:id empty-team)}
|
|
(set (:teams (:message msg)))))
|
|
(t/is (= #{(:id empty-team)}
|
|
(set (:deleted-teams (:message msg))))))))))
|
|
|
|
(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-organization-events
|
|
;; --- Deferred owned-organizations: nil during setup, filled before RPC ---
|
|
(let [owned-organizations-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: return nil during profile/team creation,
|
|
;; then serve the computed owned-organizations once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-owned-organizations @owned-organizations-ref
|
|
nil))}
|
|
;; --- Worker mock: capture delete-task submissions ---
|
|
wrk-mock {:target 'app.worker/submit! :return nil}
|
|
;; --- Message bus mock: capture published events ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create a profile with teams across two organizations ---
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
;; --- Org-1: team with files: kept and renamed ---
|
|
organization-1-team-files (th/db-get :team {:id (:default-team-id profile)})
|
|
organization-1-proj (th/create-project* 1 {:profile-id (:id profile)
|
|
:team-id (:id organization-1-team-files)})
|
|
_ (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:id organization-1-proj)})
|
|
;; --- Org-1: empty team: soft-deleted ---
|
|
organization-1-team-empty (th/create-team* 1 {:profile-id (:id profile)})
|
|
|
|
;; --- Org-2: team with files: kept and renamed ---
|
|
organization-2-team-files (th/create-team* 2 {:profile-id (:id profile)})
|
|
organization-2-proj (th/create-project* 2 {:profile-id (:id profile)
|
|
:team-id (:id organization-2-team-files)})
|
|
_ (th/create-file* 2 {:profile-id (:id profile)
|
|
:project-id (:id organization-2-proj)})
|
|
;; --- Org-2: empty team: soft-deleted ---
|
|
organization-2-team-empty (th/create-team* 3 {:profile-id (:id profile)})
|
|
|
|
;; --- Data needed by the RPC call ---
|
|
organization-1-id (uuid/random)
|
|
organization-2-id (uuid/random)
|
|
organization-1-name "Org One / Design"
|
|
organization-2-name "Org Two"
|
|
organization-1-prefix (str "[" (d/sanitize-string organization-1-name) "] ")
|
|
organization-2-prefix (str "[" (d/sanitize-string organization-2-name) "] ")
|
|
;; --- Owned-organizations that nitrate would return ---
|
|
owned-organizations [{:id organization-1-id
|
|
:name organization-1-name
|
|
:teams [{:id (:id organization-1-team-files)
|
|
:is-your-penpot true}
|
|
{:id (:id organization-1-team-empty)
|
|
:is-your-penpot true}]}
|
|
{:id organization-2-id
|
|
:name organization-2-name
|
|
:teams [{:id (:id organization-2-team-files)
|
|
:is-your-penpot true}
|
|
{:id (:id organization-2-team-empty)
|
|
:is-your-penpot true}]}]
|
|
;; --- Publish owned-organizations so the mock can serve it ---
|
|
_ (reset! owned-organizations-ref owned-organizations)
|
|
|
|
;; --- Exercise: notify Penpot that the user's organizations are deleted ---
|
|
out (th/management-command! {::th/type :notify-user-organizations-deletion
|
|
::rpc/profile-id (:id profile)
|
|
:profile-id (:id profile)})
|
|
|
|
;; --- Fetch teams post-deletion to verify mutations ---
|
|
organization-1-updated-files (th/db-get :team {:id (:id organization-1-team-files)} {::db/remove-deleted false})
|
|
organization-1-updated-empty (th/db-get :team {:id (:id organization-1-team-empty)} {::db/remove-deleted false})
|
|
organization-2-updated-files (th/db-get :team {:id (:id organization-2-team-files)} {::db/remove-deleted false})
|
|
organization-2-updated-empty (th/db-get :team {:id (:id organization-2-team-empty)} {::db/remove-deleted false})
|
|
|
|
;; --- Extract published messages from the message bus mock ---
|
|
msgs (->> (:call-args-list @mbus-mock)
|
|
(map #(apply hash-map (rest %)))
|
|
(map :message)
|
|
vec)
|
|
organization-msg (fn [organization-name]
|
|
(first (filter #(= organization-name (:organization-name %)) msgs)))]
|
|
|
|
;; --- Verify: nitrate was queried for owned organizations with correct params ---
|
|
(let [[_ method params] (:call-args @nitrate-mock)]
|
|
(t/is (= :get-owned-organizations method))
|
|
(t/is (= {:profile-id (:id profile)} params)))
|
|
|
|
;; --- Verify: RPC returns success with no result payload ---
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:result out)))
|
|
|
|
;; --- Verify: organization-1 team with files kept, renamed, default flag removed ---
|
|
(t/is (false? (:is-default organization-1-updated-files)))
|
|
(t/is (str/starts-with? (:name organization-1-updated-files) organization-1-prefix))
|
|
(t/is (nil? (:deleted-at organization-1-updated-files)))
|
|
;; --- Verify: organization-1 empty team soft-deleted ---
|
|
(t/is (some? (:deleted-at organization-1-updated-empty)))
|
|
|
|
;; --- Verify: organization-2 team with files kept, renamed, default flag removed ---
|
|
(t/is (false? (:is-default organization-2-updated-files)))
|
|
(t/is (str/starts-with? (:name organization-2-updated-files) organization-2-prefix))
|
|
(t/is (nil? (:deleted-at organization-2-updated-files)))
|
|
;; --- Verify: organization-2 empty team soft-deleted ---
|
|
(t/is (some? (:deleted-at organization-2-updated-empty)))
|
|
|
|
;; --- Verify: two delete tasks submitted (one per empty team) ---
|
|
(t/is (:called? @wrk-mock))
|
|
(t/is (= 2 (:call-count @wrk-mock)))
|
|
|
|
;; --- Verify: one organization-deleted event per organization, all on correct topic ---
|
|
(t/is (= 2 (count msgs)))
|
|
(t/is (every? #(= uuid/zero (:topic %))
|
|
(->> (:call-args-list @mbus-mock)
|
|
(map #(apply hash-map (rest %))))))
|
|
(t/is (= #{:organization-deleted} (set (map :type msgs))))
|
|
|
|
;; --- Verify: each organization-deleted event has correct organization-specific payload ---
|
|
(let [m1 (organization-msg organization-1-name)
|
|
m2 (organization-msg organization-2-name)]
|
|
(t/is (some? m1))
|
|
(t/is (some? m2))
|
|
(t/is (= organization-1-id (:organization-id m1)))
|
|
(t/is (= organization-2-id (:organization-id m2)))
|
|
(t/is (= #{(:id organization-1-team-files) (:id organization-1-team-empty)}
|
|
(set (:teams m1))))
|
|
(t/is (= #{(:id organization-1-team-empty)}
|
|
(set (:deleted-teams m1))))
|
|
(t/is (= #{(:id organization-2-team-files) (:id organization-2-team-empty)}
|
|
(set (:teams m2))))
|
|
(t/is (= #{(:id organization-2-team-empty)}
|
|
(set (:deleted-teams m2)))))))))
|
|
|
|
(t/deftest get-profile-by-email-success-and-not-found
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [profile (th/create-profile* 1 {:is-active true
|
|
:fullname "Lookup by Email"})
|
|
ok-out (th/management-command! {::th/type :get-profile-by-email
|
|
::rpc/profile-id (:id profile)
|
|
:email (:email profile)})
|
|
ko-out (th/management-command! {::th/type :get-profile-by-email
|
|
::rpc/profile-id (:id profile)
|
|
:email "not-found@example.com"})]
|
|
(t/is (th/success? ok-out))
|
|
(t/is (= (:id profile) (-> ok-out :result :id)))
|
|
(t/is (= "Lookup by Email" (-> ok-out :result :name)))
|
|
(t/is (nil? (-> ok-out :result :photo-url)))
|
|
|
|
(t/is (not (th/success? ko-out)))
|
|
(t/is (= :not-found (th/ex-type (:error ko-out))))
|
|
(t/is (= :profile-not-found (th/ex-code (:error ko-out)))))))
|
|
|
|
(t/deftest get-profile-by-id-success-and-not-found
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [profile (th/create-profile* 1 {:is-active true
|
|
:fullname "Lookup by Id"})
|
|
ok-out (th/management-command! {::th/type :get-profile-by-id
|
|
::rpc/profile-id (:id profile)
|
|
:id (:id profile)})
|
|
ko-out (th/management-command! {::th/type :get-profile-by-id
|
|
::rpc/profile-id (:id profile)
|
|
:id (uuid/random)})]
|
|
(t/is (th/success? ok-out))
|
|
(t/is (= (:id profile) (-> ok-out :result :id)))
|
|
(t/is (= "Lookup by Id" (-> ok-out :result :name)))
|
|
(t/is (nil? (-> ok-out :result :photo-url)))
|
|
|
|
(t/is (not (th/success? ko-out)))
|
|
(t/is (= :not-found (th/ex-type (:error ko-out))))
|
|
(t/is (= :profile-not-found (th/ex-code (:error ko-out)))))))
|
|
|
|
(t/deftest get-organization-invitations-returns-valid-deduped-by-email
|
|
;; --- Deferred organization-summary: nil during setup, filled before RPC ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: return nil during profile/team creation,
|
|
;; then serve the computed organization-summary when the handler queries it ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-summary @organization-summary-ref
|
|
nil))}]
|
|
|
|
;; --- Setup: create profile, teams, and invitation data ---
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
|
|
|
;; --- Data needed by the RPC call ---
|
|
organization-id (uuid/random)
|
|
organization-summary {:id organization-id
|
|
:teams [{:id (:id team-1)}
|
|
{:id (:id team-2)}]}
|
|
params {::th/type :get-organization-invitations
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id}
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Insert invitation records ---
|
|
;; Same email appears in organization and team invitations; only one should be returned.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to "dup@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-1)
|
|
:org-id nil
|
|
:email-to "dup@example.com"
|
|
:created-by (:id profile)
|
|
:role "admin"
|
|
:valid-until (ct/in-future "72h")})
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-2)
|
|
:org-id nil
|
|
:email-to "valid@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "48h")})
|
|
;; Expired invitation should be ignored.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to "expired@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-past "1h")})
|
|
|
|
;; --- Exercise: query organization invitations ---
|
|
out (th/management-command! params)
|
|
|
|
;; --- Extract results ---
|
|
result (:result out)
|
|
emails (->> result (map :email) set)
|
|
dedup (->> result
|
|
(filter #(= "dup@example.com" (:email %)))
|
|
first)]
|
|
|
|
;; --- Verify: nitrate was queried for the organization summary ---
|
|
(let [[_ method params'] (:call-args @nitrate-mock)]
|
|
(t/is (= :get-organization-summary method))
|
|
(t/is (= {:organization-id organization-id} params')))
|
|
|
|
;; --- Verify: RPC returns success with deduplicated invitations ---
|
|
(t/is (th/success? out))
|
|
(t/is (= #{"dup@example.com" "valid@example.com"} emails))
|
|
(t/is (= 2 (count result)))
|
|
;; --- Verify: deduplicated invitation has id/sent-at but no team-specific fields ---
|
|
(t/is (some? (:id dedup)))
|
|
(t/is (some? (:sent-at dedup)))
|
|
(t/is (nil? (:organization-id dedup)))
|
|
(t/is (nil? (:team-id dedup)))
|
|
(t/is (nil? (:role dedup)))
|
|
(t/is (nil? (:valid-until dedup)))))))
|
|
|
|
(t/deftest get-organization-invitations-includes-organization-level-invitations-when-no-teams
|
|
;; --- Org-summary has no teams — computable before with-mocks, no deferral needed ---
|
|
(let [organization-id (uuid/random)
|
|
organization-summary {:id organization-id :teams []}
|
|
params {::th/type :get-organization-invitations
|
|
:organization-id organization-id}]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: return the organization-summary when the handler queries it ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-summary organization-summary
|
|
nil))}]
|
|
|
|
;; --- Setup: create profile and insert an organization-level invitation ---
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to "organization-only@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; --- Exercise: query organization invitations ---
|
|
out (th/management-command! (assoc params ::rpc/profile-id (:id profile)))
|
|
result (:result out)]
|
|
|
|
;; --- Verify: nitrate was queried for the organization summary ---
|
|
(let [[_ method params'] (:call-args @nitrate-mock)]
|
|
(t/is (= :get-organization-summary method))
|
|
(t/is (= {:organization-id organization-id} params')))
|
|
|
|
;; --- Verify: the organization-level invitation is returned ---
|
|
(t/is (th/success? out))
|
|
(t/is (= 1 (count result)))
|
|
(t/is (= "organization-only@example.com" (-> result first :email)))
|
|
(t/is (some? (-> result first :sent-at)))))))
|
|
|
|
(t/deftest get-organization-invitations-returns-existing-profile-data
|
|
;; --- Org-summary has no teams — computable before with-mocks, no deferral needed ---
|
|
(let [organization-id (uuid/random)
|
|
organization-summary {:id organization-id :teams []}]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: return the organization-summary when the handler queries it ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-summary organization-summary
|
|
nil))}]
|
|
|
|
;; --- Setup: create profiles, set photo on invited user, insert invitation ---
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
invited (th/create-profile* 2 {:is-active true
|
|
:fullname "Invited User"})
|
|
photo-id (uuid/random)
|
|
_ (th/db-insert! :storage-object {:id photo-id
|
|
:backend "assets-fs"})
|
|
_ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)})
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to (:email invited)
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; --- Exercise: query organization invitations ---
|
|
out (th/management-command! {::th/type :get-organization-invitations
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id})
|
|
invitation (-> out :result first)]
|
|
|
|
;; --- Verify: nitrate was queried for the organization summary ---
|
|
(let [[_ method params'] (:call-args @nitrate-mock)]
|
|
(t/is (= :get-organization-summary method))
|
|
(t/is (= {:organization-id organization-id} params')))
|
|
|
|
;; --- Verify: invitation includes the invited user's existing profile data ---
|
|
(t/is (th/success? out))
|
|
(t/is (= "Invited User" (:name invitation)))
|
|
(t/is (some? (:sent-at invitation)))
|
|
(t/is (str/ends-with? (:photo-url invitation)
|
|
(str "/assets/by-id/" photo-id)))))))
|
|
|
|
(t/deftest delete-organization-invitations-removes-organization-and-organization-team-invitations-for-email
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)
|
|
target-email "target@example.com"]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary when handler queries it ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-summary @organization-summary-ref
|
|
nil))}]
|
|
|
|
;; --- Setup: create profile, teams, and invitation data ---
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
|
organization-id (uuid/random)
|
|
organization-summary {:id organization-id
|
|
:teams [{:id (:id team-1)}
|
|
{:id (:id team-2)}]}
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Insert invitation records ---
|
|
;; Should be deleted: organization-level invitation for same organization+email.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to target-email
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
;; Should be deleted: team-level invitation for teams in organization summary.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-1)
|
|
:org-id nil
|
|
:email-to target-email
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-past "1h")})
|
|
;; Should remain: different email.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-2)
|
|
:org-id nil
|
|
:email-to "other@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
;; Should remain: same email but outside organization scope.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id outside-team)
|
|
:org-id nil
|
|
:email-to target-email
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; --- Exercise: delete organization invitations for target email ---
|
|
out (th/management-command! {::th/type :delete-organization-invitations
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id
|
|
:email "TARGET@example.com"})
|
|
remaining-target (th/db-query :team-invitation {:email-to target-email})
|
|
remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})]
|
|
|
|
;; --- Verify: nitrate was queried for the organization summary ---
|
|
(let [[_ method params'] (:call-args @nitrate-mock)]
|
|
(t/is (= :get-organization-summary method))
|
|
(t/is (= {:organization-id organization-id} params')))
|
|
|
|
;; --- Verify: RPC returns success with no result payload ---
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:result out)))
|
|
;; --- Verify: only the outside-team invitation remains for target email ---
|
|
(t/is (= 1 (count remaining-target)))
|
|
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
|
|
;; --- Verify: other-email invitation is untouched ---
|
|
(t/is (= 1 (count remaining-other)))))))
|
|
|
|
(t/deftest delete-all-organization-invitations-removes-organization-and-organization-team-invitations
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary when handler queries it ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-summary @organization-summary-ref
|
|
nil))}]
|
|
|
|
;; --- Setup: create profile, teams, and invitation data ---
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
|
organization-id (uuid/random)
|
|
organization-summary {:id organization-id
|
|
:teams [{:id (:id team-1)}
|
|
{:id (:id team-2)}]}
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Insert invitation records ---
|
|
;; Should be deleted: organization-level invitation.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to "alice@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
;; Should be deleted: team-level invitation in team-1 (belongs to organization).
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-1)
|
|
:org-id nil
|
|
:email-to "bob@example.com"
|
|
:created-by (:id profile)
|
|
:role "admin"
|
|
:valid-until (ct/in-future "48h")})
|
|
;; Should be deleted: team-level invitation in team-2 (belongs to organization),
|
|
;; even if expired.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-2)
|
|
:org-id nil
|
|
:email-to "carol@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-past "1h")})
|
|
;; Should remain: invitation to a team outside the org.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id outside-team)
|
|
:org-id nil
|
|
:email-to "dan@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
;; Should remain: invitation to a different organization.
|
|
_ (th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id (uuid/random)
|
|
:team-id nil
|
|
:email-to "erin@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; --- Exercise: delete all invitations in the organization ---
|
|
out (th/management-command! {::th/type :delete-all-organization-invitations
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id})
|
|
present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))]
|
|
|
|
;; --- Verify: the handler's nitrate call was :get-organization-summary with correct params ---
|
|
;; (The mock also recorded setup-phase calls from add-profile-to-team!,
|
|
;; so :call-args reflects the LAST call — which is the handler's.)
|
|
(let [[_ method params'] (:call-args @nitrate-mock)]
|
|
(t/is (= :get-organization-summary method))
|
|
(t/is (= {:organization-id organization-id} params')))
|
|
|
|
;; --- Verify: RPC returns success with no result payload ---
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:result out)))
|
|
|
|
;; --- Verify: organization-level + team-in-organization invitations are deleted ---
|
|
(t/is (not (present? "alice@example.com")))
|
|
(t/is (not (present? "bob@example.com")))
|
|
(t/is (not (present? "carol@example.com")))
|
|
|
|
;; --- Verify: invitations outside the organization survive ---
|
|
(t/is (present? "dan@example.com"))
|
|
(t/is (present? "erin@example.com"))))))
|
|
|
|
(t/deftest delete-all-organization-invitations-handles-organization-with-no-teams
|
|
(let [organization-id (uuid/random)
|
|
params {::th/type :delete-all-organization-invitations
|
|
:organization-id organization-id}]
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}
|
|
nitrate-fn {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-summary {:id organization-id :teams []}
|
|
nil))}]
|
|
(let [profile (th/create-profile* 1 {:is-active true})]
|
|
|
|
|
|
;; Org-level invitation should still be deleted.
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to "alice@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
(let [out (th/management-command! params)
|
|
remaining (th/db-query :team-invitation {:org-id organization-id})]
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:result out)))
|
|
(t/is (empty? remaining)))))))
|
|
|
|
(t/deftest exists-organization-team-invitations-for-non-members-reports-invitations-to-delete
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
|
|
profile (th/create-profile* 4 {:is-active true})
|
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
|
organization-id (uuid/random)
|
|
base-params {::th/type :exists-organization-team-invitations-for-non-members
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id
|
|
:team-ids [(:id team-1) (:id team-2)]
|
|
:member-ids [(:id member1)]}
|
|
exist! (fn [] (-> (th/management-command! base-params)
|
|
:result
|
|
:exists))]
|
|
|
|
(t/is (false? (exist!)))
|
|
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-1)
|
|
:org-id nil
|
|
:email-to "member1@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
(t/is (false? (exist!)))
|
|
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to "pending@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
(t/is (false? (exist!)))
|
|
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id outside-team)
|
|
:org-id nil
|
|
:email-to "outsider@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
(t/is (false? (exist!)))
|
|
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-2)
|
|
:org-id nil
|
|
:email-to "orphan@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
(t/is (true? (exist!))))))
|
|
|
|
(t/deftest delete-organization-team-invitations-for-non-members-removes-non-member-invitations
|
|
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
|
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
|
|
profile (th/create-profile* 4 {:is-active true})
|
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
|
organization-id (uuid/random)
|
|
params {::th/type :delete-organization-team-invitations-for-non-members
|
|
::rpc/profile-id (:id profile)
|
|
:organization-id organization-id
|
|
:team-ids [(:id team-1) (:id team-2)]
|
|
:member-ids [(:id member1)]}]
|
|
|
|
;; Should remain: member1 is an organization member.
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-1)
|
|
:org-id nil
|
|
:email-to "member1@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; Org-level invitation remains (out of team cleanup scope).
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to "pending@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; Should be deleted: team invitation for non-member
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-2)
|
|
:org-id nil
|
|
:email-to "pending@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; Should be deleted: orphaned invitation
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-2)
|
|
:org-id nil
|
|
:email-to "orphan@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
;; Should be deleted: expired invitation.
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team-1)
|
|
:org-id nil
|
|
:email-to "expired@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-past "1h")})
|
|
|
|
;; Should remain: outside organization scope.
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id outside-team)
|
|
:org-id nil
|
|
:email-to "outsider@example.com"
|
|
:created-by (:id profile)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
(let [out (th/management-command! params)]
|
|
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:result out)))
|
|
|
|
;; Verify remaining invitations.
|
|
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"}))))
|
|
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "pending@example.com"}))))
|
|
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"}))))
|
|
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "expired@example.com"}))))
|
|
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"}))))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; Tests: remove-from-organization
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- make-organization-summary
|
|
[& {:keys [organization-id organization-name owner-id your-penpot-teams organization-teams]
|
|
:or {your-penpot-teams [] organization-teams []}}]
|
|
{:id organization-id
|
|
:name organization-name
|
|
:owner-id owner-id
|
|
:teams (into
|
|
(mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams)
|
|
(mapv (fn [id] {:id id :is-your-penpot false}) organization-teams))})
|
|
|
|
(defn- nitrate-call-mock
|
|
([organization-summary]
|
|
(nitrate-call-mock organization-summary nil))
|
|
([organization-summary remove-profile-params]
|
|
(fn [_cfg method params]
|
|
(case method
|
|
:get-organization-summary organization-summary
|
|
:get-organization-membership {:organization-id (:id organization-summary)
|
|
:is-member true}
|
|
:remove-profile-from-organization (when remove-profile-params
|
|
(reset! remove-profile-params params))
|
|
nil))))
|
|
|
|
(t/deftest remove-from-organization-happy-path-no-extra-teams
|
|
;; User is only in its default team (which has files); it should be
|
|
;; kept, renamed and unset as default. A notification must be sent.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)
|
|
remove-profile-params (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref remove-profile-params) args))}
|
|
;; --- Message bus mock: capture published events ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create profiles, team with files, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
organization-team (th/create-team* 1 {:profile-id (:id user)})
|
|
project (th/create-project* 1 {:profile-id (:id user)
|
|
:team-id (:id organization-team)})
|
|
_ (th/create-file* 1 {:profile-id (:id user)
|
|
:project-id (:id project)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: remove the user from the organization ---
|
|
out (th/management-command!
|
|
{::th/type :remove-from-organization
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: nitrate was called (via nitrate-call-mock delegating) ---
|
|
(t/is (:called? @nitrate-mock))
|
|
|
|
;; --- Verify: RPC returns success with no result payload ---
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:result out)))
|
|
(t/is (= (:id organization-owner)
|
|
(:user-who-delete-member @remove-profile-params)))
|
|
(t/is (= "organization-owner"
|
|
(:deleted-by-role @remove-profile-params)))
|
|
|
|
;; --- Verify: default team preserved, renamed and unset as default ---
|
|
(let [team (th/db-get :team {:id (:id organization-team)})]
|
|
(t/is (false? (:is-default team)))
|
|
(t/is (str/starts-with? (:name team) "[Acme Org] ")))
|
|
|
|
;; --- Verify: exactly one notification sent to the user ---
|
|
(t/is (:called? @mbus-mock))
|
|
(let [msg (apply hash-map (rest (:call-args @mbus-mock)))]
|
|
(t/is (= :user-organization-change (:type (:message msg))))
|
|
(t/is (= (:id user) (:topic msg)))
|
|
(t/is (= organization-id (:organization-id (:message msg))))
|
|
(t/is (= "Acme Org" (:organization-name (:message msg))))
|
|
(t/is (= "dashboard.user-no-longer-belong-organization" (:notification (:message msg)))))))))
|
|
|
|
(t/deftest remove-from-organization-deletes-empty-default-team
|
|
;; When the default team has no files it should be soft-deleted.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)
|
|
remove-profile-params (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref remove-profile-params) args))}
|
|
;; --- Message bus mock: swallow notifications ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create profiles, empty default team, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
organization-team (th/create-team* 2 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: remove the user from the organization ---
|
|
out (th/management-command!
|
|
{::th/type :remove-from-organization
|
|
::rpc/profile-id uuid/zero
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success ---
|
|
(t/is (th/success? out))
|
|
(t/is (nil? (:user-who-delete-member @remove-profile-params)))
|
|
(t/is (nil? (:deleted-by-role @remove-profile-params)))
|
|
;; --- Verify: empty default team is soft-deleted ---
|
|
(let [team (th/db-get :team {:id (:id organization-team)} {::db/remove-deleted false})]
|
|
(t/is (some? (:deleted-at team))))))))
|
|
|
|
(t/deftest remove-from-organization-deletes-sole-owner-team
|
|
;; When the user is the sole member of an organization team it should be deleted.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}
|
|
;; --- Message bus mock: swallow notifications ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
extra-team (th/create-team* 3 {:profile-id (:id user)})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: remove the user from the organization ---
|
|
out (th/management-command!
|
|
{::th/type :remove-from-organization
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success ---
|
|
(t/is (th/success? out))
|
|
;; --- Verify: extra team (sole-owner team) is deleted ---
|
|
(let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})]
|
|
(t/is (some? (:deleted-at team))))))))
|
|
|
|
(t/deftest remove-from-organization-transfers-ownership-of-multi-member-team
|
|
;; When the user owns a team that has another non-owner member, ownership
|
|
;; is transferred to that member by the endpoint automatically.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}
|
|
;; --- Message bus mock: swallow notifications ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
candidate (th/create-profile* 3 {:is-active true})
|
|
extra-team (th/create-team* 4 {:profile-id (:id user)})
|
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
|
:profile-id (:id candidate)
|
|
:role :editor})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: remove the user from the organization ---
|
|
out (th/management-command!
|
|
{::th/type :remove-from-organization
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success ---
|
|
(t/is (th/success? out))
|
|
;; --- Verify: user no longer a member of extra-team ---
|
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
|
|
(t/is (nil? rel)))
|
|
;; --- Verify: candidate promoted to owner ---
|
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})]
|
|
(t/is (true? (:is-owner rel))))))))
|
|
|
|
(t/deftest remove-from-organization-exits-non-owned-team
|
|
;; When the user is a non-owner member of an organization team, they simply leave.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}
|
|
;; --- Message bus mock: swallow notifications ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
extra-team (th/create-team* 5 {:profile-id (:id organization-owner)})
|
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
|
:profile-id (:id user)
|
|
:role :editor})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: remove the user from the organization ---
|
|
out (th/management-command!
|
|
{::th/type :remove-from-organization
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success ---
|
|
(t/is (th/success? out))
|
|
;; --- Verify: user no longer a member of extra-team ---
|
|
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
|
|
(t/is (nil? rel)))
|
|
;; --- Verify: team still exists for the owner ---
|
|
(let [team (th/db-get :team {:id (:id extra-team)})]
|
|
(t/is (some? team)))))))
|
|
|
|
(t/deftest remove-from-organization-error-nobody-to-reassign
|
|
;; When the user owns a multi-member team but every other member is
|
|
;; also an owner, the auto-selection query finds nobody and raises.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}
|
|
;; --- Message bus mock: swallow notifications ---
|
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [other-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
extra-team (th/create-team* 6 {:profile-id (:id user)})
|
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
|
:profile-id (:id other-owner)
|
|
:role :editor})
|
|
_ (th/db-update! :team-profile-rel
|
|
{:is-owner true :is-admin false}
|
|
{:team-id (:id extra-team) :profile-id (:id other-owner)})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id other-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: remove the user from the organization ---
|
|
out (th/management-command!
|
|
{::th/type :remove-from-organization
|
|
::rpc/profile-id (:id other-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns error ---
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :validation (th/ex-type (:error out))))
|
|
(t/is (= :nobody-to-reassign-team (th/ex-code (:error out))))))))
|
|
|
|
;; Tests: get-remove-from-organization-summary
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest get-remove-from-organization-summary-no-extra-teams
|
|
;; User only has a default team — nothing to delete/transfer/exit.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}]
|
|
|
|
;; --- Setup: create profiles, team, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
organization-team (th/create-team* 1 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: get the summary ---
|
|
out (th/management-command!
|
|
{::th/type :get-remove-from-organization-summary
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success with all-zero summary ---
|
|
(t/is (th/success? out))
|
|
(t/is (= {:teams-to-delete 0
|
|
:teams-to-transfer 0
|
|
:teams-to-exit 0
|
|
:teams-to-detach 0}
|
|
(:result out)))))))
|
|
|
|
(t/deftest get-remove-from-organization-summary-with-teams-to-delete
|
|
;; User owns a sole-member extra organization team → 1 to delete.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
extra-team (th/create-team* 3 {:profile-id (:id user)})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: get the summary ---
|
|
out (th/management-command!
|
|
{::th/type :get-remove-from-organization-summary
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success with 1 team to delete ---
|
|
(t/is (th/success? out))
|
|
(t/is (= {:teams-to-delete 1
|
|
:teams-to-transfer 0
|
|
:teams-to-exit 0
|
|
:teams-to-detach 0}
|
|
(:result out)))))))
|
|
|
|
(t/deftest get-remove-from-organization-summary-with-teams-to-transfer
|
|
;; User owns a multi-member extra organization team → 1 to transfer.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
candidate (th/create-profile* 3 {:is-active true})
|
|
extra-team (th/create-team* 4 {:profile-id (:id user)})
|
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
|
:profile-id (:id candidate)
|
|
:role :editor})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: get the summary ---
|
|
out (th/management-command!
|
|
{::th/type :get-remove-from-organization-summary
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success with 1 team to transfer ---
|
|
(t/is (th/success? out))
|
|
(t/is (= {:teams-to-delete 0
|
|
:teams-to-transfer 1
|
|
:teams-to-exit 0
|
|
:teams-to-detach 0}
|
|
(:result out)))))))
|
|
|
|
(t/deftest get-remove-from-organization-summary-with-teams-to-exit
|
|
;; User is a non-owner member of an organization team → 1 to exit.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
extra-team (th/create-team* 5 {:profile-id (:id organization-owner)})
|
|
_ (th/create-team-role* {:team-id (:id extra-team)
|
|
:profile-id (:id user)
|
|
:role :editor})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: get the summary ---
|
|
out (th/management-command!
|
|
{::th/type :get-remove-from-organization-summary
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: RPC returns success with 1 team to exit ---
|
|
(t/is (th/success? out))
|
|
(t/is (= {:teams-to-delete 0
|
|
:teams-to-transfer 0
|
|
:teams-to-exit 1
|
|
:teams-to-detach 0}
|
|
(:result out)))))))
|
|
|
|
(t/deftest get-remove-from-organization-summary-does-not-mutate
|
|
;; Calling the summary endpoint must not modify any teams.
|
|
;; --- Deferred organization-summary: depends on team IDs from setup ---
|
|
(let [organization-summary-ref (atom nil)]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve organization-summary once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [& args]
|
|
(apply (nitrate-call-mock @organization-summary-ref) args))}]
|
|
|
|
;; --- Setup: create profiles, teams, organization-summary ---
|
|
(let [organization-owner (th/create-profile* 1 {:is-active true})
|
|
user (th/create-profile* 2 {:is-active true})
|
|
extra-team (th/create-team* 6 {:profile-id (:id user)})
|
|
organization-team (th/create-team* 99 {:profile-id (:id user)})
|
|
organization-id (uuid/random)
|
|
organization-summary (make-organization-summary
|
|
:organization-id organization-id
|
|
:organization-name "Acme Org"
|
|
:owner-id (:id organization-owner)
|
|
:your-penpot-teams [(:id organization-team)]
|
|
:organization-teams [(:id extra-team)])
|
|
;; --- Publish organization-summary so the mock can serve it ---
|
|
_ (reset! organization-summary-ref organization-summary)
|
|
|
|
;; --- Exercise: call the summary endpoint ---
|
|
_ (th/management-command!
|
|
{::th/type :get-remove-from-organization-summary
|
|
::rpc/profile-id (:id organization-owner)
|
|
:profile-id (:id user)
|
|
:organization-id organization-id
|
|
:default-team-id (:id organization-team)})]
|
|
|
|
;; --- Verify: both teams still exist and are undeleted ---
|
|
(let [t1 (th/db-get :team {:id (:id organization-team)})]
|
|
(t/is (some? t1))
|
|
(t/is (nil? (:deleted-at t1))))
|
|
(let [t2 (th/db-get :team {:id (:id extra-team)})]
|
|
(t/is (some? t2))
|
|
(t/is (nil? (:deleted-at t2))))
|
|
;; --- Verify: user is still a member of both teams ---
|
|
(let [rel1 (th/db-get :team-profile-rel {:team-id (:id organization-team) :profile-id (:id user)})]
|
|
(t/is (some? rel1)))
|
|
(let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
|
|
(t/is (some? rel2)))))))
|
|
|
|
(t/deftest notify-organization-sso-change-sends-setup-sso-email-once-per-recipient
|
|
;; --- Deferred mock data: depends on profile/team IDs from setup ---
|
|
(let [mock-data-ref (atom nil)
|
|
sent (atom [])]
|
|
(with-mocks
|
|
[;; --- Nitrate mock: nil during setup, serve data once available ---
|
|
nitrate-mock {:target 'app.nitrate/call
|
|
:return (fn [_cfg method _params]
|
|
(if-let [data @mock-data-ref]
|
|
(let [{:keys [owner-id member-id organization-summary]} data]
|
|
(case method
|
|
:get-organization-members [owner-id member-id]
|
|
:get-organization-summary organization-summary
|
|
nil))
|
|
nil))}
|
|
;; --- Email mock: capture sent emails ---
|
|
email-mock {:target 'app.email/send!
|
|
:return (fn [params] (swap! sent conj params) nil)}]
|
|
|
|
;; --- Setup: create profiles, team, organization-summary ---
|
|
(let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"})
|
|
member (th/create-profile* 2 {:is-active true
|
|
:fullname "Member"
|
|
:email "member@example.com"})
|
|
invited (th/create-profile* 3 {:is-active true
|
|
:fullname "Invited User"
|
|
:email "invited@example.com"})
|
|
organization-id (uuid/random)
|
|
organization-name "Acme Inc"
|
|
team (th/create-team* 1 {:profile-id (:id owner)})
|
|
organization-summary {:id organization-id
|
|
:name organization-name
|
|
:teams [{:id (:id team)}]}
|
|
params {::th/type :notify-organization-sso-change
|
|
:organization-id organization-id
|
|
:updated-props false
|
|
:announce-activation true}]
|
|
|
|
;; --- Setup: insert invitations ---
|
|
;; Member also has a pending invitation: should still receive only one email.
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:org-id organization-id
|
|
:team-id nil
|
|
:email-to (:email member)
|
|
:created-by (:id owner)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "24h")})
|
|
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team)
|
|
:org-id nil
|
|
:email-to (:email invited)
|
|
:created-by (:id owner)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "48h")})
|
|
|
|
;; --- Invite without an existing profile ---
|
|
(th/db-insert! :team-invitation
|
|
{:id (uuid/random)
|
|
:team-id (:id team)
|
|
:org-id nil
|
|
:email-to "external@example.com"
|
|
:created-by (:id owner)
|
|
:role "editor"
|
|
:valid-until (ct/in-future "72h")})
|
|
|
|
;; --- Publish mock data so the mock can serve it ---
|
|
(reset! mock-data-ref {:owner-id (:id owner)
|
|
:member-id (:id member)
|
|
:organization-summary organization-summary})
|
|
|
|
;; --- Exercise: notify SSO change ---
|
|
(th/management-command! params)
|
|
|
|
;; --- Verify: 4 emails sent to correct recipients ---
|
|
(let [emails (->> @sent (map :to) set)]
|
|
(t/is (= 4 (count @sent)))
|
|
(t/is (= #{"member@example.com"
|
|
(:email owner)
|
|
"invited@example.com"
|
|
"external@example.com"}
|
|
emails))
|
|
(doseq [email-params @sent]
|
|
(t/is (= organization-name (:organization-name email-params)))
|
|
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))))
|
|
|
|
(t/deftest notify-organization-sso-change-skips-email-when-not-active
|
|
(let [sent (atom [])
|
|
params {::th/type :notify-organization-sso-change
|
|
:organization-id (uuid/random)
|
|
:updated-props false
|
|
:announce-activation false}]
|
|
(with-redefs [eml/send! (fn [params] (swap! sent conj params))]
|
|
(th/management-command! params))
|
|
(t/is (empty? @sent))))
|
|
|
|
(t/deftest check-organization-sso-returns-valid-true
|
|
(let [organization-id (uuid/random)
|
|
out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)]
|
|
(th/management-command!
|
|
{::th/type :check-organization-sso
|
|
:organization-id organization-id
|
|
:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://idp.example.com"}))]
|
|
(t/is (th/success? out))
|
|
(t/is (true? (-> out :result :valid)))))
|
|
|
|
(t/deftest check-organization-sso-returns-valid-false-on-invalid-config
|
|
(let [out (th/management-command!
|
|
{::th/type :check-organization-sso
|
|
:organization-id (uuid/random)
|
|
:client-id "test-client"
|
|
:client-secret "test-secret"})]
|
|
(t/is (th/success? out))
|
|
(t/is (false? (-> out :result :valid)))))
|
|
|
|
(t/deftest check-organization-sso-passes-issuer-to-validation
|
|
(let [organization-id (uuid/random)
|
|
out (with-redefs [oidc/is-organization-sso-config-valid?
|
|
(fn [_cfg sso]
|
|
(and (= "test-client" (:client-id sso))
|
|
(= "https://idp.example.com/" (:issuer sso))))]
|
|
(th/management-command!
|
|
{::th/type :check-organization-sso
|
|
:organization-id organization-id
|
|
:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://idp.example.com/"}))]
|
|
(t/is (th/success? out))
|
|
(t/is (true? (-> out :result :valid)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; PUSH AUDIT EVENTS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def ^:private http-request
|
|
{:headers {"x-forwarded-for" "192.168.1.10"
|
|
"x-real-ip" "192.168.1.10"}})
|
|
|
|
(t/deftest push-audit-events-stores-backend-source
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(binding [cf/flags #{:audit-log}]
|
|
(let [prof (th/create-profile* 1 {:is-active true})
|
|
params {::th/type :push-audit-events
|
|
:events [{:name "test-action"
|
|
:profile-id (:id prof)
|
|
:props {:key "val"}
|
|
:type "action"}]}
|
|
params (with-meta params
|
|
{::http/request http-request})
|
|
out (th/management-command! params)]
|
|
(t/is (nil? (:error out)))
|
|
(t/is (:called? @audit-mock))
|
|
(t/is (= 1 (:call-count @audit-mock)))
|
|
(let [[_ event] (:call-args @audit-mock)]
|
|
(t/is (= "test-action" (:name event)))
|
|
(t/is (= "action" (:type event)))
|
|
(t/is (= (:id prof) (:profile-id event)))
|
|
(t/is (= "val" (get-in event [:props :key])))
|
|
(t/is (some? (:tracked-at event))))))))
|
|
|
|
(t/deftest push-audit-events-type-defaults-to-action
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(binding [cf/flags #{:audit-log}]
|
|
(let [prof (th/create-profile* 1 {:is-active true})
|
|
params {::th/type :push-audit-events
|
|
:events [{:name "no-type-event"
|
|
:profile-id (:id prof)}]}
|
|
params (with-meta params
|
|
{::http/request http-request})
|
|
out (th/management-command! params)]
|
|
(t/is (nil? (:error out)))
|
|
(let [[_ event] (:call-args @audit-mock)]
|
|
(t/is (= "action" (:type event)))
|
|
(t/is (= "no-type-event" (:name event))))))))
|
|
|
|
(t/deftest push-audit-events-multiple-events
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(binding [cf/flags #{:audit-log}]
|
|
(let [prof (th/create-profile* 1 {:is-active true})
|
|
params {::th/type :push-audit-events
|
|
:events [{:name "event-a"
|
|
:profile-id (:id prof)
|
|
:type "action"}
|
|
{:name "event-b"
|
|
:profile-id (:id prof)
|
|
:type "action"}]}
|
|
params (with-meta params
|
|
{::http/request http-request})
|
|
out (th/management-command! params)]
|
|
(t/is (nil? (:error out)))
|
|
(t/is (= 2 (:call-count @audit-mock)))
|
|
(let [events (mapv second (:call-args-list @audit-mock))]
|
|
(t/is (= "event-a" (:name (first events))))
|
|
(t/is (= "event-b" (:name (second events)))))))))
|
|
|
|
(t/deftest push-audit-events-merges-context
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(binding [cf/flags #{:audit-log}]
|
|
(let [prof (th/create-profile* 1 {:is-active true})
|
|
params {::th/type :push-audit-events
|
|
:events [{:name "context-test"
|
|
:profile-id (:id prof)
|
|
:type "action"
|
|
:context {:custom-key "custom-val"
|
|
:foo "bar"}}]}
|
|
params (with-meta params
|
|
{::http/request http-request})
|
|
out (th/management-command! params)]
|
|
(t/is (nil? (:error out)))
|
|
(let [[_ event] (:call-args @audit-mock)]
|
|
(t/is (= "custom-val" (get-in event [:context :custom-key])))
|
|
(t/is (= "bar" (get-in event [:context :foo])))
|
|
(t/is (= (:full cf/version) (get-in event [:context :version])))
|
|
(t/is (= "app" (get-in event [:context :initiator]))))))))
|