💥 Remove client-provided id from creation RPC commands (#11784)

The seven creation commands no longer accept an optional client
id: create-file, create-project, create-team,
create-team-with-invitations, upload-file-media-object,
create-file-media-object-from-url and assemble-file-media-object.
The server always generates the identifier; a sent id is ignored.

Malli maps are open and the RPC layer never strips unknown params,
so the handlers that would still honor an id (create-file,
create-project) now drop it explicitly. Internal callers that pass
remapped ids (project duplicate, binfile import) keep working.

Closes #11783

AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
Andrey Antukh 2026-09-22 15:51:50 +02:00 committed by GitHub
parent 6498619f60
commit 2f679eaa0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 299 additions and 465 deletions

View File

@ -7,6 +7,7 @@
- RPC auth defaults to enabled. Public endpoints must set `::auth false` metadata explicitly. - RPC auth defaults to enabled. Public endpoints must set `::auth false` metadata explicitly.
- The wrapper stack does auth before params validation, then auditing/rate/concurrency/metrics/retry/condition handling, with DB transaction handling inside that stack. `::db/transaction` metadata controls transaction wrapping. - The wrapper stack does auth before params validation, then auditing/rate/concurrency/metrics/retry/condition handling, with DB transaction handling inside that stack. `::db/transaction` metadata controls transaction wrapping.
- Params with `::sm/params` are decoded/conformed through the JSON transformer and successful IObj results get `:encode/json` metadata. Legacy spec conforming only applies when no Malli params schema exists. Client params are stripped of qualified keys (`d/without-qualified`) before merging with the server auth context, so request bodies cannot override `::profile-id`, `::auth-type`, or `::token-perms`. - Params with `::sm/params` are decoded/conformed through the JSON transformer and successful IObj results get `:encode/json` metadata. Legacy spec conforming only applies when no Malli params schema exists. Client params are stripped of qualified keys (`d/without-qualified`) before merging with the server auth context, so request bodies cannot override `::profile-id`, `::auth-type`, or `::token-perms`.
- Params schemas are open by default, so undeclared client keys reach the handler unless the map is `:closed true`. Creation commands (`create-file`, `create-project`, `create-team`, `create-team-with-invitations`, `upload-file-media-object`, `create-file-media-object-from-url`, `assemble-file-media-object`) use closed schemas: a client-provided `:id` fails with `:params-validation`. Their internal creation functions still accept an optional explicit `:id` for imports, duplicates and deterministic test fixtures.
- Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set. - Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set.
## DB helpers ## DB helpers

View File

@ -72,15 +72,15 @@
(bfc/get-file cfg (:id file))))) (bfc/get-file cfg (:id file)))))
(def ^:private schema:create-file (def ^:private schema:create-file
[:map {:title "create-file"} [:map {:title "create-file" :closed true}
[:name [:string {:max 250}]] [:name [:string {:max 250}]]
[:project-id ::sm/uuid] [:project-id ::sm/uuid]
[:id {:optional true} ::sm/user-provided-uuid]
[:is-shared {:optional true} ::sm/boolean] [:is-shared {:optional true} ::sm/boolean]
[:features {:optional true} ::cfeat/features]]) [:features {:optional true} ::cfeat/features]])
(sv/defmethod ::create-file (sv/defmethod ::create-file
{::doc/added "1.17" {::doc/added "1.17"
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
::doc/module :files ::doc/module :files
::webhooks/event? true ::webhooks/event? true
::sm/params schema:create-file ::sm/params schema:create-file

View File

@ -47,8 +47,7 @@
WHERE f.id = ?") WHERE f.id = ?")
(def ^:private schema:upload-file-media-object (def ^:private schema:upload-file-media-object
[:map {:title "upload-file-media-object"} [:map {:title "upload-file-media-object" :closed true}
[:id {:optional true} ::sm/user-provided-uuid]
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:is-local ::sm/boolean] [:is-local ::sm/boolean]
[:name [:string {:max 250}]] [:name [:string {:max 250}]]
@ -56,6 +55,7 @@
(sv/defmethod ::upload-file-media-object (sv/defmethod ::upload-file-media-object
{::doc/added "1.17" {::doc/added "1.17"
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
::sm/params schema:upload-file-media-object ::sm/params schema:upload-file-media-object
::climit/id [[:process-image/by-profile ::rpc/profile-id] ::climit/id [[:process-image/by-profile ::rpc/profile-id]
[:process-image/global]]} [:process-image/global]]}
@ -221,15 +221,15 @@
(declare ^:private create-file-media-object-from-url) (declare ^:private create-file-media-object-from-url)
(def ^:private schema:create-file-media-object-from-url (def ^:private schema:create-file-media-object-from-url
[:map {:title "create-file-media-object-from-url"} [:map {:title "create-file-media-object-from-url" :closed true}
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:is-local ::sm/boolean] [:is-local ::sm/boolean]
[:url ::sm/uri] [:url ::sm/uri]
[:id {:optional true} ::sm/user-provided-uuid]
[:name {:optional true} [:string {:max 250}]]]) [:name {:optional true} [:string {:max 250}]]])
(sv/defmethod ::create-file-media-object-from-url (sv/defmethod ::create-file-media-object-from-url
{::doc/added "1.17" {::doc/added "1.17"
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
::sm/params schema:create-file-media-object-from-url} ::sm/params schema:create-file-media-object-from-url}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(files/check-edition-permissions! pool profile-id file-id) (files/check-edition-permissions! pool profile-id file-id)
@ -550,21 +550,21 @@
;; --- Chunked Upload: Assemble all chunks into a final media object ;; --- Chunked Upload: Assemble all chunks into a final media object
(def ^:private schema:assemble-file-media-object (def ^:private schema:assemble-file-media-object
[:map {:title "assemble-file-media-object"} [:map {:title "assemble-file-media-object" :closed true}
[:session-id ::sm/uuid] [:session-id ::sm/uuid]
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:is-local ::sm/boolean] [:is-local ::sm/boolean]
[:name [:string {:max 250}]] [:name [:string {:max 250}]]
[:mtype :string] [:mtype :string]])
[:id {:optional true} ::sm/user-provided-uuid]])
(sv/defmethod ::assemble-file-media-object (sv/defmethod ::assemble-file-media-object
{::doc/added "2.17" {::doc/added "2.17"
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
::sm/params schema:assemble-file-media-object ::sm/params schema:assemble-file-media-object
::climit/id [[:process-image/by-profile ::rpc/profile-id] ::climit/id [[:process-image/by-profile ::rpc/profile-id]
[:process-image/global]]} [:process-image/global]]}
[{:keys [::db/pool] :as cfg} [{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id session-id file-id is-local name mtype id] :as params}] {:keys [::rpc/profile-id session-id file-id is-local name mtype] :as params}]
(files/check-edition-permissions! pool profile-id file-id) (files/check-edition-permissions! pool profile-id file-id)
(db/tx-run! cfg (db/tx-run! cfg
@ -576,7 +576,6 @@
(media.v/validate-media-type!) (media.v/validate-media-type!)
(media.v/validate-media-size!)) (media.v/validate-media-size!))
mobj (create-file-media-object cfg (assoc params mobj (create-file-media-object cfg (assoc params
:id id
:from-chunks? true :from-chunks? true
:content content))] :content content))]

View File

@ -196,13 +196,13 @@
(assoc project :is-pinned false))) (assoc project :is-pinned false)))
(def ^:private schema:create-project (def ^:private schema:create-project
[:map {:title "create-project"} [:map {:title "create-project" :closed true}
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
[:name [:string {:max 250 :min 1}]] [:name [:string {:max 250 :min 1}]]])
[:id {:optional true} ::sm/user-provided-uuid]])
(sv/defmethod ::create-project (sv/defmethod ::create-project
{::doc/added "1.18" {::doc/added "1.18"
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
::webhooks/event? true ::webhooks/event? true
::sm/params schema:create-project} ::sm/params schema:create-project}
[cfg {:keys [::rpc/profile-id team-id] :as params}] [cfg {:keys [::rpc/profile-id team-id] :as params}]

View File

@ -520,15 +520,15 @@
(declare ^:private create-team-default-project) (declare ^:private create-team-default-project)
(def ^:private schema:create-team (def ^:private schema:create-team
[:map {:title "create-team"} [:map {:title "create-team" :closed true}
[:name types.team/schema:team-name] [:name types.team/schema:team-name]
[:features {:optional true} ::cfeat/features] [:features {:optional true} ::cfeat/features]
[:id {:optional true} ::sm/user-provided-uuid]
[:organization-id {:optional true} ::sm/uuid] [:organization-id {:optional true} ::sm/uuid]
[:is-default {:optional true} :boolean]]) [:is-default {:optional true} :boolean]])
(sv/defmethod ::create-team (sv/defmethod ::create-team
{::doc/added "1.17" {::doc/added "1.17"
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
::sm/params schema:create-team} ::sm/params schema:create-team}
[cfg {:keys [::rpc/profile-id organization-id] :as params}] [cfg {:keys [::rpc/profile-id organization-id] :as params}]
@ -651,7 +651,7 @@
(assoc team :default-project-id (:id project)))) (assoc team :default-project-id (:id project))))
(defn- create-team* (defn- create-team*
[conn {:keys [id name is-default features] :as params}] [conn {:keys [id name is-default features]}]
(let [id (or id (uuid/next)) (let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false) is-default (if (boolean? is-default) is-default false)
features (db/create-array conn "text" features) features (db/create-array conn "text" features)
@ -690,6 +690,9 @@
(defn create-project (defn create-project
[conn {:keys [id team-id name is-default created-at modified-at]}] [conn {:keys [id team-id name is-default created-at modified-at]}]
;; NOTE: the explicit id is kept for internal callers that duplicate or
;; import projects with a remapped id (see management.clj); the RPC
;; commands no longer accept a client-provided id.
(let [id (or id (uuid/next)) (let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false) is-default (if (boolean? is-default) is-default false)
name (d/normalize-string name) name (d/normalize-string name)

View File

@ -535,15 +535,15 @@
;; --- Mutation: Create Team & Invite Members ;; --- Mutation: Create Team & Invite Members
(def ^:private schema:create-team-with-invitations (def ^:private schema:create-team-with-invitations
[:map {:title "create-team-with-invitations"} [:map {:title "create-team-with-invitations" :closed true}
[:name [:string {:max 250}]] [:name [:string {:max 250}]]
[:features {:optional true} ::cfeat/features] [:features {:optional true} ::cfeat/features]
[:id {:optional true} ::sm/user-provided-uuid]
[:emails [::sm/set ::sm/email]] [:emails [::sm/set ::sm/email]]
[:role types.team/schema:role]]) [:role types.team/schema:role]])
(sv/defmethod ::create-team-with-invitations (sv/defmethod ::create-team-with-invitations
{::doc/added "1.17" {::doc/added "1.17"
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
::doc/module :teams ::doc/module :teams
::sm/params schema:create-team-with-invitations ::sm/params schema:create-team-with-invitations
::db/transaction true} ::db/transaction true}

View File

@ -46,25 +46,23 @@
(let [prof (th/create-profile* 1 {:is-active true}) (let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof) team-id (:default-team-id prof)
proj-id (:default-project-id prof) proj-id (:default-project-id prof)
file-id (uuid/next) page-id (uuid/next)
page-id (uuid/next)] data {::th/type :create-file
(t/testing "create file"
(let [data {::th/type :create-file
::rpc/profile-id (:id prof) ::rpc/profile-id (:id prof)
:project-id proj-id :project-id proj-id
:id file-id
:name "foobar" :name "foobar"
:is-shared false :is-shared false}
:components-v2 true} out (th/command! data)
out (th/command! data)] file-id (:id (:result out))]
(t/testing "create file"
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(let [result (:result out)] (let [result (:result out)]
(t/is (uuid? file-id))
(t/is (= (:name data) (:name result))) (t/is (= (:name data) (:name result)))
(t/is (= proj-id (:project-id result)))))) (t/is (= proj-id (:project-id result)))))
(t/testing "rename file" (t/testing "rename file"
(let [data {::th/type :rename-file (let [data {::th/type :rename-file
@ -141,58 +139,6 @@
(let [result (:result out)] (let [result (:result out)]
(t/is (= 0 (count result)))))))) (t/is (= 0 (count result))))))))
(t/deftest create-file-with-duplicate-id
(let [prof (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id prof)
file-id (uuid/next)]
(t/testing "create file with specific id"
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "first-file"}
out (th/command! data)]
(t/is (nil? (:error out)))))
(t/testing "create file with duplicate id returns normalized error"
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "duplicate-file"}
out (th/command! data)
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))))
(t/deftest create-file-id-version
(let [prof (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id prof)
v3-id "6fa459ea-ee8a-3ca4-894e-db77e160355e"
v4-id "550e8400-e29b-41d4-a716-446655440000"]
;; reserved version (v3) must be rejected at the RPC boundary
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id v3-id
:name "file with v3 id"}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; v4 id is accepted
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id v4-id
:name "file with v4 id"}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= v4-id (str (:id (:result out))))))))
(t/deftest file-gc-with-fragments (t/deftest file-gc-with-fragments
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile) file (th/create-file* 1 {:profile-id (:id profile)
@ -824,8 +770,7 @@
::rpc/profile-id (:id profile2) ::rpc/profile-id (:id profile2)
:project-id (:default-project-id profile1) :project-id (:default-project-id profile1)
:name "foobar" :name "foobar"
:is-shared false :is-shared false}
:components-v2 true}
out (th/command! data) out (th/command! data)
error (:error out)] error (:error out)]
@ -2118,25 +2063,22 @@
(let [prof (th/create-profile* 1 {:is-active true}) (let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof) team-id (:default-team-id prof)
proj-id (:default-project-id prof) proj-id (:default-project-id prof)
file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")] now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (ct/fixed-clock now)] (binding [ct/*clock* (ct/fixed-clock now)]
(let [data {::th/type :create-file (let [data {::th/type :create-file
::rpc/profile-id (:id prof) ::rpc/profile-id (:id prof)
:project-id proj-id :project-id proj-id
:id file-id
:name "foobar" :name "foobar"
:is-shared false :is-shared false}
:components-v2 true} out (th/command! data)
out (th/command! data)] _ (t/is (nil? (:error out)))
file-id (:id (:result out))]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)] (let [result (:result out)]
(t/is (= (:name data) (:name result))) (t/is (= (:name data) (:name result)))
(t/is (= proj-id (:project-id result))))) (t/is (= proj-id (:project-id result))))
(let [data {::th/type :delete-file (let [data {::th/type :delete-file
:id file-id :id file-id
@ -2174,31 +2116,28 @@
(t/is (= (:ids data) (val ev2))))) (t/is (= (:ids data) (val ev2)))))
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])] (let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (= (:deleted-at row) now))))))) (t/is (= (:deleted-at row) now))))))))
(t/deftest restore-deleted-files (t/deftest restore-deleted-files
(let [prof (th/create-profile* 1 {:is-active true}) (let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof) team-id (:default-team-id prof)
proj-id (:default-project-id prof) proj-id (:default-project-id prof)
file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")] now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (ct/fixed-clock now)] (binding [ct/*clock* (ct/fixed-clock now)]
(let [data {::th/type :create-file (let [data {::th/type :create-file
::rpc/profile-id (:id prof) ::rpc/profile-id (:id prof)
:project-id proj-id :project-id proj-id
:id file-id
:name "foobar" :name "foobar"
:is-shared false :is-shared false}
:components-v2 true} out (th/command! data)
out (th/command! data)] _ (t/is (nil? (:error out)))
file-id (:id (:result out))]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)] (let [result (:result out)]
(t/is (= (:name data) (:name result))) (t/is (= (:name data) (:name result)))
(t/is (= proj-id (:project-id result))))) (t/is (= proj-id (:project-id result))))
(let [data {::th/type :delete-file (let [data {::th/type :delete-file
:id file-id :id file-id
@ -2238,7 +2177,7 @@
(t/is (= (:ids data) (last (last events))))))) (t/is (= (:ids data) (last (last events)))))))
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])] (let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (nil? (:deleted-at row))))))) (t/is (nil? (:deleted-at row))))))))
(t/deftest restore-deleted-files-and-projets (t/deftest restore-deleted-files-and-projets
@ -2721,3 +2660,15 @@
(t/is (th/ex-info? err)) (t/is (th/ex-info? err))
(t/is (= :not-found (:type edata))) (t/is (= :not-found (:type edata)))
(t/is (= :object-not-found (:code edata))))))) (t/is (= :object-not-found (:code edata)))))))
(t/deftest create-file-rejects-client-id
(let [prof (th/create-profile* 1 {:is-active true})
sent-id (uuid/next)
out (th/command! {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id (:default-project-id prof)
:name "file with client id"
:id sent-id})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))

View File

@ -79,6 +79,7 @@
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result out)] (let [{:keys [media-id thumbnail-id] :as result} (:result out)]
(t/is (= (:id file) (:file-id result))) (t/is (= (:id file) (:file-id result)))
(t/is (uuid? (:id result)))
(t/is (= 800 (:width result))) (t/is (= 800 (:width result)))
(t/is (= 800 (:height result))) (t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result))) (t/is (= "image/jpeg" (:mtype result)))
@ -93,111 +94,6 @@
(t/is (= 3890 (:size mobj2))))))) (t/is (= 3890 (:size mobj2)))))))
(t/deftest media-object-upload-idempotency
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile
:id (uuid/next)}]
;; First try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))
;; Second try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))))
(t/deftest upload-file-media-object-id-version
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
v3-id "6fa459ea-ee8a-3ca4-894e-db77e160355e"
v4-id "550e8400-e29b-41d4-a716-446655440000"]
;; reserved version (v3) must be rejected at the RPC boundary
(let [params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile
:id v3-id}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; v4 id is accepted
(let [params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile
:id v4-id}
out (th/command! params)]
(t/is (th/success? out))
(t/is (= v4-id (str (:id (:result out))))))))
(t/deftest create-file-media-object-from-url-id-version
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
v3-id "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
;; reserved version (v3) must be rejected before any download happens
(let [params {::th/type :create-file-media-object-from-url
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:url "https://example.com/sample.jpg"
:id v3-id}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))))
(t/deftest media-object-from-url-command (t/deftest media-object-from-url-command
(let [prof (th/create-profile* 1) (let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof) proj (th/create-project* 1 {:profile-id (:id prof)
@ -254,6 +150,7 @@
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result out)] (let [{:keys [media-id thumbnail-id] :as result} (:result out)]
(t/is (= (:id file) (:file-id result))) (t/is (= (:id file) (:file-id result)))
(t/is (uuid? (:id result)))
(t/is (= 800 (:width result))) (t/is (= 800 (:width result)))
(t/is (= 800 (:height result))) (t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result))) (t/is (= "image/jpeg" (:mtype result)))
@ -268,51 +165,6 @@
(t/is (= 3890 (:size mobj2))))))) (t/is (= 3890 (:size mobj2)))))))
(t/deftest media-object-upload-idempotency-command
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile
:id (uuid/next)}]
;; First try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))
;; Second try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))))
(t/deftest media-object-upload-command-when-file-is-deleted (t/deftest media-object-upload-command-when-file-is-deleted
(let [prof (th/create-profile* 1) (let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof) proj (th/create-project* 1 {:profile-id (:id prof)
@ -580,7 +432,6 @@
file (th/create-file* 1 {:profile-id (:id prof) file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof) :project-id (:default-project-id prof)
:is-shared false}) :is-shared false})
media-id (uuid/next)
source-path (th/tempfile "backend_tests/test_files/sample.jpg") source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file
mtype "image/jpeg" mtype "image/jpeg"
@ -600,10 +451,9 @@
:file-id (:id file) :file-id (:id file)
:is-local true :is-local true
:name "sample" :name "sample"
:mtype mtype :mtype mtype})]
:id media-id})]
(t/is (nil? (:error out1))) (t/is (nil? (:error out1)))
(t/is (= media-id (:id (:result out1))))) (t/is (uuid? (:id (:result out1)))))
;; Second assemble with the same session-id must fail because the ;; Second assemble with the same session-id must fail because the
;; session row has been marked as consumed after the first assembly ;; session row has been marked as consumed after the first assembly
@ -613,8 +463,7 @@
:file-id (:id file) :file-id (:id file)
:is-local true :is-local true
:name "sample" :name "sample"
:mtype mtype :mtype mtype})]
:id media-id})]
(t/is (some? (:error out2))) (t/is (some? (:error out2)))
(t/is (= :not-found (-> out2 :error ex-data :type))) (t/is (= :not-found (-> out2 :error ex-data :type)))
(t/is (= :object-not-found (-> out2 :error ex-data :code)))))) (t/is (= :object-not-found (-> out2 :error ex-data :code))))))
@ -1323,47 +1172,57 @@
(t/is (= :not-found (:type error-data))) (t/is (= :not-found (:type error-data)))
(t/is (= :object-not-found (:code error-data)))))) (t/is (= :object-not-found (:code error-data))))))
(t/deftest assemble-file-media-object-id-version (t/deftest upload-file-media-object-rejects-client-id
(let [prof (th/create-profile* 1) (let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof) file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof) :project-id (:default-project-id prof)
:is-shared false}) :is-shared false})
v3-id "6fa459ea-ee8a-3ca4-894e-db77e160355e" mfile {:filename "sample.jpg"
v4-id "550e8400-e29b-41d4-a716-446655440000" :path (th/tempfile "backend_tests/test_files/sample.jpg")
mtype "image/jpeg"] :mtype "image/jpeg"
:size 312043}
sent-id (uuid/next)
out (th/command! {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile
:id sent-id})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
;; reserved version (v3) must be rejected without touching any session (t/deftest create-file-media-object-from-url-rejects-client-id
(let [out (th/command! {::th/type :assemble-file-media-object (let [prof (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
sent-id (uuid/next)
out (th/command! {::th/type :create-file-media-object-from-url
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:url "https://example.com/sample.jpg"
:id sent-id})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest assemble-file-media-object-rejects-client-id
(let [prof (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
sent-id (uuid/next)
out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof) ::rpc/profile-id (:id prof)
:session-id (uuid/next) :session-id (uuid/next)
:file-id (:id file) :file-id (:id file)
:is-local true :is-local true
:name "assembled-image" :name "assembled-image"
:mtype mtype :mtype "image/jpeg"
:id v3-id})] :id sent-id})]
(t/is (not (th/success? out))) (t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))) (t/is (th/ex-of-code? (:error out) :params-validation))))
;; v4 id is accepted through the full chunked flow
(let [source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
session-id (create-session! prof 1)
mfile (make-chunk-mfile (first chunks) mtype)
_ (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})
out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype mtype
:id v4-id})]
(t/is (th/success? out))
(t/is (= v4-id (str (:id (:result out))))))))

