mirror of
https://github.com/penpot/penpot.git
synced 2026-11-12 13:00:37 +00:00
* ✨ Strictly validate uploads against file bytes on disk Sniff the real content type of every upload with simplemagic magic bytes instead of trusting the client-supplied mtype header. Declared types outside the allowlist keep the not-allowed code; allowed declarations whose bytes differ are rejected as mismatch. Covers SVG by probing its root tag over leading bytes (magic never matches XML text), TTF/OTF by magic name and WOFF/WOFF2 by four-cc. Read the real file size from disk instead of trusting the declared :size. Files over the limit are rejected even when the declared size lies downwards, and small files pass even when it lies upwards. Run the path check first everywhere so forged server paths keep the params-validation code. Recheck from-url downloads after the write, with a declared-only fast fail at header time. Thread the assembled path through font validation. Refresh the affected tests around real and sparse fixture files, including a PNG-as-font chunked-upload rejection case. Closes #12796 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Accept BOM-prefixed SVG uploads and cover WOFF/WOFF2/OTF/WEBP Address review comments on #12801: - SVG files saved with a UTF-8 BOM were rejected as :media-type-mismatch: read-head kept U+FEFF and the <svg probe never ran. A leading BOM is now stripped before the probe, and a generic XML sniff (application/xml, text/xml) is treated as "no magic evidence" for declared image/svg+xml, so the root-tag probe decides whether the file opens with an <svg root. - sniff-match?/svg-file? docstrings claimed the magic database never matches SVG; simplemagic 1.17 does match an SVG with an XML prolog as image/svg+xml. Reworded to match reality. - Added matching-pass tests for WOFF, WOFF2, OTF and WEBP (they hit the read-four-cc, magic-name and direct magic branches) and three BOM SVG regression cases. AI-assisted-by: inkling-2-small-free
708 lines
32 KiB
Clojure
708 lines
32 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/multipart-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 (= 8 (: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 (= 8 (: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 (= 8 (: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/multipart-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))))))
|
|
|
|
(t/deftest create-font-variant-png-bytes-chunked-upload
|
|
"PNG bytes uploaded in chunks but declared as font/ttf are 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 63)
|
|
png-bytes (-> (io/resource "backend_tests/test_files/sample.png") (io/read*))
|
|
session-id (upload-font-chunked! prof png-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 "png-as-font"
|
|
:font-weight 400
|
|
:font-style "normal"
|
|
:uploads {"font/ttf" session-id}})]
|
|
(t/is (some? (:error out)))
|
|
(t/is (= :validation (-> out :error ex-data :type)))
|
|
(t/is (= :media-type-mismatch (-> 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))))
|