diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 9106298915..085c803c0b 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -10,6 +10,7 @@ [app.auth :refer [derive-password-weak]] [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -25,7 +26,35 @@ (def ^:private schema:create-demo-profile [:map - [:skip-onboarding {:optional true} ::sm/boolean]]) + [:skip-onboarding {:optional true} ::sm/boolean] + [:expires-in {:optional true} ::ct/duration]]) + +(def ^:private min-expires-in + (ct/duration "5m")) + +(defn- resolve-deletion-delay + "Resolve the effective `:demo-purge` delay for a demo profile. Without + `expires-in` it falls back to the global deletion delay. Otherwise the + value is only allowed to shorten the lifetime: below the 5 minutes + minimum or above the global delay it raises a validation error." + [expires-in] + (let [max-delay (cf/get-deletion-delay)] + (cond + (nil? expires-in) + max-delay + + (ct/is-before? expires-in min-expires-in) + (ex/raise :type :validation + :code :invalid-expires-in + :hint "expires-in is below the 5 minutes minimum.") + + (ct/is-after? expires-in max-delay) + (ex/raise :type :validation + :code :invalid-expires-in + :hint "expires-in exceeds the configured deletion delay.") + + :else + expires-in))) (sv/defmethod ::create-demo-profile "A command that is responsible of creating a demo purpose @@ -34,42 +63,44 @@ {::rpc/auth false ::doc/added "1.15" ::doc/changes [["1.15" "This method is migrated from mutations to commands."] - ["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]] + ["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."] + ["2.18" "Add optional `expires-in` param. When set, the demo purge is scheduled that long after creation instead of the global deletion delay. Only values between 5 minutes and the global delay are accepted."]] ::sm/params schema:create-demo-profile} - [cfg {:keys [skip-onboarding]}] + [cfg {:keys [skip-onboarding expires-in]}] (when-not (contains? cf/flags :demo-users) (ex/raise :type :validation :code :demo-users-not-allowed :hint "Demo users are disabled by config.")) - (let [sem (uuid/next) - email (str "demo-" sem "@demo.example.com") - fullname (str "Demo User " sem) + (let [deletion-delay (resolve-deletion-delay expires-in) + sem (uuid/next) + email (str "demo-" sem "@demo.example.com") + fullname (str "Demo User " sem) - password (-> (bn/random-bytes 16) - (bc/bytes->b64 true) - (bc/bytes->str)) + password (-> (bn/random-bytes 16) + (bc/bytes->b64 true) + (bc/bytes->str)) - params {:email email - :fullname fullname - :is-active true - :is-demo true - :password (derive-password-weak password) - :props (cond-> {} - skip-onboarding (assoc :onboarding-viewed true - ;; Redundant today: auth/create-profile - ;; overwrites this with the current - ;; version, kept so the skip does not - ;; depend on that default. - :release-notes-viewed (:main cf/version)))} - profile (db/tx-run! cfg (fn [cfg] - (->> (auth/create-profile cfg params) - (auth/create-profile-rels cfg))))] + params {:email email + :fullname fullname + :is-active true + :is-demo true + :password (derive-password-weak password) + :props (cond-> {} + skip-onboarding (assoc :onboarding-viewed true + ;; Redundant today: auth/create-profile + ;; overwrites this with the current + ;; version, kept so the skip does not + ;; depend on that default. + :release-notes-viewed (:main cf/version)))} + profile (db/tx-run! cfg (fn [cfg] + (->> (auth/create-profile cfg params) + (auth/create-profile-rels cfg))))] (wrk/submit! (-> cfg (assoc ::wrk/task :demo-purge) - (assoc ::wrk/delay (cf/get-deletion-delay)) + (assoc ::wrk/delay deletion-delay) (assoc ::wrk/params {:profile-id (:id profile)}))) (with-meta {:email email diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj index 3bda13fc61..505ddb8f93 100644 --- a/backend/test/backend_tests/rpc_demo_test.clj +++ b/backend/test/backend_tests/rpc_demo_test.clj @@ -7,8 +7,10 @@ (ns backend-tests.rpc-demo-test (:require [app.auth :as auth] + [app.common.time :as ct] [app.config :as cf] [app.rpc.commands.profile :as profile] + [app.worker :as wrk] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -74,3 +76,48 @@ :skip-onboarding "yes"})] (t/is (th/ex-of-type? error :validation)) (t/is (th/ex-of-code? error :params-validation))))) + +(t/deftest create-demo-profile-uses-global-delay-by-default + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [captured (atom nil)] + (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/delay]}] + (reset! captured {:task task :delay delay}))] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})] + (t/is (nil? error)) + (t/is (some? (:email result))) + (t/is (= :demo-purge (:task @captured))) + (t/is (= (cf/get-deletion-delay) (:delay @captured)))))))) + +(t/deftest create-demo-profile-accepts-short-expires-in + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [captured (atom nil)] + (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/delay]}] + (reset! captured {:task task :delay delay}))] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile + :expires-in "10m"})] + (t/is (nil? error)) + (t/is (some? (:email result))) + (t/is (= :demo-purge (:task @captured))) + (t/is (= (ct/duration "10m") (:delay @captured)))))))) + +(t/deftest create-demo-profile-rejects-expires-in-below-minimum + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile + :expires-in "1m"})] + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :invalid-expires-in))))) + +(t/deftest create-demo-profile-rejects-expires-in-above-global-delay + (with-redefs [cf/flags (conj cf/flags :demo-users) + cf/get-deletion-delay (fn [] (ct/duration {:days 7}))] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile + :expires-in "200h"})] + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :invalid-expires-in))))) + +(t/deftest create-demo-profile-rejects-non-duration-expires-in + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile + :expires-in "yes"})] + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation))))) diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index 5410ee6a49..c37216b99a 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -175,8 +175,14 @@ #?(:clj (defn parse-duration + "Parse a value into a Duration. Total: returns the input unchanged + when it cannot be parsed, so schema decoding never throws and + invalid values fail validation with a clean params error instead." [s] - (duration s))) + (try + (duration s) + (catch Exception _ + s)))) #?(:clj (defn format-duration diff --git a/common/test/common_tests/time_test.cljc b/common/test/common_tests/time_test.cljc index 3015c4fd36..0c7812cc54 100644 --- a/common/test/common_tests/time_test.cljc +++ b/common/test/common_tests/time_test.cljc @@ -14,3 +14,17 @@ dtb (dt/inst 20000)] (t/is (false? (dt/is-after? dta dtb))) (t/is (true? (dt/is-before? dta dtb))))) + +#?(:clj + (t/deftest parse-duration-test + (t/is (dt/duration? (dt/parse-duration "10m"))) + (t/is (= (dt/duration "10m") (dt/parse-duration "10m"))) + (t/is (= (dt/duration "1h") (dt/parse-duration "1h"))) + + ;; Invalid values are returned unchanged instead of throwing, so + ;; they fail the `duration?` schema predicate with a clean + ;; validation error downstream. + (t/is (= "yes" (dt/parse-duration "yes"))) + (t/is (not (dt/duration? (dt/parse-duration "yes")))) + (t/is (= true (dt/parse-duration true))) + (t/is (not (dt/duration? (dt/parse-duration true))))))