Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-08-26 20:14:51 +02:00
commit 87c51090b1
25 changed files with 772 additions and 111 deletions

View File

@ -34,7 +34,8 @@ Before writing any test, read:
2. Module-specific testing memory for the affected module:
- `mem:common/testing` — CLJC unit tests
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E
- `mem:backend/core` — JVM clojure.test conventions
- `mem:backend/testing` — JVM clojure.test conventions
- `mem:exporter/testing` — exporter unit tests
## Key Rules

View File

@ -102,9 +102,5 @@ misleading linter/compiler output. See `mem:scripts/paren-repair`.
## Testing
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline.
* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
Backend test commands, coverage rules, and conventions: `mem:backend/testing`.
Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.

View File

@ -0,0 +1,11 @@
# Backend Testing
JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`.
- READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all JVM test runs.
- All CLI commands must be executed from the `backend/` subdirectory.
- Tests are invoked directly via `clojure -M:dev:test` (kaocha) — there is no pnpm wrapper. Kaocha auto-discovers test namespaces, so no runner registration is needed.
- Coverage: if code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
- Isolated run: `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace, or `clojure -M:dev:test --focus backend-tests.my-ns-test/my-test-var` for a specific test var.
- Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas.
- If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`.

View File

@ -13,7 +13,7 @@ and helpers, consult:
builders, production-path change helpers
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests,
live browser verification via nREPL
- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core`
- `mem:backend/testing` — JVM `clojure.test` under `backend/test/`
## When to Use

View File

