penpot/backend/test/backend_tests/rpc_management_nitrate_test.clj
Andrey Antukh d3ca9a0b83
✨ Add unified jobs substrate replacing legacy one-shot task system (#11542)
* 🎉 Move backend jobs to the unified job table substrate

Replace the generic 'task' table with a unified 'job' table as the
durable substrate for all backend jobs, and migrate every producer
and consumer to it. The legacy 'task' table stops receiving writes
but stays dormant (still cleaned by tasks-gc); upload-session
chunking and the other post-branch upstream changes are preserved.

New 0154 migration creates the 'job' table: dispatch and lifecycle
columns (name, queue, priority, scheduled_at, retries, status,
timestamps, props) plus optional user-facing ledger columns
(profile_id, target, progress, error, result, resource_id,
expires_at). No modified_at trigger: the application updates it on
every write for lease and orphan detection. Partial indexes cover
the dispatcher claim query, orphan scanning, and per-profile
listing.

New app.jobs public API: submit!/invoke!, heartbeats and progress
reporting, a precompiled job-def registry (name, schema, handler,
decoder, validator), an ephemeral request/response mode, and a
management API for external workers. The dispatcher claims
new/retry rows (FOR UPDATE SKIP LOCKED) with a plain JSON redis
payload and marks lease-expired running rows as orphaned; the
runner executes per-queue with the same retry conventions.

New jobs GC (daily): deletes expired rows and retains internal
terminal rows per :jobs-retention, touching referenced storage
objects so storage-gc-touched reclaims them. Job resources live in
a dedicated job-resource bucket with a reclaim branch in
storage-gc-touched.

All production call-sites (sendmail, delete-object, demo-purge,
file-gc, offload, webhooks, quotes, nitrate bulk delete,
snapshots, telemetry) and the ten cron tasks now submit through
app.jobs; cron is a pure scheduler and the legacy worker registry,
task-table inserts, and per-namespace handlers are removed.

Includes migrations, dispatcher, runner, jobs, request/response,
jobs GC, management API, and cron test suites.

AI-assisted-by: glm-5.3-flash
AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: kimi-k3
AI-assisted-by: mimo-v2.5

* ✨ Address review comments on jobs substrate

Resolves the four open review threads on the jobs PR. Orphaned jobs
are now marked aborted, a system-side terminal state with no retry:
the dispatcher sweep stores the end event with outcome aborted in a
single bulk insert in the same transaction, records the terminal
outcome next to the orphaned counter, and reports an error log;
orphans keep alerting through the log and the counter, with no
user notification.

The aborted state is terminal everywhere: the status CHECK, the
sweep, the lifecycle writers, the jobs GC retention, the backlog
gauges and the user status mapping, with tests covering each
transition. The claim comment, the 2.20 doc version and the legacy
task table notes are corrected, and objects-gc heartbeats per chunk.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-06 07:33:12 +02:00

2131 lines
116 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns 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.jobs :as jobs]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.util.ssrf :as ssrf]
[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-admin-console])
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
(let [owner-id-ref (atom nil)]
(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 (fn [_cfg method params]
(when (= method :get-organization-summary)
{:id (:organization-id params)
:name "Acme"
:owner-id @owner-id-ref
:teams []}))}]
(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}
_ (reset! owner-id-ref (:id owner))
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 invite-to-organization-rejects-non-owner
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(let [owner (th/create-profile* 103 {:is-active true})
attacker (th/create-profile* 104 {:is-active true})
organization-id (uuid/random)
organization {:id organization-id
:name "Trusted Organization"
:initials "TO"
:logo nil
:avatar-bg-url nil}
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id attacker)
:email "victim@example.com"
:organization organization})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock)))))))
(t/deftest invite-to-organization-rejects-unknown-organization
(with-mocks [email-mock {:target 'app.email/send :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 105 {:is-active true})
organization-id (uuid/random)
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id profile)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Organization"
:initials "FO"
:logo "https://evil.example/logo.png"
:avatar-bg-url nil}})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock))))))
(t/deftest invite-to-organization-uses-authoritative-branding
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 106 {:is-active true})
organization-id (uuid/random)
logo-id (uuid/random)
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:logo-id logo-id
:avatar-bg-url "https://trusted.example/avatar.svg"
:sso-active true
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Bank"
:initials "FB"
:logo "https://evil.example/logo.png"
:avatar-bg-url "https://evil.example/avatar.svg"
:sso-active false}})
email-params (second (:call-args @email-mock))
organization (:organization email-params)]
(t/is (th/success? out))
(t/is (= "Trusted Organization" (:name organization)))
(t/is (= "" (:initials organization)))
(t/is (str/ends-with? (str (:logo organization))
(str "/assets/by-id/" logo-id)))
(t/is (nil? (:avatar-bg-url organization)))
(t/is (true? (:sso-active organization))))))))
(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-air-gapped
(let [out (th/management-command! {::th/type :get-air-gapped})]
(t/is (th/success? out))
(t/is (false? (-> out :result :air-gapped))))
(binding [cf/flags (conj cf/flags :air-gapped-conf)]
(let [out (th/management-command! {::th/type :get-air-gapped})]
(t/is (th/success? out))
(t/is (true? (-> out :result :air-gapped))))))
(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 get-member-teams-returns-all-active-memberships
(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})
default-team (th/db-get :team {:id (:default-team-id profile)})
owned-team (th/create-team* 1 {:profile-id (:id profile)})
member-team (th/create-team* 2 {:profile-id (:id other)})
_ (th/create-team-role* {:team-id (:id member-team)
:profile-id (:id profile)
:role :editor})
deleted-team (th/create-team* 3 {:profile-id (:id profile)})
_ (th/db-update! :team
{:deleted-at (ct/now)}
{:id (:id deleted-team)})
out (th/management-command! {::th/type :get-member-teams
::rpc/profile-id (:id profile)})]
(t/is (th/success? out))
(let [teams-by-id (->> out :result (d/index-by :id))]
(t/is (= #{(:id default-team) (:id owned-team) (:id member-team)}
(set (keys teams-by-id))))
(t/is (true? (get-in teams-by-id [(:id default-team) :is-default])))
(t/is (false? (get-in teams-by-id [(:id owned-team) :is-default])))
(t/is (false? (get-in teams-by-id [(:id member-team) :is-default])))))))
(t/deftest update-profile-theme-updates-only-theme
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true
:fullname "Nitrate User"
:lang "es"
:theme "light"})
_ (th/db-update! :profile
{:lang "es"}
{:id (:id profile)})
out (th/management-command! {::th/type :update-profile-theme
::rpc/profile-id (:id profile)
:theme "dark"})
saved (-> (th/db-get :profile {:id (:id profile)})
(profile/decode-row))]
(t/is (th/success? out))
(t/is (= "dark" (:theme saved)))
(t/is (= "Nitrate User" (:fullname saved)))
(t/is (= "es" (:lang saved))))))
(t/deftest update-profile-theme-rejects-invalid-theme
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true})
out (th/management-command! {::th/type :update-profile-theme
::rpc/profile-id (:id profile)
:theme "invalid"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation)))))
(t/deftest update-profile-props-merges-onboarding-props
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true})
out (th/management-command! {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:nitrate-onboarding-viewed true
:onboarding-questions-answered true
:onboarding-questions
{:role "developer"
:company-size "2-100"}}})
saved (-> (th/db-get :profile {:id (:id profile)})
(profile/decode-row))]
(t/is (th/success? out))
(t/is (true? (get-in saved [:props :nitrate-onboarding-viewed])))
(t/is (true? (get-in saved [:props :onboarding-questions-answered])))
(t/is (= {:role "developer" :company-size "2-100"}
(get-in saved [:props :onboarding-questions]))))))
(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 (= team-id (-> @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-team-removal-publishes-event
(let [team-id (uuid/random)
organization-name "OrgA"
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
:organization {:name organization-name}}))]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= team-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)]
(t/is (= :team-organization-change (:type msg)))
(t/is (= "dashboard.team-no-longer-belong-organization" (:notification msg)))
(t/is (= team-id (-> msg :team :id)))
(t/is (= organization-name (-> msg :team :organization :name)))
(t/is (nil? (-> msg :team :organization :id))))))
(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 (= "Personal Projects" (: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.jobs/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 (= organization-id (: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-organization-change-sso-publishes-event
(let [organization-id (uuid/random)
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-organization-sso-change
::rpc/profile-id (uuid/random)
:organization-id organization-id
:updated-props true
:announce-activation false}))]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= organization-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)]
(t/is (= :organization-change-sso (:type msg)))
(t/is (= organization-id (:organization-id 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.jobs/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? #(contains? #{organization-1-id organization-2-id} (: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 [_cfg 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 [_cfg 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 [ssrf/safe-url? (constantly true)
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 [ssrf/safe-url? (constantly true)
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)))))
(t/deftest check-organization-sso-returns-valid-false-on-ssrf-blocked-issuer
(t/testing "an SSRF-blocked issuer must not reach the OIDC validation flow"
(let [called? (atom false)
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg _sso] (reset! called? true) true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id (uuid/random)
:client-id "test-client"
:client-secret "test-secret"
:issuer "http://127.0.0.1/idp"}))]
(t/is (th/success? out))
(t/is (false? (-> out :result :valid)))
(t/is (false? @called?)
"OIDC validation should not run when the issuer is SSRF-blocked"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; 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]))))))))
(t/deftest push-audit-events-initiator-is-plain-string
;; Shared-key callers (e.g. admin-console) carry :app.http/auth-key-id as a
;; keyword; the stored initiator must be a plain string, and a
;; caller-supplied initiator must never survive (server context wins).
(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"
:initiator "spoofed"}}]}
params (with-meta params
{::http/request (assoc http-request
::http/auth-key-id :admin-console)})
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 (= "admin-console" (get-in event [:context :initiator])))
(t/is (string? (get-in event [:context :initiator]))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests: send-renewal-email
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- send-renewal-email-params
[profile user-name]
{::th/type :send-renewal-email
:profile-id (:id profile)
:user-email (:email profile)
:user-name user-name
:renewal-date "2026-01-01"
:estimated-amount 42.0
:organizations [{:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}]})
(t/deftest send-renewal-email-falls-back-to-profile-fullname-when-name-is-nil
;; `nil` user-name means "no override": the RPC must look up the
;; account owner's real name instead of sending a blank greeting.
(with-mocks [email-mock {:target 'app.email/send :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true :fullname "Nitrate User"})
out (th/management-command! (send-renewal-email-params profile nil))]
(t/is (th/success? out))
(let [[_cfg params] (:call-args @email-mock)]
(t/is (= "Nitrate User" (:user-name params)))))))
(t/deftest send-renewal-email-keeps-explicit-empty-name
;; An explicit "" means the caller deliberately wants no name shown
;; and must not be replaced by the profile's fullname.
(with-mocks [email-mock {:target 'app.email/send :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true :fullname "Nitrate User"})
out (th/management-command! (send-renewal-email-params profile ""))]
(t/is (th/success? out))
(let [[_cfg params] (:call-args @email-mock)]
(t/is (= "" (:user-name params)))))))
(t/deftest send-renewal-email-treats-blank-name-as-empty
;; A blank name is trimmed and follows the same path as "": no name
;; is shown and the profile's fullname is not used.
(with-mocks [email-mock {:target 'app.email/send :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true :fullname "Nitrate User"})
out (th/management-command! (send-renewal-email-params profile " "))]
(t/is (th/success? out))
(let [[_cfg params] (:call-args @email-mock)]
(t/is (= "" (:user-name params)))))))