penpot/backend/test/backend_tests/rpc_font_test.clj
Andrey Antukh ad7e035b63
Add indirection for upload-chunks storage via new table (#11651)
*  Add upload_session_chunk indirection for chunked uploads

Chunks now live in the upload_session_chunk table with non-deleting
foreign keys to storage_object and upload_session, instead of tempfile
objects with session metadata. Reads go through a JOIN, so chunk state
never scans storage_object.

Uploads validate the live session, reject duplicate indexes, and store
objects in the new upload-session bucket without extra metadata.
Assemble removes mappings and marks the session consumed; objects-gc
procedurally purges consumed and stalled sessions, touching referenced
objects first. Touched-gc and deleted-gc handle the new bucket, and
upload-session-gc is removed.

Closes #11644

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix quota, give-up and coverage for session chunks

Exclude consumed sessions from the sessions-per-profile quota so
finished uploads free their slot at once. Remove chunk mappings before
the gc-deleted give-up delete to respect the NO ACTION keys. Catch
java.sql.SQLException for duplicate chunks. Cover the profile-owned
session purge and the UNIQUE race backstop with tests.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Align chunked upload tests with upload_session_chunk

Drop the duplicate-index tests written against metadata-backed
chunks; the UNIQUE mapping makes those cases unrepresentable and
the new tests cover them. Rewrite the rejected-duplicate tests to
expect :validation/:chunk-already-exists and assert against the
upload_session_chunk table, and scope the chunk-too-large "nothing
stored" check to the mapping table.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Use NO ACTION DEFERRABLE session FKs in single migration

Fold the profile FK change into 0154 so the feature ships one
migration. All three upload session FKs use ON DELETE NO ACTION
DEFERRABLE: identical to RESTRICT in normal operation, but deferrable
for tooling that relies on SET CONSTRAINTS ALL DEFERRED. Extend the
RESTRICT test to the direct profile delete.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Reserve chunk slot before writing blob in upload-chunk

Make object_id nullable and insert the mapping with NULL inside the
session-locking transaction, then write the blob outside it and link
it with a conditional update. A failed write removes the mapping and
reraises; a mid-flight death leaves a NULL row and the client starts
a new session.

AI-assisted-by: muse-spark-1.3-contributor

* 🔥 Remove redundant session_id index on upload_session_chunk

The UNIQUE(session_id, chunk_index) btree already serves
session_id-only lookups and the session FK check through its
leftmost column, so the standalone index only taxed the
per-chunk INSERT path. Verified with EXPLAIN on an equivalent
table shape.

AI-assisted-by: muse-spark-1.3-contributor

*  Merge chunk touch and delete into single RETURNING query

Replace the SELECT-then-DELETE round-trip in
delete-upload-sessions! with DELETE ... RETURNING object_id,
touching each returned object. Same semantics, one less query
per purged session. Follows the RETURNING pattern already used
in file-gc.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Let objects-gc own chunk mapping deletion

Assemble-chunks now only marks the session as consumed; the
chunk mappings stay until objects-gc purges them (touching the
chunk objects first), leaving a single procedural deletion
path for consumed, stalled and profile-purge sessions.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix font-deletion GC expectations for chunk objects

Update final storage-gc-touched counts to include the two
chunk objects touched by objects-gc when purging consumed
upload sessions (8/5/5 instead of 6/3/3).

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Release chunk reservation when the link UPDATE fails

Review feedback on #11651: the link UPDATE in upload-chunk could
leave a NULL reservation behind, blocking retries of the same
index with :chunk-already-exists. Remove the reservation when
the link fails so the client can retry in the same session;
the orphaned blob stays touched for touched-gc. Also realign
the process-bucket! cond branches in gc-touched.

Tests: chunked-upload-link-failure-releases-slot and
chunked-upload-null-reservation-blocks-retry.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-15 17:26:43 +02:00

688 lines
31 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-font-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]
[datoteka.io :as io]
[mockery.core :refer [with-mocks]])
(:import
java.io.RandomAccessFile))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
;; -----------------------------------------------------------------------
;; Helpers for chunked-upload font tests
;; -----------------------------------------------------------------------
(defn- split-bytes-into-chunks
"Splits `data` (byte array) into chunks of at most `chunk-size` bytes.
Returns a vector of byte arrays."
[^bytes data chunk-size]
(let [length (alength data)]
(loop [offset 0 chunks []]
(if (>= offset length)
chunks
(let [remaining (- length offset)
size (min chunk-size remaining)
buf (byte-array size)]
(System/arraycopy data offset buf 0 size)
(recur (+ offset size) (conj chunks buf)))))))
(defn- make-chunk-mfile
"Writes `data` (byte array) to a tempfile and returns a map
compatible with the upload-chunk :content parameter."
[^bytes data mtype]
(let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")]
(io/write* tmp data)
{:filename "chunk"
:path tmp
:mtype mtype
:size (alength data)}))
(defn- create-upload-session!
"Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID."
[prof total-chunks]
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks total-chunks})]
(let [session-id (:session-id (:result out))]
(t/is (nil? (:error out))
(str "create-upload-session failed: "
(some-> (:error out) ex-data)))
(t/is (uuid? session-id)
(str "create-upload-session returned an invalid session-id: " session-id))
session-id)))
(defn- upload-font-chunked!
"Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload
session, uploads all chunks, and returns the session-id UUID."
[prof ^bytes font-bytes mtype chunk-size]
(let [chunks (split-bytes-into-chunks font-bytes chunk-size)
session-id (create-upload-session! prof (count chunks))]
(when (uuid? session-id)
(doseq [[idx chunk-data] (map-indexed vector chunks)]
(let [mfile (make-chunk-mfile chunk-data mtype)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out))))))
session-id))
(defn- assert-font-variant-result
"Checks that a successful create-font-variant result has valid UUIDs and
the expected scalar fields matching `params`."
[params result]
(t/is (uuid? (:id result)))
(t/is (uuid? (:ttf-file-id result)))
(t/is (uuid? (:otf-file-id result)))
(t/is (uuid? (:woff1-file-id result)))
(t/are [k] (= (get params k) (get result k))
:team-id
:font-id
:font-family
:font-weight
:font-style))
(t/deftest font-deletion-1
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
(io/read*))]
;; Create font variant
(let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 500
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
::rpc/profile-id (:id prof)
:team-id team-id
:id font-id}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; processed = 4: the 2 font variants plus the 2 consumed upload sessions
(t/is (= 4 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
;; deleted = 8: the 6 font objects plus the 2 chunk objects touched
;; by objects-gc when purging the consumed sessions
(t/is (= 8 (:delete res)))))))
(t/deftest font-deletion-2
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff")
(io/read*))]
;; Create font variant
(let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id (uuid/custom 10 2)
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font
::rpc/profile-id (:id prof)
:team-id team-id
:id font-id}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; processed = 3: the font plus the 2 consumed upload sessions
(t/is (= 3 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
;; deleted = 5: the 3 font objects plus the 2 chunk objects touched
;; by objects-gc when purging the consumed sessions
(t/is (= 5 (:delete res)))))))
(t/deftest font-deletion-3
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*))
sid1 (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024))
sid2 (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024))
params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 400 :font-style "normal" :uploads {"font/woff" sid1}}
params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id :font-family "somefont"
:font-weight 500 :font-style "normal" :uploads {"font/woff" sid2}}
out1 (th/command! params1)
out2 (th/command! params2)]
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
;; freeze with hours 3 clock
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 6 (:freeze res))))
(let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof)
:team-id team-id :id (-> out1 :result :id)}
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; no-op with hours 3 clock (nothing touched yet)
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res))))
;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; processed = 3: the font variant plus the 2 consumed upload sessions
(t/is (= 3 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
;; deleted = 5: the 3 font objects plus the 2 chunk objects touched
;; by objects-gc when purging the consumed sessions
(t/is (= 5 (:delete res)))))))
(t/deftest input-sanitization-1
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
font-id (uuid/custom 10 1)
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))
session-id (upload-font-chunked! prof ttfdata "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "somefont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))))
;; -----------------------------------------------------------------------
;; Chunked upload (:uploads map)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-chunked-upload-ttf
"Upload a TTF via the new :uploads path (chunked-upload API)."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 30)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "new-chunked"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
;; quotes/check! is called at least once (for the font-variant quota) plus
;; once during session creation — assert it fired at least once.
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-chunked-upload-otf
"Upload an OTF via the new :uploads path."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 31)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/otf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "new-chunked-otf"
:font-weight 400
:font-style "normal"
:uploads {"font/otf" session-id}}
out (th/command! params)]
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-chunked-upload-woff
"Upload a WOFF via the new :uploads path."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 32)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/woff" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "new-chunked-woff"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-chunked-upload-multi-chunk
"Upload a WOFF split into many small chunks to exercise multi-chunk assembly."
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 33)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
;; Use a chunk-size smaller than 4 MiB to force multiple chunks while
;; staying within the 20-chunk-per-session quota limit (29836 / 2000 = ~15 chunks).
session-id (upload-font-chunked! prof font-bytes "font/woff" 2000)
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "multi-chunk-woff"
:font-weight 400
:font-style "normal"
:uploads {"font/woff" session-id}}
out (th/command! params)]
(t/is (>= (:call-count @mock) 1))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Error cases
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-missing-uploads
"Missing :uploads — schema validation must reject it."
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 40)
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "bad"
:font-weight 400
:font-style "normal"}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))))
(t/deftest create-font-variant-chunked-upload-missing-chunks
"When only some chunks are uploaded the assembly step must fail."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 41)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
;; 5000-byte chunks → 68640/5000 = 14 chunks; declare 15 but only upload 13
chunks (split-bytes-into-chunks font-bytes 5000)
;; Declare one extra chunk so assembly will fail (not all chunks present)
session-id (create-upload-session! prof (inc (count chunks)))]
;; Upload all real chunks except the last one (omit it so the session is incomplete)
(doseq [[idx chunk-data] (map-indexed vector (butlast chunks))]
(let [mfile (make-chunk-mfile chunk-data "font/ttf")
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out)))))
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "missing-chunks"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))))))
(t/deftest create-font-variant-chunked-upload-invalid-session
"Passing a non-existent session-id must fail at assembly time."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 42)
out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "bad-session"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" (uuid/next)}})]
(t/is (some? (:error out))))))
;; -----------------------------------------------------------------------
;; Font size validation tests
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-size-exceeded-chunked-upload
"New :uploads path exceeding font-max-file-size must be rejected after assembly."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 52)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded-chunked"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))))
;; -----------------------------------------------------------------------
;; Font media-type validation
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-invalid-type-chunked-upload
"New :uploads path with a disallowed mtype must be rejected after assembly."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 62)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
;; Upload the bytes under a valid session but lie about the mtype
;; when calling create-font-variant.
session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))
out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type-chunked"
:font-weight 400
:font-style "normal"
:uploads {"image/jpeg" session-id}})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
;; --- Font family name validation / XSS prevention
(t/deftest create-font-variant-with-invalid-family
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 100)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
;; name with < should fail
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil<script>alert(1)</script>"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-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)))
;; name with ' should fail
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil'name"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; name with } should fail
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil}name"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; valid name should succeed
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id (uuid/custom 10 101)
:font-family "Source Sans Pro"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (th/success? out))))))
(t/deftest update-font-with-invalid-family
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 102)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
;; Create a valid font first
(let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "ValidFont"
:font-weight 400 :font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (th/success? out)))
;; rename with < should fail
(let [params {::th/type :update-font
::rpc/profile-id (:id prof)
:team-id team-id :id font-id
:name "evil<script>x</script>"}
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)))
;; rename with ' should fail
(let [params {::th/type :update-font
::rpc/profile-id (:id prof)
:team-id team-id :id font-id
:name "evil'name"}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; valid rename should succeed
(let [params {::th/type :update-font
::rpc/profile-id (:id prof)
:team-id team-id :id font-id
:name "Valid Font Name"}
out (th/command! params)]
(t/is (th/success? out))))))
(t/deftest create-font-variant-rejects-foreign-font-id
;; N2-07: A user with edit permissions on their own team must not be
;; able to create a font variant using a font-id that already belongs
;; to another team (BOLA / CWE-639).
(let [prof1 (th/create-profile* 1 {:is-active true})
prof2 (th/create-profile* 2 {:is-active true})
team1 (:default-team-id prof1)
team2 (:default-team-id prof2)
font-id (uuid/custom 10 999)
data (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))]
;; prof1 creates a font variant in team1 with font-id
(let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof1)
:team-id team1
:font-id font-id
:font-family "SharedFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (nil? (:error out))))
;; prof2 tries to create a variant using the same font-id but
;; in team2, which must be rejected because font-id belongs to team1
(let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id team2
:font-id font-id
:font-family "SharedFont"
:font-weight 700
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest get-font-variants-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id prof)
:file-id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-font-variants-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id other)
:file-id (:id file)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))