@ -42,31 +42,52 @@
;; OIDC PROVIDER (GENERIC)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- raise-invalid-sso-config
"Raise a controlled validation error for OIDC provider configuration failures."
[& {:keys [hint cause] :as params}]
(throw (ex-info (or hint "invalid-sso-config")
(-> params
(dissoc :cause)
(assoc :type :validation
:code :invalid-sso-config))
cause)))
(defn- discover-oidc-config
[cfg {:keys [base-uri skip-ssrf-check?] :as provider}]
(let [uri (u/join base-uri ".well-known/openid-configuration")
rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
(let [uri (u/join base-uri ".well-known/openid-configuration")]
(try
(let [rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 (:status rsp))
(let [data (-> rsp :body json/decode)
token-uri (get data :token_endpoint)
auth-uri (get data :authorization_endpoint)
user-uri (get data :userinfo_endpoint)
jwks-uri (get data :jwks_uri)
logout-uri (get data :end_session_endpoint)]
(if (= 200 (:status rsp))
(let [data (-> rsp :body json/decode)
token-uri (get data :token_endpoint)
auth-uri (get data :authorization_endpoint)
user-uri (get data :userinfo_endpoint)
jwks-uri (get data :jwks_uri)
logout-uri (get data :end_session_endpoint)]
(-> provider
(assoc :token-uri token-uri)
(assoc :auth-uri auth-uri)
(assoc :user-uri user-uri)
(assoc :jwks-uri jwks-uri)
(assoc :logout-uri logout-uri)))
(-> provider
(assoc :token-uri token-uri)
(assoc :auth-uri auth-uri)
(assoc :user-uri user-uri)
(assoc :jwks-uri jwks-uri)
(assoc :logout-uri logout-uri)))
(ex/raise :type ::internal
:code :invalid-sso-config
:hint "unable to discover OIDC configuration"
:discover-uri uri
:response-status-code (:status rsp)))))
(raise-invalid-sso-config
:hint "unable to discover OIDC configuration"
:discover-uri uri
:response-status-code (:status rsp))))
(catch Throwable cause
;; Controlled raises above are ExceptionInfo and would otherwise be
;; re-wrapped by this catch, dropping fields like :response-status-code.
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
;; Wrap SSRF blocks, DNS failures, TLS errors, etc. — from the caller's
;; perspective these are all "bad/unreachable issuer URL".
(raise-invalid-sso-config
:hint "unable to discover OIDC configuration"
:discover-uri uri
:cause cause))))))
(def ^:private default-oidc-scopes
#{"openid" "profile" "email"})
@ -107,16 +128,29 @@
(defn- fetch-oidc-jwks
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 status)
(-> body json/decode :keys process-oidc-jwks)
(ex/raise :type ::internal
:code :unable-to-fetch-sso-jwks
:hint "unable to retrieve JWKs (unexpected response status code)"
:response-status-code status))))
(try
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 status)
(-> body json/decode :keys process-oidc-jwks)
(raise-invalid-sso-config
:hint "unable to retrieve JWKs (unexpected response status code)"
:jwks-uri jwks-uri
:response-status-code status)))
(catch Throwable cause
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unable to retrieve JWKs"
:jwks-uri jwks-uri
:cause cause)))))
(defn- populate-jwks
"Fetch and Add (if possible) JWK's to the OIDC provider"
"Fetch and add JWKs to the OIDC provider.
When `:strict-jwks?` is set (organization SSO), failures raise a controlled
validation error. Otherwise JWKS is best-effort: log and continue without keys
so global OIDC/GitLab providers can still initialize if JWKS is temporarily down."
[cfg provider]
(try
(if-let [jwks (when-let [jwks-uri (:jwks-uri provider)]
@ -124,20 +158,28 @@
(assoc provider :jwks jwks)
provider)
(catch Throwable cause
(l/warn :hint "unable to fetch JWKs for the OIDC provider"
:provider (str (:id provider))
:cause cause)
provider)))
(if (:strict-jwks? provider)
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unable to retrieve JWKs"
:provider (:id provider)
:cause cause))
(do
(l/warn :hint "unable to fetch JWKs for the OIDC provider"
:provider (str (:id provider))
:cause cause)
provider)))))
(defn- prepare-oidc-provider
[cfg params]
(when-not (and (string? (:base-uri params))
(string? (:client-id params))
(string? (:client-secret params)))
(ex/raise :type ::internal
:code :invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(raise-invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(try
(if (and (string? (:token-uri params))
@ -150,11 +192,13 @@
(with-meta provider {::discovered true})))
(catch Throwable cause
(ex/raise :type ::internal
:type :invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause))))
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause)))))
(defmethod ig/assert-key ::providers/generic
[_ params]
@ -322,10 +366,9 @@
[cfg params]
(when-not (and (string? (:client-id params))
(string? (:client-secret params)))
(ex/raise :type ::internal
:code :invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(raise-invalid-sso-config
:hint "missing params for provider initialization"
:provider (:id params)))
(try
(let [provider (populate-jwks cfg params)]
@ -336,11 +379,13 @@
:client-secret (d/obfuscate-string (:client-secret provider)))
provider)
(catch Throwable cause
(ex/raise :type ::internal
:type :invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause))))
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw cause)
(raise-invalid-sso-config
:hint "unexpected exception on configuring provider"
:provider (:id params)
:cause cause)))))
(defmethod ig/init-key ::providers/gitlab
[_ cfg]
@ -867,7 +912,10 @@
:base-uri (some-> (non-blank-uri issuer)
(str/rtrim "/")
(str "/"))
:scopes default-oidc-scopes}))
:scopes default-oidc-scopes
;; Organization SSO is configured by customers; discovery
;; and JWKS failures must surface as controlled errors.
:strict-jwks? true}))
(defn build-organization-sso-auth-redirect-uri
"Build the OIDC authorization redirect URI for an organization SSO config.
@ -877,16 +925,24 @@
issuer (organization-sso-discovery-uri sso)
dest-url (or dest-url (str (cf/get :public-uri)))]
(when-not issuer
(ex/raise :type :validation
:code :invalid-sso-config
:hint "missing issuer"))
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
state-token (tokens/generate cfg {:iss "oidc"
:dest-url dest-url
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})]
(build-auth-redirect-uri oidc-provider state-token))))
(raise-invalid-sso-config
:hint "missing issuer"
:organization-id organization-id))
(try
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
state-token (tokens/generate cfg {:iss "oidc"
:dest-url dest-url
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})]
(build-auth-redirect-uri oidc-provider state-token))
(catch Throwable cause
(if (and (ex/error? cause)
(= :invalid-sso-config (:code (ex-data cause))))
(throw (ex-info (ex-message cause)
(assoc (ex-data cause) :organization-id organization-id)
(ex-cause cause)))
(throw cause))))))
(def ^:private probe-auth-code "penpot-sso-config-probe")