View File

@ -21,20 +21,19 @@
(t/deftest projects-simple-crud (t/deftest projects-simple-crud
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)}) team (th/create-team* 1 {:profile-id (:id profile)})
project-id (uuid/next)] data {::th/type :create-project
;; create project
(let [data {::th/type :create-project
::rpc/profile-id (:id profile) ::rpc/profile-id (:id profile)
:id project-id
:team-id (:id team) :team-id (:id team)
:name "test project"} :name "test project"}
out (th/command! data)] out (th/command! data)
;; (th/print-result! out) _ (t/is (nil? (:error out)))
project-id (:id (:result out))]
(t/is (nil? (:error out))) ;; create project
;; (th/print-result! out)
(t/is (uuid? project-id))
(let [result (:result out)] (let [result (:result out)]
(t/is (= (:name data) (:name result))))) (t/is (= (:name data) (:name result))))
;; query the list of projects of a team ;; query the list of projects of a team
(let [data {::th/type :get-projects (let [data {::th/type :get-projects
@ -123,33 +122,6 @@
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))) (t/is (th/ex-of-type? error :not-found))))
(t/deftest create-project-id-version
(let [profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
v3-id "6fa459ea-ee8a-3ca4-894e-db77e160355e"
v4-id "550e8400-e29b-41d4-a716-446655440000"]
;; reserved version (v3) must be rejected at the RPC boundary
(let [data {::th/type :create-project
::rpc/profile-id (:id profile)
:team-id (:id team)
:id v3-id
:name "project with v3 id"}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; v4 id is accepted
(let [data {::th/type :create-project
::rpc/profile-id (:id profile)
:team-id (:id team)
:id v4-id
:name "project with v4 id"}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= v4-id (str (:id (:result out))))))))
(t/deftest permissions-checks-rename-project (t/deftest permissions-checks-rename-project
(let [profile1 (th/create-profile* 1) (let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2) profile2 (th/create-profile* 2)
@ -289,3 +261,16 @@
err (:error out)] err (:error out)]
(t/is (th/ex-info? err)) (t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found)))) (t/is (th/ex-of-type? err :not-found))))
(t/deftest create-project-rejects-client-id
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile)})
sent-id (uuid/next)
out (th/command! {::th/type :create-project
::rpc/profile-id (:id profile)
:team-id (:id team)
:name "project with client id"
:id sent-id})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))

