penpot/backend/test/backend_tests/http_assets_test.clj
Andrey Antukh d67a00c1d5
✨ Normalize storage metadata with a closed schema (#11987)
* ✨ Add Malli schema for storage metadata with dual decode

Phase 1 of the storage_object.metadata migration: reads accept both
Transit and plain JSON (sniffed by the marker) and always return the
normalized shape; writes validate against a closed per-bucket Malli
schema and still serialize as Transit unless the new
:storage-metadata-as-json config flag is set.

The 0155 migration normalizes existing rows inside Transit (reference
to bucket, default bucket, drop of chunk leftovers) and is
idempotent; large instances should fake it and run the batched
script instead.

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

* ♻️ Address review findings on storage metadata Phase 1

Collapse the dead :reference leg of the gc-touched bucket fallback
(the decode always sets :bucket on non-nil metadata, so it is only
reachable with a NULL column) and fix its comment.

Pin the write flag off in the transit-assuming metadata tests so the
suite proves the same with the flag set, and add coverage for the
flag rollback contract, JSON hash survival, NULL metadata in
gc-touched, and the 0155 normalization statements.

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

* ♻️ Backfill NULLs, canonical buckets, comment fix

Backfill NULL metadata columns in 0155 via coalesce (the key-missing
rule already matches them), derive valid-buckets from the Malli schema
dispatch entries so the list lives in one place, and correct the
lookup-bucket fallback comment to NULL columns.

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

* ♻️ Defer corrupt metadata rows in storage gc-touched

Decode touched rows individually so one non-map metadata value no
longer aborts the whole chunk: corrupt rows are logged and deferred
exactly one day in the same transaction, keeping their metadata
intact for a later repair, while healthy rows process normally.

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

* ♻️ Address storage metadata phase 1 review findings

Address the review findings on the storage metadata phase 1 branch:

- Fix put-and-delete-object: it stored the object with
  ::sto/expired-at, so the row was already deleted and del-object!
  returned false. Add delete-expired-object-returns-false to keep
  the expired-delete case covered.
- Cache the Malli decoder and encoder per process. Building them
  compiles the closed multi-dispatch schema, and decode-metadata
  runs on every read path (get-object, dedup probes, GC batches).
- Catch Exception instead of Throwable in try-decode-row so JVM
  Errors are not deferred as corrupt metadata.
- Add penpot_storage_gc_poison_total, emitted from
  storage-gc-touched; wire ::mtx/metrics into its handler.
- Anchor the encoding sniff to the start of the document so a
  plain JSON value that begins with a Transit-looking prefix is
  not read as Transit.
- Cover every bucket on both encodings, a JSON roundtrip through
  the jsonb column, nil metadata, the canonical bucket set and a
  poison-only GC chunk.
- Rename private check-metadata! to check-metadata.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Simplify the storage metadata schema to a single map

Replace the per-bucket :multi dispatch with a single closed map: the
bucket is validated with ::sm/one-of over metadata-buckets (now a plain
set) and the remaining keys are typed optional fields. Per-bucket
enforcement shrinks to a one-line :fn guard requiring :file-id and :id
for file-data, whose ids the GC reads to resolve references.

- Drop the dead (sm/register! ::metadata ...): nothing references the
  schema by keyword.
- Define tempfile-bucket and upload-session-bucket in the schema and
  alias them from app.storage, removing duplicated literals.
- Keep content-type required and the map closed, so an unknown bucket
  or key still fails fast on write.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Drop input coercion from encode-metadata

encode-metadata no longer runs the json-transformer decoder before
validation. On the write path its only effect was coercing string
UUIDs to UUID, and every producer already passes native UUIDs (the
RPC profile-id, uuid/random, or binfile ids decoded as ::sm/uuid).
Reads keep decoding, so stored Transit or JSON values still come
back as native types.

- Replace encode-accepts-string-uuids with
  encode-rejects-string-uuids, pinning the stricter contract.
- Pass native UUIDs in encode-writes-plain-json-with-flag.

AI-assisted-by: deepseek-v4.1-flash

* 📚 Document each statement in the storage metadata migration

Move the per-statement rules out of the header and add a comment to each
UPDATE explaining what it does: drop chunk leftovers, promote the legacy
"~:reference" to "~:bucket", drop residual "~:reference", and backfill the
default bucket. The header keeps the scope, the encoding note, the `->`
vs `?` note and the large-instance warning.

AI-assisted-by: deepseek-v4.1-flash

* 📚 Unwrap wrapped lines in the backend storage memory

One line per bullet or paragraph, as mem:memory-maintenance requires.
Only formatting; no content change.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Defer storage GC poison rows in their own transaction

process-chunk! no longer takes poison-ids; it only processes the healthy
chunk. The deferral moves to defer-poison! and process-touched! runs it in
its own transaction, separate from the freeze/delete work. The loop still
drains while there is chunk or poison, so a batch made only of poison rows
does not leave healthy rows behind the LIMIT 10 waiting for the next run.

Add a regression test: ten poison rows plus one healthy row with a later
touched_at are all handled in the same run.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Declare per-bucket metadata requirements in one map

Replace the file-data-specific predicate with bucket-requirements, a map
from bucket to the extra keys it must carry. metadata-buckets is derived
from its keys and a single generic :fn enforces presence, so a new bucket
and its contract are one entry. organization now requires
:organization-id; file-data keeps requiring :file-id and :id.

Update the http-assets test helper to set organization-id for its
organization objects.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Drop the ! suffix from storage GC helpers

Rename the internal helpers in app.storage.gc-touched (process-chunk,
defer-poison, mark-freeze-in-bulk, ...) to drop the trailing !.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Drop the ! suffix from storage GC deleted helpers

Rename the internal helpers in app.storage.gc-deleted (clean-deleted,
delete-sobjects, delete-give-up, ...) to drop the trailing !.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Fix dedup lookup for JSON-encoded storage metadata

get-database-object-by-hash only matched the Transit keys, so once the
:storage-metadata-as-json flag wrote plain JSON rows the dedup stopped
finding them and duplicated blobs. Match both encodings with a UNION ALL
of two indexable branches.

- Add migration 0156 with the plain-key dedup index; the legacy 0068
  index stays until Transit support is removed.
- Cover it with a JSON dedup test and a Transit -> JSON cross test.

AI-assisted-by: deepseek-v4.1-flash
2026-10-01 07:19:21 +02:00

1205 lines
60 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.http-assets-test
(:require
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token :as actoken]
[app.http.assets :as assets]
[app.http.session :as session]
[app.main :as main]
[app.metrics :as mtx]
[app.metrics.definition :as-alias mdef]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token :as access-token]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]
[integrant.core :as ig]
[mockery.core :refer [with-mocks]]
[yetti.response :as-alias yres])
(:import
io.prometheus.client.Counter
io.prometheus.client.Counter$Child))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each (th/serial
th/database-reset
th/clean-storage))
;; ----------------------------------------------------------------
;; Helpers
;; ----------------------------------------------------------------
(defn- configure-storage-backend
"Given storage map, returns a storage configured with the
appropriate backend for assets."
[storage]
(assoc storage ::sto/backend :fs))
(defn- create-storage-object!
"Create a storage object with the given bucket and content.
Optional opts map can include :profile-id to set the owner."
([storage bucket content]
(create-storage-object! storage bucket content {}))
([storage bucket content {:keys [profile-id]}]
(sto/put-object! storage (cond-> {::sto/content (sto/content content)
:bucket bucket
:content-type "text/plain"}
(some? profile-id)
(assoc :profile-id profile-id)
;; file-data objects require file/id
;; references for GC (has-file-data-refs?).
(= bucket "file-data")
(assoc :file-id (uuid/random)
:id (uuid/random))
;; organization objects require their owner id.
(= bucket "organization")
(assoc :organization-id (uuid/random))))))
(defn- make-metrics
[]
(ig/init-key :app.metrics/metrics
{:default (select-keys main/default-metrics
[:storage-asset-requests])}))
(defn- make-handler-cfg
"Build a minimal cfg map for the assets handlers. It carries a metrics
instance because the handlers require one."
[storage]
{::sto/storage storage
::mtx/metrics (make-metrics)
::assets/path "/assets"})
(defn- make-metrics-cfg
"Build a handler cfg map with an isolated metrics instance."
[storage metrics]
(assoc (make-handler-cfg storage) ::mtx/metrics metrics))
(defn- counter-value
[metrics labels]
(let [collector (mtx/get-collector metrics :storage-asset-requests)
instance (::mdef/instance collector)
child (.labels ^Counter instance (into-array String labels))]
(.get ^Counter$Child child)))
;; ----------------------------------------------------------------
;; Tests: get-id
;; ----------------------------------------------------------------
(t/deftest get-id-with-valid-uuid
(let [id (uuid/next)
request {:path-params {:id (str id)}}
result (assets/get-id request)]
(t/is (= id result))))
(t/deftest get-id-with-invalid-uuid
(let [request {:path-params {:id "not-a-uuid"}}]
(try
(assets/get-id request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
(t/deftest get-id-with-missing-id
(let [request {:path-params {}}]
(try
(assets/get-id request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — non-existent objects
;; ----------------------------------------------------------------
(t/deftest objects-handler-non-existent-object
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
request {:path-params {:id (str (uuid/next))}}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest objects-handler-invalid-uuid
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
request {:path-params {:id "not-a-uuid"}}]
(try
(assets/objects-handler cfg request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — public buckets (no auth required)
;; ----------------------------------------------------------------
(t/deftest objects-handler-public-bucket-no-auth
;; Objects in public buckets should be accessible without authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)]
(doseq [bucket ["file-media-object"
"file-object-thumbnail"
"team-font-variant"
"file-data-fragment"
"organization"]]
(t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "public data")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response))
(str "bucket " bucket " should not require auth"))
(t/is (not= 404 (::yres/status response))
(str "bucket " bucket " object should exist")))))))
(t/deftest objects-handler-organization-logo-no-auth
;; Organization logos are embedded in unauthenticated contexts, such as
;; the invitation email image shown to a not-yet-registered invitee, so
;; they must be servable without a session or access token.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "organization" "logo data")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
(t/deftest objects-handler-public-bucket-with-auth
;; Objects in public buckets should also be accessible WITH authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "file-media-object" "public data")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — non-public buckets (auth required)
;; ----------------------------------------------------------------
(t/deftest objects-handler-file-thumbnail-bucket-link-preview-flag
;; Objects in the file-thumbnail bucket are public only when the
;; link-preview flag is enabled.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "file-thumbnail" "thumbnail data")
request {:path-params {:id (str (:id object))}}]
(t/testing "flag enabled"
(with-redefs [cf/flags (conj cf/flags :link-preview)]
(let [response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response))))))
(t/testing "flag disabled"
(with-redefs [cf/flags (disj cf/flags :link-preview)]
(let [response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response))))))))
(t/deftest objects-handler-non-public-bucket-no-auth
;; Objects in non-public buckets should return 401 without authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response)))))
(t/deftest objects-handler-non-public-bucket-with-session-auth
;; Objects in non-public buckets should be accessible with session auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
(t/deftest objects-handler-non-public-bucket-with-access-token-auth
;; Objects in non-public buckets should be accessible with access token auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}
::actoken/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
(t/deftest objects-handler-non-public-bucket-with-both-auth
;; Objects should be accessible when both session and access token auth are present.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)
::actoken/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (not= 401 (::yres/status response)))
(t/is (not= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — all non-public buckets
;; ----------------------------------------------------------------
(t/deftest objects-handler-all-non-public-buckets-require-auth
;; Verify that all buckets NOT in the public set require authentication.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)]
;; NOTE: file-thumbnail is not included here because it is public
;; when the link-preview flag is enabled; see
;; objects-handler-file-thumbnail-bucket-link-preview-flag.
(doseq [bucket ["profile"
"tempfile"
"file-data"
"file-change"]]
(t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "some data")
request {:path-params {:id (str (:id object))}}]
;; Without auth → 401
(let [response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response))
(str "bucket " bucket " should require auth")))
;; With session auth → not 401
(let [response (assets/objects-handler cfg (assoc request ::session/profile-id (:id profile)))]
(t/is (not= 401 (::yres/status response))
(str "bucket " bucket " should be accessible with session auth")))
;; With access token auth → not 401
(let [response (assets/objects-handler cfg (assoc request ::actoken/profile-id (:id profile)))]
(t/is (not= 401 (::yres/status response))
(str "bucket " bucket " should be accessible with access token auth"))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — serve-object response (FS backend)
;; ----------------------------------------------------------------
(t/deftest objects-handler-fs-backend-serves-object
;; Verify that the FS backend returns the correct response structure.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (create-storage-object! storage "file-media-object" "file content")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
;; FS backend returns 204 with x-accel-redirect header
(t/is (= 204 (::yres/status response)))
(t/is (some? (get (::yres/headers response) "x-accel-redirect")))
(t/is (= "text/plain" (get (::yres/headers response) "content-type")))
(t/is (some? (get (::yres/headers response) "cache-control")))))
(t/deftest objects-handler-fs-backend-accel-redirect-path
;; Verify that x-accel-redirect contains the object's relative path.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "file-media-object" "file content")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)
redirect (get (::yres/headers response) "x-accel-redirect")]
;; The redirect path should contain the object's relative path
(t/is (string? redirect))
(t/is (clojure.string/includes? redirect (sto/object->relative-path object)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — content disposition
;; ----------------------------------------------------------------
(t/deftest objects-handler-non-public-bucket-served-as-attachment
;; A non-public bucket holds bytes the user uploaded and is reachable by
;; direct navigation, so the response marks it as an attachment.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)]
(doseq [bucket ["profile"
"tempfile"
"file-data"
"file-thumbnail"
"file-change"]]
(t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "some data")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (= "attachment" (get (::yres/headers response) "content-disposition"))
(str "bucket " bucket " should be served as an attachment")))))))
(t/deftest objects-handler-public-bucket-served-inline
;; Public buckets are embedded by the viewer and by outgoing mail, so they
;; keep being served without a disposition.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)]
(doseq [bucket ["file-media-object"
"file-object-thumbnail"
"team-font-variant"
"file-data-fragment"
"organization"]]
(t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "some data")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (nil? (get (::yres/headers response) "content-disposition"))
(str "bucket " bucket " should stay inline")))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — cache headers
;; ----------------------------------------------------------------
(t/deftest objects-handler-cache-control-header
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "file-media-object" "content")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)
cc (get (::yres/headers response) "cache-control")]
(t/is (string? cc))
(t/is (clojure.string/starts-with? cc "max-age="))))
;; ----------------------------------------------------------------
;; Tests: middleware integration — session auth end-to-end
;; ----------------------------------------------------------------
(t/deftest session-auth-integration
;; Test the full session auth flow: create session → assign token →
;; authenticate request → access protected asset.
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
;; Create a session and generate a token
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "test-agent"})
(#'session/assign-token cfg))
;; Create a storage object in a non-public bucket
storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
object (create-storage-object! storage "profile" "profile data")
;; Simulate what the middleware chain does:
;; 1. mw/auth extracts token from cookie and sets ::http/auth-data
;; 2. session/authz reads ::http/auth-data and sets ::session/profile-id
request {::http/auth-data {:type :cookie
:token (:token session)
:claims {:sid (:id session)
:uid (:id profile)}
:metadata {:ver 1}}
:path-params {:id (str (:id object))}}
;; Apply session/authz middleware
handler (#'session/wrap-authz
(fn [req]
;; This is where the actual handler would be called
;; We verify that ::session/profile-id is set
req)
{::session/manager manager})
result (handler request)]
;; Verify the session auth set the profile-id
(t/is (= (:id profile) (::session/profile-id result)))
(t/is (some? (::session/session result)))))
;; ----------------------------------------------------------------
;; Tests: middleware integration — access token auth end-to-end
;; ----------------------------------------------------------------
(t/deftest access-token-auth-integration
;; Test the full access token flow: create token → authenticate
;; request → access protected asset.
(let [profile (th/create-profile* 1)
;; Create an access token in the database
atoken (db/tx-run! th/*system*
access-token/create-access-token
(:id profile) "test-token" nil nil)
;; Create a storage object in a non-public bucket
storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
object (create-storage-object! storage "profile" "profile data")
;; Simulate what the middleware chain does:
;; 1. mw/auth extracts token from Authorization header and sets ::http/auth-data
;; 2. actoken/authz reads ::http/auth-data and sets ::actoken/profile-id
request {::http/auth-data {:type :token
:token (:token atoken)
:claims {:tid (:id atoken)}}
:path-params {:id (str (:id object))}}
;; Apply actoken/authz middleware
handler (#'actoken/wrap-authz
(fn [req]
;; Verify that ::actoken/profile-id is set
req)
th/*system*)
result (handler request)]
;; Verify the access token auth set the profile-id
(t/is (= (:id profile) (::actoken/profile-id result)))))
;; ----------------------------------------------------------------
;; Tests: middleware chain — combined session + access token
;; ----------------------------------------------------------------
(t/deftest combined-middleware-chain
;; Test that both session/authz and actoken/authz work together
;; in the middleware chain, matching the assets route configuration.
(let [cfg th/*system*
manager (session/inmemory-manager)
profile (th/create-profile* 1)
;; Create a session
session (->> (session/create-session manager {:profile-id (:id profile)
:user-agent "test-agent"})
(#'session/assign-token cfg))
;; Create an access token
atoken (db/tx-run! th/*system*
access-token/create-access-token
(:id profile) "test-token" nil nil)
;; Build the middleware chain like assets routes do:
;; session/authz → actoken/authz → handler
inner-handler (fn [request] request)
with-actoken (#'actoken/wrap-authz inner-handler th/*system*)
with-session (#'session/wrap-authz with-actoken {::session/manager manager})]
(t/testing "session cookie auth sets ::session/profile-id"
(let [request {::http/auth-data {:type :cookie
:token (:token session)
:claims {:sid (:id session)
:uid (:id profile)}
:metadata {:ver 1}}}
result (with-session request)]
(t/is (= (:id profile) (::session/profile-id result)))))
(t/testing "access token auth sets ::actoken/profile-id"
(let [request {::http/auth-data {:type :token
:token (:token atoken)
:claims {:tid (:id atoken)}}}
result (with-session request)]
(t/is (= (:id profile) (::actoken/profile-id result)))))
(t/testing "no auth sets neither profile-id"
(let [request {}
result (with-session request)]
(t/is (nil? (::session/profile-id result)))
(t/is (nil? (::actoken/profile-id result)))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — edge cases
;; ----------------------------------------------------------------
(t/deftest objects-handler-nil-profile-id-in-session
;; When session auth is present but profile-id is nil (e.g. invalid session),
;; non-public objects should still be denied.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "profile" "data")
request {:path-params {:id (str (:id object))}
::session/profile-id nil}
response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response)))))
(t/deftest objects-handler-nil-profile-id-in-access-token
;; When access token auth is present but profile-id is nil,
;; non-public objects should still be denied.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
object (create-storage-object! storage "profile" "data")
request {:path-params {:id (str (:id object))}
::actoken/profile-id nil}
response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response)))))
(t/deftest objects-handler-empty-request
;; A request with no path-params should raise a not-found error.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
request {}]
(try
(assets/objects-handler cfg request)
(t/is false "should have thrown")
(catch Exception e
(t/is (= :not-found (:type (ex-data e))))))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — expired objects
;; ----------------------------------------------------------------
;; ----------------------------------------------------------------
;; Tests: file-objects-handler — authz required (T2-N1-01)
;; ----------------------------------------------------------------
(t/deftest file-objects-handler-unauthenticated-returns-404
;; Unauthenticated requests to file-media assets must return 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-no-file-perms-returns-404
;; Authenticated user without file read permissions must get 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
stranger (th/create-profile* 2)
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id stranger)}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-with-file-perms-succeeds
;; Authenticated user with file read permissions must get the object
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest file-thumbnails-handler-unauthenticated-returns-404
;; Unauthenticated requests to file-thumbnail assets must return 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}}
response (assets/file-thumbnails-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-thumbnails-handler-with-file-perms-succeeds
;; Authenticated user with file read permissions must get the thumbnail
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id thumb-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-thumbnails-handler cfg request)]
;; Falls back to media-id since no thumbnail-id, but still serves
(t/is (= 204 (::yres/status response)))))
(t/deftest file-objects-handler-non-existent-media-returns-404
;; Request for non-existent file-media-object returns 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
request {:path-params {:id (str (uuid/next))}
::session/profile-id (:id profile)}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-nil-profile-id-returns-404
;; When profile-id is nil (invalid session), must return 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id nil}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: file-objects-handler — share-link authz (issue #11338)
;; ----------------------------------------------------------------
(t/deftest file-objects-handler-anonymous-with-valid-share-id-succeeds
;; Anonymous request with a valid share-id matching the file must
;; succeed (share-link viewers are unauthenticated by definition).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
slink (:result (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "all"}))
request {:path-params {:id (str (:id media-obj))}
:query-params {:share-id (str (:id slink))}}
response (assets/file-objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest file-objects-handler-anonymous-with-share-id-for-other-file-returns-404
;; A share-id from file A must not grant access to assets of file B.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file-a (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
file-b (th/create-file* 2 {:profile-id (:id owner)
:project-id (:id project)})
media-a (create-storage-object! storage "file-media-object" "image A")
media-obj-a (th/create-file-media-object* {:file-id (:id file-a)
:media-id (:id media-a)})
media-b (create-storage-object! storage "file-media-object" "image B")
media-obj-b (th/create-file-media-object* {:file-id (:id file-b)
:media-id (:id media-b)})
slink (:result (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file-a)
:pages #{}
:who-comment "team"
:who-inspect "all"}))
request {:path-params {:id (str (:id media-obj-b))}
:query-params {:share-id (str (:id slink))}}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-anonymous-with-malformed-share-id-returns-404
;; Malformed share-id must not raise; it must short-circuit to 404.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
:query-params {:share-id "not-a-uuid"}}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-thumbnails-handler-anonymous-with-valid-share-id-succeeds
;; Thumbnail endpoint must also honor the share-id query param.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id thumb-storage)})
slink (:result (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "all"}))
request {:path-params {:id (str (:id media-obj))}
:query-params {:share-id (str (:id slink))}}
response (assets/file-thumbnails-handler cfg request)]
;; Falls back to media-id since no thumbnail-id, but still serves
(t/is (= 204 (::yres/status response)))))
(t/deftest objects-handler-expired-object
;; Expired objects should return 404 (get-object filters them out).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
object (sto/put-object! storage {::sto/content (sto/content "expired")
::sto/expired-at (ct/now)
:bucket "profile"
:content-type "text/plain"})
request {:path-params {:id (str (:id object))}
::session/profile-id (:id profile)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: objects-handler — tempfile bucket ownership (T9-F-10)
;; ----------------------------------------------------------------
(t/deftest objects-handler-tempfile-owner-can-access
;; Owner of a tempfile should be able to access it via session auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::session/profile-id (:id owner)}
response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest objects-handler-tempfile-non-owner-gets-404
;; Non-owner accessing a tempfile should get 404 (not 403, to avoid leaking existence).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
stranger (th/create-profile* 2)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::session/profile-id (:id stranger)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest objects-handler-tempfile-access-token-owner-can-access
;; Owner of a tempfile should be able to access it via access token auth.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::actoken/profile-id (:id owner)}
response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest objects-handler-tempfile-access-token-non-owner-gets-404
;; Non-owner accessing a tempfile via access token should get 404.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
stranger (th/create-profile* 2)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::actoken/profile-id (:id stranger)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest objects-handler-tempfile-no-stored-profile-id-serves
;; Legacy tempfile objects without stored profile-id should be accessible
;; to any authenticated user (backward compatibility).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
stranger (th/create-profile* 1)
object (create-storage-object! storage "tempfile" "legacy temp data")
request {:path-params {:id (str (:id object))}
::session/profile-id (:id stranger)}
response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
;; ----------------------------------------------------------------
;; Tests: asset request metrics
;; ----------------------------------------------------------------
(t/deftest objects-handler-emits-served-metric
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
object (create-storage-object! storage "file-media-object" "file content")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["by-id" "fs" "file-media-object" "served"])))))
(t/deftest objects-handler-emits-not-found-metric
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
request {:path-params {:id (str (uuid/next))}}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["by-id" "unknown" "unknown" "not-found"])))))
(t/deftest objects-handler-emits-unauthorized-metric
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
object (create-storage-object! storage "profile" "profile photo")
request {:path-params {:id (str (:id object))}}
response (assets/objects-handler cfg request)]
(t/is (= 401 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["by-id" "fs" "profile" "unauthorized"])))))
(t/deftest file-objects-handler-emits-route-metric
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["by-file-media-id" "fs" "file-media-object" "served"])))))
(t/deftest file-objects-handler-no-perms-emits-unauthorized-metric
;; Permission-denied file-media requests answer 404 but are counted as
;; unauthorized (no existence is leaked over HTTP).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
stranger (th/create-profile* 2)
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id stranger)}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["by-file-media-id" "unknown" "unknown" "unauthorized"])))
(t/is (= 0.0 (counter-value metrics ["by-file-media-id" "unknown" "unknown" "not-found"])))))
(t/deftest asset-requests-default-metrics-definition
(let [defs main/default-metrics]
(t/is (= "penpot_storage_asset_requests_total" (::mdef/name (:storage-asset-requests defs))))
(t/is (= ["route" "backend" "bucket" "result"] (::mdef/labels (:storage-asset-requests defs))))))
(t/deftest objects-handler-serve-failure-emits-error-and-rethrows
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
object (create-storage-object! storage "file-media-object" "file content")
request {:path-params {:id (str (:id object))}}]
(with-mocks [_mock {:target 'app.storage/object->relative-path
:throw (ex-info "boom" {})}]
(t/is (thrown? clojure.lang.ExceptionInfo
(assets/objects-handler cfg request))))
(t/is (= 1.0 (counter-value metrics ["by-id" "fs" "file-media-object" "error"])))
(t/is (= 0.0 (counter-value metrics ["by-id" "fs" "file-media-object" "served"])))))
(t/deftest objects-handler-s3-backend-emits-served-metric
;; The S3 path is exercised without a real object store: the row is
;; inserted directly and the presigned URL is mocked.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
id (uuid/next)]
(db/insert! th/*pool* :storage-object
{:id id
:size 9
:backend "s3"
:metadata (db/tjson {:bucket "file-media-object"
:content-type "text/plain"})
:status "valid"})
(with-mocks [_mock {:target 'app.storage/get-object-url
:return (fn [_ _ & _] (u/uri "https://example.invalid/object"))}]
(let [response (assets/objects-handler cfg {:path-params {:id (str id)}})]
(t/is (= 307 (::yres/status response)))))
(t/is (= 1.0 (counter-value metrics ["by-id" "s3" "file-media-object" "served"])))))
(t/deftest objects-handler-unknown-backend-raises-and-emits-error
;; A row with an unexpected backend fails explicitly instead of
;; returning nil to the router; the failure is counted and rethrown.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
id (uuid/next)]
(db/insert! th/*pool* :storage-object
{:id id
:size 9
:backend "bogus"
:metadata (db/tjson {:bucket "file-media-object"
:content-type "text/plain"})
:status "valid"})
(t/is (thrown? clojure.lang.ExceptionInfo
(assets/objects-handler cfg {:path-params {:id (str id)}})))
(t/is (= 1.0 (counter-value metrics ["by-id" "bogus" "file-media-object" "error"])))))
(t/deftest file-thumbnails-handler-emits-thumbnail-route-metric
;; Served through the real thumbnail-id path (not the media-id fallback).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})]
(th/db-update! :file-media-object
{:thumbnail-id (:id thumb-storage)}
{:id (:id media-obj)})
(let [request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-thumbnails-handler cfg request)]
(t/is (= 204 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["thumbnail" "fs" "file-object-thumbnail" "served"]))))))
(t/deftest file-thumbnails-handler-fallback-emits-thumbnail-route-metric
;; Served through the media-id fallback (no thumbnail-id set).
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-thumbnails-handler cfg request)]
(t/is (= 204 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["thumbnail" "fs" "file-media-object" "served"])))))
(t/deftest file-thumbnails-handler-missing-storage-emits-not-found-metric
;; Media row exists but the storage object is gone: 404 with unknown labels.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}]
(with-mocks [_mock {:target 'app.storage/get-object
:return (fn [_ _] nil)}]
(let [response (assets/file-thumbnails-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/is (= 1.0 (counter-value metrics ["thumbnail" "unknown" "unknown" "not-found"])))))
(t/deftest objects-handler-tempfile-mismatch-emits-unauthorized-metric
;; The response stays 404 to avoid leaking existence, but the metric
;; records the internal auth outcome.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
owner (th/create-profile* 1)
stranger (th/create-profile* 2)
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
request {:path-params {:id (str (:id object))}
::session/profile-id (:id stranger)}
response (assets/objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))
(t/is (= 1.0 (counter-value metrics ["by-id" "fs" "tempfile" "unauthorized"])))
(t/is (= 0.0 (counter-value metrics ["by-id" "fs" "tempfile" "not-found"])))))
(t/deftest handlers-require-metrics
;; Metrics is no longer optional: a handler wired without it must fail
;; loudly instead of silently dropping the measurement.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (dissoc (make-handler-cfg storage) ::mtx/metrics)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
object (create-storage-object! storage "file-media-object" "file content")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id object)})]
(t/is (thrown? Throwable
(assets/objects-handler
cfg {:path-params {:id (str (:id object))}})))
(t/is (thrown? Throwable
(assets/file-objects-handler
cfg {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)})))
(t/is (thrown? Throwable
(assets/file-thumbnails-handler
cfg {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)})))
(t/is (thrown? Throwable
(assets/objects-handler
cfg {:path-params {:id (str (uuid/next))}})))))
(t/deftest malformed-uuid-emits-nothing
;; get-id raises before any metric emission point is reached.
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
request {:path-params {:id "not-a-uuid"}}]
(t/is (thrown? clojure.lang.ExceptionInfo
(assets/objects-handler cfg request)))
(t/is (= 0.0 (counter-value metrics ["by-id" "fs" "file-media-object" "served"])))
(t/is (= 0.0 (counter-value metrics ["by-id" "unknown" "unknown" "not-found"])))
(t/is (= 0.0 (counter-value metrics ["by-id" "unknown" "unknown" "error"])))))
(t/deftest assets-handler-survives-metrics-failure
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
object (create-storage-object! storage "file-media-object" "file content")
request {:path-params {:id (str (:id object))}}]
(with-mocks [_mock {:target 'app.metrics/run-collector!
:throw (ex-info "boom" {})}]
(let [response (assets/objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))))
(t/deftest file-objects-handler-serve-failure-emits-error-and-rethrows
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
metrics (make-metrics)
cfg (make-metrics-cfg storage metrics)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}]
(with-mocks [_mock {:target 'app.storage/object->relative-path
:throw (ex-info "boom" {})}]
(t/is (thrown? clojure.lang.ExceptionInfo
(assets/file-objects-handler cfg request))))
(t/is (= 1.0 (counter-value metrics ["by-file-media-id" "fs" "file-media-object" "error"])))
(t/is (= 0.0 (counter-value metrics ["by-file-media-id" "fs" "file-media-object" "served"])))))
(t/deftest result-label-mapping
(t/are [status expected]
(= expected (#'app.http.assets/result-label status))
nil "error"
200 "served"
204 "served"
307 "served"
401 "unauthorized"
403 "unauthorized"
404 "not-found"
429 "error"
500 "error"))