View File

@ -194,6 +194,7 @@
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string]
[:auth-token-cookie-max-age {:optional true} ::ct/duration]

View File

@ -103,17 +103,32 @@
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
(defn- request-profile-id
"Extract the authenticated profile-id from the request."
[request]
(or (::session/profile-id request)
(::actoken/profile-id request)))
(defn- authenticated?
"Check if the request has an authenticated profile, either via session
or access token."
[request]
(or (some? (::session/profile-id request))
(some? (::actoken/profile-id request))))
(some? (request-profile-id request)))
(defn- tempfile-owner-match?
"Check if the request's profile-id matches the tempfile's stored owner.
Returns true if no profile-id was stored (legacy objects)."
[obj request]
(let [stored-profile-id (:profile-id (meta obj))
request-profile-id (request-profile-id request)]
(or (nil? stored-profile-id)
(= stored-profile-id request-profile-id))))
(defn objects-handler
"Handler that serves storage objects by id.
For non-public buckets (e.g. profile), requires authentication
via session cookie or access token."
via session cookie or access token.
For tempfile bucket, also requires ownership (profile-id match)."
[{:keys [::sto/storage] :as cfg} request]
(let [id (get-id request)
obj (sto/get-object storage id)]
@ -125,6 +140,10 @@
(not (authenticated? request)))
{::yres/status 401}
(and (= (-> obj meta :bucket) sto/tempfile-bucket)
(not (tempfile-owner-match? obj request)))
{::yres/status 404}
:else
(serve-object cfg obj))))

View File

@ -60,7 +60,7 @@
{::sto/content data
::sto/touched-at (ct/in-future {:minutes 60})
:content-type "application/zip"
:bucket "tempfile"})]
:bucket sto/tempfile-bucket})]
(-> (cf/get :public-uri)
(u/ensure-path-slash)

View File

@ -353,7 +353,7 @@
::sto/touched-at (ct/in-future {:minutes 30})
:profile-id profile-id
:content-type mtype
:bucket "tempfile"}]
:bucket sto/tempfile-bucket}]
(sto/put-object! storage content)))

View File

@ -40,6 +40,12 @@
(declare create-file-media-object)
(def ^:private sql:get-team-id-for-file
"SELECT p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?")
(def ^:private schema:upload-file-media-object
[:map {:title "upload-file-media-object"}
[:id {:optional true} ::sm/uuid]
@ -58,6 +64,12 @@
(media.v/validate-media-type! content)
(media.v/validate-media-size! content)
(let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))]
(quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/incr (:size content)}))
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; We get the minimal file for proper checking if
;; file is not already deleted
@ -367,7 +379,7 @@
::sto/deduplicate? false
::sto/touch true
:content-type (:mtype content)
:bucket "tempfile"
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index index}))

View File

