From eec06a598798ed521eb7d16c310fc6879da1e48d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 25 Sep 2026 14:13:12 +0200 Subject: [PATCH] :bug: Allow registration with disabled public registration (#11912) * :bug: Allow invitation-based registration when disable-registration is set (#5178) Per documentation, disable-registration 'disables registration (still enabled for invitations only)'. Two bugs prevented this: 1. verify_token.clj: when processing an invitation token for a non-logged-in user with no member-id, the redirect included registration-disabled? in its condition, sending invited users to the login page instead of the register page. 2. auth.clj validate-register-attempt!: the registration-disabled check fired unconditionally before the invitation-token check, rejecting the actual register RPC even with a valid invitation. Fix: in verify_token.clj remove registration-disabled? from the redirect condition for new-user invitations. In auth.clj restructure the check as an if/else: with an invitation token, validate the token and allow registration; without one, enforce the flag as before. * :bug: Allow registration with disabled public registration Allow valid team invitations to create new profiles when public registration is disabled, while keeping password login and invitation validation required. Add backend regression coverage for flag combinations and verify-token redirects, frontend route coverage, and configuration documentation. Closes #5178 AI-assisted-by: space-bunny-free * :bug: Revalidate active invitation during registration Require a live, unexpired team invitation before using the registration exception, and recheck it before creating a profile. Reuse the same lookup in invitation token verification. Add regression tests for canceled and expired invitations, the registration race, and explicit redirect contracts. Update docs and backend auth guidance. AI-assisted-by: Space Bunny Free * :bug: Lock and normalize invitation registration checks Lock active invitation rows during transactional registration and acceptance so cancellations cannot race with profile or membership creation. Normalize invitation emails before comparisons and database lookups. Add concurrency, email casing, and final flag regression tests. AI-assisted-by: Space Bunny Free --------- Co-authored-by: Sumit Ridhal --- .../auth-permissions-product-domains.md | 1 + backend/src/app/rpc/commands/auth.clj | 63 +++-- .../app/rpc/commands/teams_invitations.clj | 18 ++ backend/src/app/rpc/commands/verify_token.clj | 29 +- .../test/backend_tests/rpc_profile_test.clj | 254 ++++++++++++++++-- docs/technical-guide/configuration.md | 19 +- .../technical-guide/getting-started/docker.md | 5 +- .../frontend_tests/data/nitrate_test.cljs | 18 ++ 8 files changed, 344 insertions(+), 63 deletions(-) diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index dd0b09edde..0423a838a6 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -12,6 +12,7 @@ - Lockout and RPC rate limits share the `app.http.errors/handle-error :rate-limit` HTTP path: status 429, body `{:type :rate-limit :code ... :hint ... :ttl ...}`, and any supplied `::http/headers` preserved. Account lockout raises `:code :account-locked` and, when it carries a non-nil `:ttl` (seconds), the handler adds `retry-after`. The RPC limiter (`app.rpc.rlimit`) raises `:code :request-blocked` and sets `retry-after` itself in `::http/headers` (seconds until the longest rejecting limit resets), alongside `x-rate-limit-remaining`/`x-rate-limit-reset`. CORS (`app.http.middleware/with-cors-headers`) exposes `content-type`, `retry-after`, and both `x-rate-limit-*` headers. External flags: `enable-account-lockout`, `enable-rpc-rlimit`. - Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI. - Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. +- When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile. - HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. ## Permission model diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index ff2e0c0ae3..a838eff3be 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -31,6 +31,7 @@ [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] + [app.rpc.commands.teams-invitations :as teams-invitations] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.setup :as-alias setup] @@ -225,23 +226,51 @@ ;; ---- COMMAND: Prepare Register +(defn- validate-registration-flags! + ([cfg invitation] + (validate-registration-flags! cfg invitation false)) + ([cfg invitation lock-invitation?] + (when-not (contains? cf/flags :login-with-password) + (ex/raise :type :restriction + :code :registration-disabled + :hint "registration disabled")) + + (when-not (contains? cf/flags :registration) + (if (nil? invitation) + (ex/raise :type :restriction + :code :registration-disabled + :hint "registration disabled") + (let [opts (when lock-invitation? {::db/for-update true})] + (when (nil? (teams-invitations/active-invitation cfg invitation opts)) + (ex/raise :type :validation + :code :invalid-token + :hint "no active invitation associated with the token"))))))) + (defn- validate-register-attempt! [cfg params] - - (when (or (not (contains? cf/flags :registration)) - (not (contains? cf/flags :login-with-password))) - (ex/raise :type :restriction - :code :registration-disabled - :hint "registration disabled")) - - (when (contains? params :invitation-token) - (let [invitation (tokens/verify cfg + (let [invitation? (contains? params :invitation-token) + invitation (when invitation? + (tokens/verify cfg {:token (:invitation-token params) - :iss :team-invitation})] - (when-not (= (:email params) (:member-email invitation)) - (ex/raise :type :restriction - :code :email-does-not-match-invitation - :hint "email should match the invitation")))) + :iss :team-invitation}))] + + (when (or (not (contains? cf/flags :login-with-password)) + (and (not (contains? cf/flags :registration)) + (not invitation?))) + (ex/raise :type :restriction + :code :registration-disabled + :hint "registration disabled")) + + (when (and invitation? + (not= (profile/clean-email (:email params)) + (profile/clean-email (:member-email invitation)))) + (ex/raise :type :restriction + :code :email-does-not-match-invitation + :hint "email should match the invitation")) + + (when (and invitation? + (not (contains? cf/flags :registration))) + (validate-registration-flags! cfg invitation))) (when (and (email.blacklist/enabled? cfg) (email.blacklist/contains? cfg (:email params))) @@ -467,6 +496,9 @@ (defn register-profile [{:keys [::db/conn] :as cfg} {:keys [token] :as params}] (let [claims (tokens/verify cfg {:token token :iss :prepared-register}) + invitation (when-let [token (:invitation-token claims)] + (tokens/verify cfg {:token token :iss :team-invitation})) + _ (validate-registration-flags! cfg invitation true) params (cond-> claims (:accept-newsletter-updates params) (update :props assoc :newsletter-updates true)) @@ -484,9 +516,6 @@ created? (-> profile meta :created true?) - invitation (when-let [token (:invitation-token params)] - (tokens/verify cfg {:token token :iss :team-invitation})) - props (-> (audit/profile->props profile) (assoc :from-invitation (some? invitation)))] diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index f78ef08c70..fe8abd5b24 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -70,6 +70,24 @@ [sql:check-recent-invitation team-id email])] (some? (db/exec-one! conn query)))) +(defn active-invitation + ([cfg claims] + (active-invitation cfg claims {})) + ([cfg {:keys [team-id organization-id member-email]} opts] + (let [email (profile/clean-email member-email) + invitation (cond + organization-id (db/get* cfg :team-invitation + {:email-to email + :org-id organization-id} + opts) + team-id (db/get* cfg :team-invitation + {:email-to email + :team-id team-id} + opts))] + (when (and invitation + (ct/is-after? (:valid-until invitation) (ct/now))) + invitation)))) + (defn- create-invitation-token [cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}] (tokens/generate cfg diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 4a7d8a8439..ac0875cb56 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -21,6 +21,7 @@ [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] + [app.rpc.commands.teams-invitations :as teams-invitations] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.rpc.quotes :as quotes] @@ -102,12 +103,6 @@ ;; --- Team Invitation -(def ^:private sql:get-organization-invitation - "SELECT * - FROM team_invitation - WHERE email_to = ? - AND org_id = ?") - (def ^:private sql:delete-organization-invitation "DELETE FROM team_invitation WHERE email_to = ? @@ -198,17 +193,16 @@ :code :invalid-invitation-token :hint "invitation token contains unexpected data")) - (let [invitation (if organization-id - (db/exec-one! conn [sql:get-organization-invitation member-email organization-id]) - (db/get* conn :team-invitation - {:email-to member-email - :team-id team-id})) + (let [member-email (profile/clean-email member-email) + claims (assoc claims :member-email member-email) + invitation (teams-invitations/active-invitation + cfg + claims + {::db/for-update true}) profile (db/get* conn :profile {:id profile-id} {:columns [:id :email :default-team-id]}) - registration-disabled? (not (contains? cf/flags :registration)) - - organization-invitation? (and (contains? cf/flags :admin-console) organization-id)] + organization-invitation? (and (contains? cf/flags :admin-console) organization-id)] (if profile (do @@ -346,11 +340,12 @@ "no invitation associated with the token"))) ;; If we have not logged-in user, and invitation comes with member-id we - ;; redirect user to login, if no member-id is present and in the invitation - ;; token and registration is enabled, we redirect user the the register page. + ;; redirect user to login. If no member-id is present this is an invitation + ;; for a new user — send them to the register page. Invitations bypass + ;; the disable-registration flag per documentation. {:invitation-token token :iss :team-invitation - :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) + :redirect-to (if member-id :auth-login :auth-register) :state :pending})))) ;; --- Default diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 5f5b803c4e..ec54555701 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -16,9 +16,11 @@ [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] + [app.rpc.commands.teams-invitations :as teams-invitations] [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.java.io :as io] + [clojure.string :as cstring] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] @@ -664,6 +666,172 @@ (let [profile (th/db-get :profile {:email "hello@example.com"})] (t/is (false? (:is-active profile))))))))) +(defn- create-invitation-fixture + [email valid-until] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + invitation (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :email-to email + :created-by (:id owner) + :role "editor" + :valid-until valid-until})] + {:owner owner + :team team + :invitation invitation})) + +(defn- create-test-invitation-token + ([{:keys [owner team]} email] + (create-test-invitation-token {:owner owner + :team team} + email + {})) + ([{:keys [owner team]} email claims] + (tokens/generate th/*system* + (merge {:iss :team-invitation + :exp (ct/in-future "48h") + :profile-id (:id owner) + :role :editor + :team-id (:id team) + :member-email email} + claims)))) + +(t/deftest prepare-register-with-invitation-and-disabled-registration + (with-redefs [app.config/flags #{:login-with-password :email-verification}] + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [email "invited@example.com" + fixture (create-invitation-fixture email (ct/in-future "48h")) + itoken (create-test-invitation-token fixture email) + data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email email + :password "Foobar12!"} + out (th/command! data)] + + (t/is (th/success? out)) + (t/is (string? (get-in out [:result :token]))) + + (let [register-out (th/command! {::th/type :register-profile + :token (get-in out [:result :token])})] + (t/is (th/success? register-out)) + (t/is (= 1 (:call-count @mock)))))))) + +(t/deftest prepare-register-normalizes-invitation-email + (with-redefs [app.config/flags #{:login-with-password :email-verification}] + (let [email "mixed-case@example.com" + fixture (create-invitation-fixture email (ct/in-future "48h")) + itoken (create-test-invitation-token + fixture + email + {:member-email (cstring/upper-case email)}) + out (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email email + :password "Foobar12!"})] + (t/is (th/success? out))))) + +(t/deftest prepare-register-with-canceled-invitation-is-rejected + (with-redefs [app.config/flags #{:login-with-password :email-verification}] + (let [email "canceled@example.com" + fixture (create-invitation-fixture email (ct/in-future "48h")) + itoken (create-test-invitation-token fixture email) + data {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email email + :password "Foobar12!"}] + (t/is (th/success? (th/command! data))) + (th/db-delete! :team-invitation + {:team-id (:id (:team fixture)) + :email-to email}) + (let [out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-token (-> out :error ex-data :code))) + (t/is (nil? (th/db-get :profile {:email email}))))))) + +(t/deftest prepare-register-with-expired-invitation-is-rejected + (with-redefs [app.config/flags #{:login-with-password :email-verification}] + (let [email "expired@example.com" + fixture (create-invitation-fixture email (ct/in-past "48h")) + itoken (create-test-invitation-token fixture email) + out (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email email + :password "Foobar12!"})] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-token (-> out :error ex-data :code))) + (t/is (nil? (th/db-get :profile {:email email})))))) + +(t/deftest active-invitation-locks-row-during-transaction + (let [email "locked@example.com" + fixture (create-invitation-fixture email (ct/in-future "48h")) + claims {:team-id (:id (:team fixture)) + :member-email email} + locked (promise) + release (promise) + holder (future + (db/tx-run! th/*system* + (fn [cfg] + (let [invitation (teams-invitations/active-invitation + cfg + claims + {::db/for-update true})] + (deliver locked invitation) + (deref release 10000 ::timeout) + invitation)))) + invitation (deref locked 10000 nil) + cancellation (when invitation + (future + (try + {:result + (db/tx-run! th/*system* + (fn [cfg] + (let [conn (::db/conn cfg)] + (db/exec-one! conn ["SET LOCAL lock_timeout = '100ms'"]) + (db/delete! conn :team-invitation + {:team-id (:id (:team fixture)) + :email-to email}))))} + (catch Throwable cause + {:error cause})))) + cancellation-result (when cancellation + (deref cancellation 10000 nil)) + _ (deliver release true) + holder-result (deref holder 10000 nil) + _ (when cancellation + (deref cancellation 10000 nil))] + + (t/is (some? invitation)) + (t/is (contains? cancellation-result :error)) + (t/is (some? holder-result)))) + +(t/deftest register-profile-revalidates-invitation-before-creating-profile + (with-redefs [app.config/flags #{:login-with-password :email-verification}] + (let [email "canceled-between@example.com" + fixture (create-invitation-fixture email (ct/in-future "48h")) + itoken (create-test-invitation-token fixture email) + prep (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email email + :password "Foobar12!"}) + rtoken (-> prep :result :token)] + (t/is (th/success? prep)) + (th/db-delete! :team-invitation + {:team-id (:id (:team fixture)) + :email-to email}) + (let [out (th/command! {::th/type :register-profile + :token rtoken})] + (t/is (not (th/success? out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-token (-> out :error ex-data :code))) + (t/is (nil? (th/db-get :profile {:email email}))))))) + (t/deftest prepare-and-register-with-invitation-and-enabled-registration-1 ;; With email-verification ENABLED (the default), a brand-new ;; profile created via the invitation flow is NOT active yet, so @@ -769,28 +937,8 @@ (t/is (= :restriction (:type edata))) (t/is (= :email-does-not-match-invitation (:code edata)))))) -(t/deftest prepare-and-register-with-invitation-and-disabled-registration-1 - (with-redefs [app.config/flags [:disable-registration]] - (let [itoken (tokens/generate th/*system* - {:iss :team-invitation - :exp (ct/in-future "48h") - :role :editor - :team-id uuid/zero - :member-email "user@example.com"}) - data {::th/type :prepare-register-profile - :invitation-token itoken - :fullname "foobar" - :email "user@example.com" - :password "Foobar12!"} - out (th/command! data)] - - (t/is (not (th/success? out))) - (let [edata (-> out :error ex-data)] - (t/is (= :restriction (:type edata))) - (t/is (= :registration-disabled (:code edata))))))) - -(t/deftest prepare-and-register-with-invitation-and-disabled-registration-2 - (with-redefs [app.config/flags [:disable-registration]] +(t/deftest prepare-register-with-invitation-and-disabled-registration-and-wrong-email + (with-redefs [app.config/flags #{:login-with-password}] (let [itoken (tokens/generate th/*system* {:iss :team-invitation :exp (ct/in-future "48h") @@ -808,10 +956,10 @@ (t/is (not (th/success? out))) (let [edata (-> out :error ex-data)] (t/is (= :restriction (:type edata))) - (t/is (= :registration-disabled (:code edata))))))) + (t/is (= :email-does-not-match-invitation (:code edata))))))) -(t/deftest prepare-and-register-with-invitation-and-disabled-login-with-password - (with-redefs [app.config/flags [:disable-login-with-password]] +(t/deftest prepare-register-with-invitation-and-disabled-login-with-password + (with-redefs [app.config/flags #{:registration}] (let [itoken (tokens/generate th/*system* {:iss :team-invitation :exp (ct/in-future "48h") @@ -831,6 +979,62 @@ (t/is (= :restriction (:type edata))) (t/is (= :registration-disabled (:code edata))))))) +(t/deftest register-profile-rejects-disabled-login-with-password + (let [email "login-disabled@example.com" + fixture (create-invitation-fixture email (ct/in-future "48h"))] + (with-redefs [app.config/flags #{:login-with-password :registration}] + (let [itoken (create-test-invitation-token fixture email) + prep (th/command! {::th/type :prepare-register-profile + :invitation-token itoken + :fullname "foobar" + :email email + :password "Foobar12!"})] + (t/is (th/success? prep)) + (with-redefs [app.config/flags #{:registration}] + (let [out (th/command! {::th/type :register-profile + :token (-> prep :result :token)})] + (t/is (not (th/success? out))) + (t/is (= :registration-disabled (-> out :error ex-data :code))) + (t/is (nil? (th/db-get :profile {:email email}))))))))) + +(t/deftest verify-token-redirects-invitation-based-on-member-id + (with-redefs [app.config/flags #{:login-with-password}] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + email "invited@example.com" + token (fn [claims] + (tokens/generate th/*system* + (merge {:iss :team-invitation + :exp (ct/in-future "48h") + :role :editor + :profile-id (:id owner) + :team-id (:id team) + :member-email email} + claims)))] + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :email-to email + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (let [new-user-token (token {}) + existing-user-token (token {:member-id (uuid/random)}) + new-user-out (th/command! {::th/type :verify-token + :token new-user-token}) + existing-user-out (th/command! {::th/type :verify-token + :token existing-user-token})] + (t/is (th/success? new-user-out)) + (t/is (= :auth-register (get-in new-user-out [:result :redirect-to]))) + (t/is (= :pending (get-in new-user-out [:result :state]))) + (t/is (= new-user-token (get-in new-user-out [:result :invitation-token]))) + + (t/is (th/success? existing-user-out)) + (t/is (= :auth-login (get-in existing-user-out [:result :redirect-to]))) + (t/is (= :pending (get-in existing-user-out [:result :state]))) + (t/is (= existing-user-token (get-in existing-user-out [:result :invitation-token]))))))) + (t/deftest prepare-register-with-registration-disabled (with-redefs [app.config/flags #{}] (let [data {::th/type :prepare-register-profile diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index cb5a4465c6..9fd0282761 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -96,13 +96,25 @@ enabled with enable-email-whitelist flag. For autoenable it when PENPOT_REGISTRATION_DOMAIN_WHITELIST is set with not-empty content. -Penpot also comes with an option to completely disable the registration process; -for this, use the following flag: +Penpot also comes with an option to disable public registration. Users with a +valid and active team invitation can still register an account when password +login is enabled. The invitation must still exist in the database and its +validity period must not have ended. To disable public registration, use the +following flag: ```bash PENPOT_FLAGS: [...] disable-registration ``` +For invitation-based registration, keep the password login flag enabled: + +```bash +PENPOT_FLAGS: [...] disable-registration enable-login-with-password +``` + +The `disable-login-with-password` flag still disables password-based login and +registration, including password-based registration through an invitation. + This option is only recommended for demo instances, not for production environments. ### Authentication Providers @@ -433,7 +445,8 @@ This is an example of a demo configuration: PENPOT_FLAGS: disable-registration enable-demo-users enable-demo-warning ``` -**disable-registration** prevents any user from registering in the platform. +**disable-registration** prevents public registration in the platform, while +valid team invitations can still create accounts when password login is enabled. **enable-demo-users** creates users with a default expiration time of 7 days, and once expired they are completely deleted with all the generated content. From the registration page, there is a link with a `Create demo account` which creates one of these diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 66d7a311ba..92c8df6ca0 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -83,7 +83,10 @@ Penpot provides a script (`manage.py`) with some administrative tasks to perform flag set in the docker-compose.yaml file. For older versions of docker-compose.yaml file, this flag is set in the backend service. -For instance, if the registration is disabled, the only way to create a new user is with this script: +For users who do not have a team invitation, if public registration is disabled, the +way to create a new user is with this script. Users with a valid and active team +invitation can register through the invitation link when password login is enabled. +The invitation must still exist and must not have expired. ```bash docker exec -ti penpot-penpot-backend-1 python3 manage.py create-profile diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index 3937fb7afa..19ed782d0d 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -122,6 +122,24 @@ (t/is (contains? event :days-since-member-added)) (t/is (nil? (:days-since-member-added event))))))) +(t/deftest pending-team-invitation-redirects-to-register + (let [emitted (atom []) + invitation-token "invitation-123"] + (with-redefs [st/emit! (fn + ([event] + (swap! emitted conj event)) + ([event & events] + (swap! emitted into (cons event events))))] + (verify-token/handle-token + {:iss :team-invitation + :state :pending + :redirect-to :auth-register + :invitation-token invitation-token}) + + (let [event @(first @emitted)] + (t/is (= :auth-register (:id event))) + (t/is (= invitation-token (get-in event [:params :invitation-token]))))))) + (t/deftest accept-organization-invitation-audit-event-test (let [emitted (atom [])] (with-redefs [st/emit! (fn