View File

@ -1116,62 +1116,6 @@
out (th/command! data)] out (th/command! data)]
(t/is (th/success? out))))) (t/is (th/success? out)))))
(t/deftest create-team-id-version
(let [profile (th/create-profile* 1 {:is-active true})
v3-id "6fa459ea-ee8a-3ca4-894e-db77e160355e"
v4-id "550e8400-e29b-41d4-a716-446655440000"
v7-id "0191062e-3f50-7a5e-9f5a-1a2b3c4d5e6f"]
;; reserved version (v3) must be rejected at the RPC boundary
(let [data {::th/type :create-team
::rpc/profile-id (:id profile)
:name "team with v3 id"
:id v3-id}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; v4, v7 and v8 ids are accepted
(doseq [id [v4-id v7-id (str (uuid/next))]]
(let [data {::th/type :create-team
::rpc/profile-id (:id profile)
:name (str "team with id " id)
:id id}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= id (str (:id (:result out)))))))))
(t/deftest create-team-with-invitations-id-version
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile (th/create-profile* 1 {:is-active true})
v3-id "6fa459ea-ee8a-3ca4-894e-db77e160355e"
v4-id "550e8400-e29b-41d4-a716-446655440000"]
;; reserved version (v3) must be rejected before any invitation is sent
(let [data {::th/type :create-team-with-invitations
::rpc/profile-id (:id profile)
:name "team with v3 id"
:id v3-id
:emails #{"invitee@example.com"}
:role :editor}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))
(t/is (= 0 (:call-count @mock))))
;; v4 id is accepted
(let [data {::th/type :create-team-with-invitations
::rpc/profile-id (:id profile)
:name "team with v4 id"
:id v4-id
:emails #{"invitee@example.com"}
:role :editor}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= v4-id (str (:id (:result out)))))))))
(t/deftest create-team-invitations-email-cooldown (t/deftest create-team-invitations-email-cooldown
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true}) (let [profile1 (th/create-profile* 1 {:is-active true})
@ -1482,3 +1426,27 @@
(t/is (not (th/success? out))) (t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :not-found)) (t/is (th/ex-of-type? (:error out) :not-found))
(t/is (th/ex-of-code? (:error out) :member-does-not-exist))))) (t/is (th/ex-of-code? (:error out) :member-does-not-exist)))))
(t/deftest create-team-rejects-client-id
(let [profile (th/create-profile* 1 {:is-active true})
sent-id (uuid/next)
out (th/command! {::th/type :create-team
::rpc/profile-id (:id profile)
:name "team with client id"
:id sent-id})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest create-team-with-invitations-rejects-client-id
(let [profile (th/create-profile* 1 {:is-active true})
sent-id (uuid/next)
out (th/command! {::th/type :create-team-with-invitations
::rpc/profile-id (:id profile)
:name "team with client id"
:emails ["invitee@example.com"]
:role :editor
:id sent-id})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))

View File

@ -53,10 +53,13 @@
(or (str/starts-with? url "http://") (or (str/starts-with? url "http://")
(str/starts-with? url "https://")))))) (str/starts-with? url "https://"))))))
(rx/mapcat (fn [item] (rx/mapcat (fn [item]
;; The upload commands use closed params schemas, so
;; drop the svg-only attrs (:href :width :height) that
;; `svg/collect-images` attaches.
(->> (rp/cmd! (if (contains? item :content) (->> (rp/cmd! (if (contains? item :content)
:upload-file-media-object :upload-file-media-object
:create-file-media-object-from-url) :create-file-media-object-from-url)
(dissoc item :href)) (dissoc item :href :width :height))
;; When the image uploaded fail we skip the shape ;; When the image uploaded fail we skip the shape
;; returning `nil` will afterward not create the shape. ;; returning `nil` will afterward not create the shape.
(rx/catch #(rx/of nil)) (rx/catch #(rx/of nil))

View File

@ -0,0 +1,63 @@
;; 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 frontend-tests.data.svg-upload-test
"Tests for the media upload triggered from an SVG import."
(:require
[app.common.transit :as tr]
[app.common.uuid :as uuid]
[app.main.data.workspace.svg-upload :as svg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.http :as http]))
(defn- image-node
[href]
{:tag :image
:attrs {:href href :width "10" :height "20"}
:content []})
(t/deftest upload-images-drops-svg-only-attrs
(t/testing "media upload params do not carry svg-only attrs"
(t/async done
(let [file-id (uuid/next)
bodies (atom [])
fetch-mock
(fn [url opts]
(swap! bodies conj {:cmd (http/url->cmd url)
:body (.-body opts)})
(js/Promise.resolve
(http/make-transit-response
{:id (uuid/next)
:file-id file-id
:name "pic"
:mtype "image/png"
:width 10
:height 20})))
orig (http/install-fetch-mock! fetch-mock)
svg-data {:tag :svg
:attrs {}
:content [(image-node "https://example.com/pic.png")]}]
(->> (svg/upload-images svg-data file-id)
(rx/subs!
(fn [_] nil)
(fn [err]
(http/restore-fetch! orig)
(t/is false (str "unexpected error: " (ex-message err)))
(done))
(fn []
(http/restore-fetch! orig)
(let [{:keys [cmd body]} (first @bodies)
params (tr/decode-str body)]
(t/is (= :create-file-media-object-from-url cmd))
(t/is (= "https://example.com/pic.png" (:url params)))
(t/is (not (contains? params :href)))
(t/is (not (contains? params :width)))
(t/is (not (contains? params :height))))
(done))))))))

View File

@ -16,6 +16,7 @@
[frontend-tests.data.profile-test] [frontend-tests.data.profile-test]
[frontend-tests.data.repo-test] [frontend-tests.data.repo-test]
[frontend-tests.data.store-test] [frontend-tests.data.store-test]
[frontend-tests.data.svg-upload-test]
[frontend-tests.data.uploads-test] [frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test] [frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-colors-test]
@ -134,6 +135,7 @@
'frontend-tests.data.repo-test 'frontend-tests.data.repo-test
'frontend-tests.data.store-test 'frontend-tests.data.store-test
'frontend-tests.data.exports-assets-test 'frontend-tests.data.exports-assets-test
'frontend-tests.data.svg-upload-test
'frontend-tests.data.uploads-test 'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test 'frontend-tests.data.viewer-test
'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-colors-test