@ -458,13 +458,14 @@
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
(if (empty? emails)
{:allows-anybody false :external-emails []}
(let [emails-array (db/create-array conn "text" (vec emails))
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
(let [emails-array (db/create-array conn "text" (vec emails))
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
external-emails (->> profiles
(remove #(contains? organization-member-ids (:id %)))
(map :email)
(vec))]
member-emails (->> profiles
(filter #(contains? organization-member-ids (:id %)))
(map :email)
(into #{}))
external-emails (into [] (remove member-emails emails))]
{:allows-anybody false :external-emails external-emails}))))))
(def ^:private schema:add-team-to-organization

View File

@ -603,7 +603,7 @@
::doc/module :teams
::sm/params schema:get-team-invitation-token}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
(teams/check-read-permissions! cfg profile-id team-id)
(teams/check-edition-permissions! cfg profile-id team-id)
(let [email (profile/clean-email email)
invit (-> (db/get pool :team-invitation
{:team-id team-id

View File

@ -43,7 +43,7 @@
::sto/touched-at (ct/in-future {:minutes 10})
:profile-id profile-id
:content-type (:mtype content)
:bucket "tempfile"}
:bucket sto/tempfile-bucket}
object (sto/put-object! storage content)]
{:id (:id object)
:uri (-> (cf/get :public-uri)

View File

@ -546,6 +546,76 @@
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:media-storage-bytes-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-media-storage-bytes-per-team-quote?
(sm/lazy-validator schema:media-storage-bytes-per-team))
(def ^:private sql:get-media-storage-bytes-per-team
"SELECT COALESCE(SUM(so.size), 0) AS total
FROM (
SELECT fmo.media_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION
SELECT fmo.thumbnail_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.thumbnail_id IS NOT NULL
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION
SELECT v.otf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.otf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.ttf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.ttf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.woff1_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff1_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.woff2_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff2_file_id IS NOT NULL
AND v.deleted_at IS NULL
) AS refs
JOIN storage_object AS so ON (so.id = refs.so_id)
WHERE so.deleted_at IS NULL")
(defmethod check-quote ::media-storage-bytes-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-media-storage-bytes-per-team
(* 20 1024 1024 1024)))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-media-storage-bytes-per-team
team-id team-id team-id team-id team-id team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: DEFAULT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -38,6 +38,10 @@
(def default-bucket
"file-media-object")
(def tempfile-bucket
"Bucket name for temporary file uploads (10-minute expiry)."
"tempfile")
(def valid-buckets
#{"file-media-object"
"team-font-variant"
@ -45,7 +49,7 @@
"file-thumbnail"
"profile"
"organization"
"tempfile"
tempfile-bucket
"file-data"
"file-data-fragment"
"file-change"})
@ -136,7 +140,7 @@
result (when (and (::deduplicate? params)
(:hash mdata)
(:bucket mdata)
(not= "tempfile" (:bucket mdata)))
(not= tempfile-bucket (:bucket mdata)))
(let [result (get-database-object-by-hash connectable backend
(:bucket mdata)
(:hash mdata))]

View File

@ -149,7 +149,7 @@
:status "delete"
:bucket bucket)
(recur to-freeze (conj to-delete id) (rest objects))))
(let [deletion-delay (if (= "tempfile" bucket)
(let [deletion-delay (if (= sto/tempfile-bucket bucket)
(ct/duration {:hours 2})
(cf/get-deletion-delay))]
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
@ -158,15 +158,16 @@
(defn- process-bucket!
[conn bucket objects]
(case bucket
"file-media-object" (process-objects! conn has-file-media-object-refs? bucket objects)
"team-font-variant" (process-objects! conn has-team-font-variant-refs? bucket objects)
"file-object-thumbnail" (process-objects! conn has-file-object-thumbnails-refs? bucket objects)
"file-thumbnail" (process-objects! conn has-file-thumbnails-refs? bucket objects)
"profile" (process-objects! conn has-profile-refs? bucket objects)
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
"tempfile" (process-objects! conn (constantly false) bucket objects)
"organization" (process-objects! conn (constantly false) bucket objects)
(cond
(= bucket "file-media-object") (process-objects! conn has-file-media-object-refs? bucket objects)
(= bucket "team-font-variant") (process-objects! conn has-team-font-variant-refs? bucket objects)
(= bucket "file-object-thumbnail") (process-objects! conn has-file-object-thumbnails-refs? bucket objects)
(= bucket "file-thumbnail") (process-objects! conn has-file-thumbnails-refs? bucket objects)
(= bucket "profile") (process-objects! conn has-profile-refs? bucket objects)
(= bucket "file-data") (process-objects! conn has-file-data-refs? bucket objects)
(= bucket sto/tempfile-bucket) (process-objects! conn (constantly false) sto/tempfile-bucket objects)
(= bucket "organization") (process-objects! conn (constantly false) bucket objects)
:else
(ex/raise :type :internal
:code :unexpected-unknown-reference
:hint (dm/fmt "unknown reference '%'" bucket))))

View File

@ -15,6 +15,7 @@
[app.setup :as-alias setup]
[app.tokens :as tokens]
[clojure.test :as t]
[cuerdas.core :as str]
[mockery.core :refer [with-mocks]]
[yetti.response :as-alias yres]))
@ -587,3 +588,138 @@
:issuer "https://idp.example.com"})
(t/is (not (true? (:skip-ssrf-check? @captured-params)))
"SSRF protection must be disabled for organization SSO")))))
(defn- ssl-handshake-failure
[]
(javax.net.ssl.SSLHandshakeException. "Remote host terminated the handshake"))
(t/deftest prepare-organization-sso-provider-raises-on-discovery-network-failure
(t/testing "SSL/network failures during OIDC discovery become controlled validation errors"
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://wrong-idp.example.com"})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))))))
(t/deftest prepare-organization-sso-provider-raises-on-discovery-non-200
(t/testing "non-200 OIDC discovery responses become controlled validation errors"
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 404 :body "not found"}}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"})
(catch Throwable t t))
data (ex-data e)]
(t/is (ex/error? e))
(t/is (= :validation (:type data)))
(t/is (= :invalid-sso-config (:code data)))
(t/is (= 404 (:response-status-code data)))
(t/is (= "unable to discover OIDC configuration" (ex-message e)))
(t/is (str/includes? (str (:discover-uri data)) "openid-configuration"))))))
(t/deftest prepare-organization-sso-provider-raises-on-ssrf-blocked-issuer
(t/testing "SSRF/DNS failures for the issuer URL become invalid-sso-config, not ssrf-blocked-target"
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [& _]
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "uri host could not be resolved"))}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://unresolvable.invalid"})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
(t/deftest prepare-organization-sso-provider-raises-on-jwks-network-failure
(t/testing "SSL/network failures while fetching JWKs become controlled validation errors"
(let [discovery-body (str "{\"authorization_endpoint\":\"https://idp.example.com/auth\","
"\"token_endpoint\":\"https://idp.example.com/token\","
"\"userinfo_endpoint\":\"https://idp.example.com/userinfo\","
"\"jwks_uri\":\"https://idp.example.com/jwks\"}")]
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [_cfg request & _]
(if (str/includes? (str (:uri request)) "openid-configuration")
{:status 200 :body discovery-body}
(throw (ssl-handshake-failure))))}]
(let [e (try
(#'oidc/prepare-organization-sso-provider
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e)))))))))
(t/deftest populate-jwks-strict-wraps-non-invalid-sso-config-errors
(t/testing "strict JWKS path wraps unrelated structured errors instead of rethrowing them"
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
:side-effect (fn [& _]
(ex/raise :type :validation
:code :ssrf-blocked-target
:hint "uri host could not be resolved"))}]
(let [e (try
(#'oidc/populate-jwks
{}
{:id "oidc"
:jwks-uri "https://idp.example.com/jwks"
:strict-jwks? true})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
(t/deftest populate-jwks-strict-rethrows-invalid-sso-config
(t/testing "strict JWKS path rethrows an already-controlled invalid-sso-config"
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
:side-effect (fn [& _]
(ex/raise :type :validation
:code :invalid-sso-config
:hint "unable to retrieve JWKs"
:jwks-uri "https://idp.example.com/jwks"))}]
(let [e (try
(#'oidc/populate-jwks
{}
{:id "oidc"
:jwks-uri "https://idp.example.com/jwks"
:strict-jwks? true})
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :invalid-sso-config (:code (ex-data e))))
(t/is (= "unable to retrieve JWKs" (ex-message e)))
(t/is (= "https://idp.example.com/jwks" (:jwks-uri (ex-data e))))))))
(t/deftest build-organization-sso-auth-redirect-uri-raises-on-unreachable-provider
(t/testing "check-nitrate-sso path surfaces a controlled error when the issuer is unreachable"
(with-mocks [http-mock {:target 'app.http.client/req
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
(let [e (try
(oidc/build-organization-sso-auth-redirect-uri
{}
{:client-id "test-client"
:client-secret "test-secret"
:issuer "https://wrong-idp.example.com"}
:dest-url "https://localhost:3449/#/dashboard"
:organization-id #uuid "00000000-0000-0000-0000-000000000001")
(catch Throwable t t))]
(t/is (ex/error? e))
(t/is (= :validation (:type (ex-data e))))
(t/is (= :invalid-sso-config (:code (ex-data e))))))))

View File

@ -37,11 +37,16 @@
(assoc storage ::sto/backend :fs))
(defn- create-storage-object!
"Create a storage object with the given bucket and content."
[storage bucket content]
(sto/put-object! storage {::sto/content (sto/content content)
:bucket bucket
:content-type "text/plain"}))
"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)))))
(defn- make-handler-cfg
"Build a minimal cfg map for the assets handlers."
@ -752,3 +757,70 @@
::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)))))

