;; 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 INC
(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)
(t/deftest ttf-font-upload-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*))
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"
:data {"font/ttf" ttfdata}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(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 ttf-font-upload-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)
data (-> (io/resource "backend_tests/test_files/font-1.woff")
(io/read*))
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"
:data {"font/woff" data}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(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 front variant
(let [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"
:data {"font/woff" data1}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [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"
:data {"font/woff" data2}}
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 {})]
(t/is (= 2 (: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)))
(t/is (= 6 (: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 front variant
(let [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"
:data {"font/woff" data1}}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out))))
(let [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"
:data {"font/woff" data2}}
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 {})]
(t/is (= 1 (: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)))
(t/is (= 3 (: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*))
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" :data {"font/woff" data1}}
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" :data {"font/woff" data2}}
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 {})]
(t/is (= 1 (: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)))
(t/is (= 3 (: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*))
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"
:data {"font/ttf" "/etc/passwd"}}
out (th/command! params)]
(t/is (= 0 (:call-count @mock)))
;; (th/print-result! out)
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))))))
;; -----------------------------------------------------------------------
;; 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})]
(t/is (nil? (:error out)))
(:session-id (:result out))))
(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))]
(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))
;; -----------------------------------------------------------------------
;; Path 1 – Normal (direct :data bytes)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-normal-ttf
(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 10)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-normal-otf
(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 11)
data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/otf" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-normal-woff
(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 12)
data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "chunked-test"
:font-weight 400
:font-style "normal"
:data {"font/woff" data}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Path 2 – Legacy chunking (:data with vector of byte-arrays per mtype)
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-legacy-chunked-ttf
"Upload a TTF via the legacy :data path where each mtype value is a
vector of byte-array chunks (4 MiB each) instead of a single byte-array."
(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 20)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
;; Simulate 4 MiB legacy chunks – font is small so a single chunk suffices
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "legacy-chunked"
:font-weight 700
:font-style "italic"
:data {"font/ttf" (vec chunks)}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
(t/deftest create-font-variant-legacy-chunked-woff
"Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks
to exercise the SequenceInputStream concatenation 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 21)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
;; Split into small chunks to exercise the SequenceInputStream path
chunks (split-bytes-into-chunks full-bytes 512)
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "legacy-chunked-woff"
:font-weight 400
:font-style "normal"
:data {"font/woff" (vec chunks)}}
out (th/command! params)]
(t/is (= 1 (:call-count @mock)))
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))
;; -----------------------------------------------------------------------
;; Path 3 – New standardized 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-data-and-uploads
"Neither :data nor :uploads is present — 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-normal
"Direct :data upload exceeding font-max-file-size must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 50)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded"
:font-weight 400
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(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)))))))
(t/deftest create-font-variant-size-exceeded-legacy-chunked
"Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected."
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
font-id (uuid/custom 10 51)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-exceeded-legacy"
:font-weight 400
:font-style "normal"
:data {"font/woff" (vec chunks)}}
out (th/command! params)]
(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)))))))
(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))))))))
(t/deftest create-font-variant-size-within-limit
"Upload exactly at the limit must succeed."
(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 53)
font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
font-size (alength ^bytes font-bytes)]
(with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)]
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "size-at-limit"
:font-weight 400
:font-style "normal"
:data {"font/ttf" font-bytes}}
out (th/command! params)]
(t/is (nil? (:error out)))
(assert-font-variant-result params (:result out)))))))
;; -----------------------------------------------------------------------
;; Font media-type validation tests
;; -----------------------------------------------------------------------
(t/deftest create-font-variant-invalid-type-normal
"Direct :data upload with a disallowed mtype must be rejected."
(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 60)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type"
:font-weight 400
:font-style "normal"
:data {"application/octet-stream" data}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
(t/deftest create-font-variant-invalid-type-legacy-chunked
"Legacy :data chunk-vector upload with a disallowed mtype must be rejected."
(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 61)
full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*))
chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
:font-id font-id
:font-family "invalid-type-legacy"
:font-weight 400
:font-style "normal"
:data {"image/png" (vec chunks)}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
(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 [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil"
:font-weight 400 :font-style "normal"
:data {"font/ttf" data}}
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 [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"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; name with } should fail
(let [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"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; valid name should succeed
(let [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"
:data {"font/ttf" data}}
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 [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"
:data {"font/ttf" data}}
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"}
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))))))