Compare commits

...
3 Commits
Author SHA1 Message Date
Andrey Antukh 35fa4d895b 📎 Fix doc version for expires-in change entry
The expires-in change entry was documented under 2.20 but the current version is 2.18. AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 07:45:55 +00:00
Andrey Antukh b36eba673f 🐛 Make duration schema decoding total instead of throwing
parse-duration returned by the duration schema decoder threw DateTimeParseException on invalid strings, escaping params validation as a raw error. It now returns the input unchanged so invalid values fail the duration predicate with a clean params-validation error. Closes #11573 AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 07:31:09 +00:00
Andrey Antukh b9e67f4304 Add expires-in option to create-demo-profile
Allow passing an optional expires-in duration when creating a demo profile so its purge is scheduled sooner than the global deletion delay. Values below 5 minutes or above the global delay are rejected with an invalid-expires-in validation error, resolved before any profile is created. Closes #11573 AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 06:52:46 +00:00
4 changed files with 124 additions and 26 deletions

No files matched your search

+56 -25
View File
@@ -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
@@ -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)))))
+7 -1
View File
@@ -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
+14
View File
@@ -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))))))