View File

@ -1168,12 +1168,68 @@
@set-team-params))
(let [emails (->> @sent (map :to) set)]
(t/is (= 2 (count @sent)))
(t/is (= #{"member302@example.com" "external301@example.com"} emails))
(t/is (= 1 (count @sent)))
(t/is (= #{"member302@example.com"} emails))
(doseq [email-params @sent]
(t/is (= organization-name (:organization-name email-params)))
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
(t/deftest add-team-to-organization-deletes-external-invitations-for-unregistered-users
(let [owner (th/create-profile* 305 {:is-active true
:fullname "Owner"
:email "owner305@example.com"})
member (th/create-profile* 306 {:is-active true
:fullname "Member"
:email "member306@example.com"})
team (th/create-team* 305 {:profile-id (:id owner)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id member)
:role :editor})
organization-id (uuid/random)
organization-summary {:id organization-id
:name "Test Org"
:owner-id (:id owner)
:teams []}
organization-perms {:owner-id (:id owner)
:permissions {:create-teams "any"
:move-teams "always"
:new-team-members "members"}}]
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to "unregistered@example.com"
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:org-id nil
:email-to "unregistered2@example.com"
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call (add-team-to-organization-nitrate-mock
{:organization-id organization-id
:organization-summary organization-summary
:organization-perms organization-perms
:owner-id (:id owner)
:team-id (:id team)
:sso-active? false})
teams/initialize-user-in-organization (fn [& _] nil)]
(let [out (th/command! {::th/type :add-team-to-organization
::rpc/profile-id (:id owner)
:team-id (:id team)
:organization-id organization-id})]
(t/is (th/success? out))))
(let [remaining (th/db-query :team-invitation {:team-id (:id team)})]
(t/is (empty? remaining) "Both external invitations should be deleted"))))
(t/deftest create-team-in-organization-passes-association-to-nitrate
(let [organization-id (uuid/random)
team {:id (uuid/random)

View File

@ -338,3 +338,137 @@
(check-ok! 4)
(check-ko! 5))))
(t/deftest media-storage-bytes-per-team-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 1000})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
team-id (:default-team-id profile-1)
data {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id (:id profile-1)
::quotes/team-id team-id
::quotes/incr 500}
check-ok! (fn [msg]
(quotes/check! th/*system* data)
(t/is (true? true) msg))
check-ko! (fn [msg]
(try
(quotes/check! th/*system* data)
(t/is false (str msg " — expected exception but none thrown"))
(catch Exception e
(let [ed (ex-data e)]
(t/is (= :restriction (:type ed)))
(t/is (= :max-quote-reached (:code ed)))
(t/is (= "media-storage-bytes-per-team" (:target ed)))))))]
;; Under default limit (1000) with incr=500 and no existing storage — ok
(check-ok! "first check under limit")
;; Insert a quote row for another profile on the same team — does not help
(th/db-insert! :usage-quote
{:profile-id (:id profile-2)
:target "media-storage-bytes-per-team"
:quote 100})
;; Insert a team+profile quote that is still too low
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-2)
:target "media-storage-bytes-per-team"
:quote 200})
;; Insert a team-level quote (no profile) that is still too low
(th/db-insert! :usage-quote
{:team-id team-id
:target "media-storage-bytes-per-team"
:quote 400})
;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked
(check-ko! "blocked by team-level quote")
;; Insert a team+profile quote that allows it
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-1)
:target "media-storage-bytes-per-team"
:quote 1000})
;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok
(check-ok! "allowed by team+profile quote"))))
(t/deftest media-storage-bytes-quote-deduped
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 1100})}]
(let [prof (th/create-profile* 1)
team-id (:default-team-id prof)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id team-id})
file1 (th/create-file* 1 {:profile-id (:id prof)
:project-id (:id proj)
:is-shared false})
file2 (th/create-file* 2 {:profile-id (:id prof)
:project-id (:id proj)
:is-shared false})
;; One physical storage object of 500 bytes
so-id (uuid/random)
_ (th/db-insert! :storage-object {:id so-id
:size 500
:backend "test"})
;; Two file_media_object rows pointing at the SAME storage object
;; (simulates the deduplication path: same content uploaded twice)
_ (th/create-file-media-object*
{:file-id (:id file1) :media-id so-id
:name "icon" :mtype "image/svg+xml"})
_ (th/create-file-media-object*
{:file-id (:id file2) :media-id so-id
:name "icon" :mtype "image/svg+xml"})
data {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id (:id prof)
::quotes/team-id team-id
::quotes/incr 200}]
;; Physical size is 500. With UNION (correct), total=500, 500+200=700 ≤ 1100 → ok.
;; With UNION ALL (buggy), total=1000, 1000+200=1200 > 1100 → rejected.
(quotes/check! th/*system* data)
(t/is (true? true) "deduped storage counted once, under quota"))))
(t/deftest media-upload-enforces-storage-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 100})}]
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:id proj)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
out (th/command! params)]
;; 312043 bytes > 100 byte limit → should be rejected
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "media-storage-bytes-per-team" (:target (ex-data error))))))))

View File

@ -357,6 +357,28 @@
(t/is (= (:id profile2) (:member-id claims))))))))
(t/deftest get-team-invitation-token-requires-edition-permissions
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
pool (:app.db/pool th/*system*)]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id profile2)
:role :viewer})
(db/insert! pool :team-invitation
{:team-id (:id team)
:email-to "victim@example.com"
:role "editor"
:valid-until (ct/in-future "48h")})
(let [data {::th/type :get-team-invitation-token
::rpc/profile-id (:id profile2)
:team-id (:id team)
:email "victim@example.com"}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :not-found (-> out :error ex-data :type))))))
(t/deftest accept-invitation-tokens
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})

View File

@ -389,6 +389,12 @@
:level :error
:timeout 3000})))
(= code :invalid-sso-config)
;; SSO error page needs :organization-id to retry
(if (:organization-id error)
(st/async-emit! (rt/assign-exception (assoc error :type :sso-error)))
(st/async-emit! (rt/assign-exception error)))
:else
(st/async-emit! (rt/assign-exception error))))

View File

@ -12,7 +12,8 @@
- exception->error-data pure transformer
- on-error re-entrancy guard prevents recursive invocations
- flash schedules async emit ntf/show is not emitted synchronously
- organization SSO recovery expired SSO sessions go back to the provider"
- organization SSO recovery expired SSO sessions go back to the provider
- invalid-sso-config handler requires :organization-id to promote to :sso-error"
(:require
[app.main.errors :as errors]
[app.main.repo :as rp]
@ -351,3 +352,47 @@
(t/is (nil? @assigned*))
(done'))))
done))))
;; ---------------------------------------------------------------------------
;; :validation / :invalid-sso-config
;;
;; The SSO error page needs an organization-id to retry meaningfully. Promote
;; to :sso-error only when that id is present; otherwise keep :validation so
;; we do not surface a broken SSO dialog for a future code path that omits it.
;; ---------------------------------------------------------------------------
(defn- capture-async-exception
"Invoke `ptk/handle-error` while capturing the error map passed to
`rt/assign-exception` via `st/async-emit!`.
`st/async-emit!` is variadic (`[& params]`); the mock must be too,
otherwise CLJS looks up `IFn$_invoke$arity$variadic` and throws."
[error]
(let [captured (atom nil)]
(with-redefs [st/async-emit! (fn [& events]
(reset! captured (first events)))
rt/assign-exception (fn [err] err)]
(ptk/handle-error error)
@captured)))
(t/deftest invalid-sso-config-with-organization-id-promotes-to-sso-error
(t/testing "invalid-sso-config with :organization-id is shown as :sso-error"
(let [org-id #uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assigned (capture-async-exception
{:type :validation
:code :invalid-sso-config
:organization-id org-id
:hint "missing issuer"})]
(t/is (= :sso-error (:type assigned)))
(t/is (= org-id (:organization-id assigned)))
(t/is (= :invalid-sso-config (:code assigned))))))
(t/deftest invalid-sso-config-without-organization-id-keeps-validation
(t/testing "invalid-sso-config without :organization-id must not become :sso-error"
(let [assigned (capture-async-exception
{:type :validation
:code :invalid-sso-config
:hint "missing issuer"})]
(t/is (= :validation (:type assigned)))
(t/is (nil? (:organization-id assigned)))
(t/is (= :invalid-sso-config (:code assigned))))))

View File

@ -45,24 +45,24 @@ declare -A TEST_CMD=(
declare -A FMT_CHECK_CMD=(
[frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss"
[backend]="pnpm run check-fmt"
[backend]="pnpm run check-fmt:clj"
[common]="pnpm run check-fmt:clj && pnpm run check-fmt:js"
[render-wasm]="cargo fmt --check"
[exporter]="pnpm run check-fmt:clj"
[mcp]="pnpm run fmt:check"
[plugins]="pnpm run format:check"
[library]="pnpm run check-fmt"
[library]="pnpm run check-fmt:clj"
)
declare -A FMT_FIX_CMD=(
[frontend]="pnpm run fmt:clj && pnpm run fmt:js && pnpm run fmt:scss"
[backend]="pnpm run fmt"
[backend]="pnpm run fmt:clj"
[common]="pnpm run fmt:clj && pnpm run fmt:js"
[render-wasm]="cargo fmt"
[exporter]="pnpm run fmt:clj"
[mcp]="pnpm run fmt"
[plugins]="pnpm run format"
[library]="pnpm run fmt"
[library]="pnpm run fmt:clj"
)
declare -A PAREN_REPAIR_CMD=(

View File

@ -473,8 +473,10 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
state
mergedAt
createdAt
headRefName
author { login }
labels(first: 20) { nodes { name } }
files(first: 100) { nodes { path } }
closingIssuesReferences(first: 5) { nodes { number } }
}
}
@ -494,8 +496,9 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]:
states: GraphQL states enum array literal, e.g. ``"[MERGED]"`` or ``"[OPEN CLOSED MERGED]"``
Returns:
List of {number, title, body, state, merged_at, created_at, author,
labels: [str], closing_issues: [int]}
List of {number, title, body, state, merged_at, created_at,
head_ref_name, author, labels: [str], files: [str],
closing_issues: [int]}
"""
query = GQL_MILESTONE_PRS_QUERY.replace("__STATES__", states)
all_nodes: list[dict] = []
@ -522,8 +525,10 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]:
"state": node["state"],
"merged_at": node.get("mergedAt"),
"created_at": node.get("createdAt"),
"head_ref_name": node.get("headRefName"),
"author": node["author"]["login"] if node["author"] else None,
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
"files": [file["path"] for file in node["files"]["nodes"]],
"closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]],
})
@ -602,7 +607,20 @@ def cmd_prs(args: argparse.Namespace) -> None:
def fetch_advisories() -> list[dict]:
"""Fetch all security advisories for the repository via REST API."""
return run_gh_rest(f"repos/{REPO}/security-advisories")
all_advisories: list[dict] = []
page = 1
while True:
advisories = run_gh_rest(
f"repos/{REPO}/security-advisories?per_page=100&page={page}"
)
all_advisories.extend(advisories)
if len(advisories) < 100:
break
page += 1
return all_advisories
def fetch_advisory(ghsa_id: str) -> dict: