mirror of
https://github.com/penpot/penpot.git
synced 2026-10-03 17:26:16 +00:00
* ✨ Enforce idle and absolute session expiration Sessions now expire on two server-side conditions: an idle window (PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE, default 7d) and an absolute cap from creation (PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE_ABSOLUTE, default 30d, enforced by the token :exp claim). The daily session-gc task deletes rows that exceed either window, so idle sessions can no longer be replayed and active sessions are not deleted at the idle window. Also remove the legacy v1 HTTP sessions: the http_session table and the string-id / :ver 0 token code paths are gone. Any v1 cookie now requires a fresh login. Document the session expiration configuration in the technical guide and add a backend memory describing the token, renewal and GC model. Closes #11646 AI-assisted-by: deepseek-v4.1-flash * 🐛 Address session-expiration review findings F1-F4 Fix the unreadable test (a stray paren broke whole-suite discovery), enforce idle expiration on every request in wrap-authz, fail boot fast when the absolute cap sits below the idle window, and align config defaults with the memory rule while fixing its migration number and stale reference. Closes #11646 AI-assisted-by: muse-spark-1.3-contributor
672 lines
33 KiB
Clojure
672 lines
33 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-middleware-test
|
|
(:require
|
|
[app.common.exceptions :as ex]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.http :as-alias http]
|
|
[app.http.access-token]
|
|
[app.http.errors :as http-errors]
|
|
[app.http.middleware :as mw]
|
|
[app.http.session :as session]
|
|
[app.main :as-alias main]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.commands.access-token]
|
|
[app.tokens :as tokens]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.string :as str]
|
|
[clojure.test :as t]
|
|
[integrant.core :as ig]
|
|
[mockery.core :refer [with-mocks]]
|
|
[yetti.request :as yreq]
|
|
[yetti.response :as yres])
|
|
(:import
|
|
io.undertow.server.RequestTooBigException))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(t/deftest auth-middleware-1
|
|
(let [request (volatile! nil)
|
|
handler (#'app.http.middleware/wrap-auth
|
|
(fn [req] (vreset! request req))
|
|
{})]
|
|
|
|
(handler (th/make-dummy-request {}))
|
|
|
|
(t/is (nil? (::http/auth-data @request)))
|
|
|
|
(handler (th/make-dummy-request {:headers {"authorization" "Token aaaa"}}))
|
|
|
|
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
|
(t/is (= :token token-type))
|
|
(t/is (= "aaaa" token))
|
|
(t/is (nil? claims)))))
|
|
|
|
(t/deftest auth-middleware-2
|
|
(let [request (volatile! nil)
|
|
handler (#'app.http.middleware/wrap-auth
|
|
(fn [req] (vreset! request req))
|
|
{})]
|
|
|
|
(handler (th/make-dummy-request {}))
|
|
(t/is (nil? (::http/auth-data @request)))
|
|
|
|
;; A bearer token is only attached when it is a current session
|
|
;; token (kid=1/ver=1) and a decoder is configured. Otherwise the
|
|
;; request stays unauthenticated.
|
|
(handler (th/make-dummy-request {:headers {"authorization" "Bearer aaaa"}}))
|
|
(t/is (nil? (::http/auth-data @request)))))
|
|
|
|
(t/deftest auth-middleware-3
|
|
(let [request (volatile! nil)
|
|
handler (#'app.http.middleware/wrap-auth
|
|
(fn [req] (vreset! request req))
|
|
{})]
|
|
|
|
(handler (th/make-dummy-request {}))
|
|
(t/is (nil? (::http/auth-data @request)))
|
|
|
|
(handler (th/make-dummy-request {:cookies {"auth-token" "foobar"}}))
|
|
(t/is (nil? (::http/auth-data @request)))))
|
|
|
|
(t/deftest shared-key-auth
|
|
(let [handler (#'app.http.middleware/wrap-shared-key-auth
|
|
(fn [req] {::yres/status 200})
|
|
{:test1 "secret-key"})]
|
|
|
|
(let [response (handler (th/make-dummy-request {}))]
|
|
(t/is (= 403 (::yres/status response))))
|
|
|
|
(let [response (handler (th/make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))]
|
|
(t/is (= 403 (::yres/status response))))
|
|
|
|
(let [response (handler (th/make-dummy-request {:headers {"x-shared-key" "secret-key"}}))]
|
|
(t/is (= 403 (::yres/status response))))
|
|
|
|
(let [response (handler (th/make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))]
|
|
(t/is (= 200 (::yres/status response))))))
|
|
|
|
(t/deftest access-token-authz
|
|
(let [profile (th/create-profile* 1)
|
|
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil)
|
|
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
|
|
|
|
(let [response (handler nil)]
|
|
(t/is (nil? response)))
|
|
|
|
(let [response (handler {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}})]
|
|
(t/is (= #{} (:app.http.access-token/perms response)))
|
|
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
|
|
|
|
(t/deftest access-token-authz-sets-token-id-and-type
|
|
(let [profile (th/create-profile* 1)
|
|
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token
|
|
(:id profile) "test" nil "mcp")
|
|
handler (#'app.http.access-token/wrap-authz identity th/*system*)
|
|
request {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}}
|
|
response (handler request)]
|
|
;; Must set ::actoken/id from claims :tid
|
|
(t/is (= (:id token) (:app.http.access-token/id response)))
|
|
;; Must set ::actoken/type from database
|
|
(t/is (= "mcp" (:app.http.access-token/type response)))
|
|
;; Existing assertions still pass
|
|
(t/is (= #{} (:app.http.access-token/perms response)))
|
|
(t/is (= (:id profile) (:app.http.access-token/profile-id response)))))
|
|
|
|
(defrecord MethodAwareDummyRequest [req-method headers]
|
|
yreq/IRequest
|
|
(method [_] req-method)
|
|
(get-header [_ name] (get headers name)))
|
|
|
|
(t/deftest cors-middleware-allowlisted-origin
|
|
(let [handler (#'app.http.middleware/wrap-cors
|
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
|
#{"https://trusted.example"})
|
|
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://trusted.example"}))
|
|
headers (::yres/headers resp)]
|
|
|
|
(t/is (= 200 (::yres/status resp)))
|
|
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
|
|
(t/is (= "true" (get headers "access-control-allow-credentials")))
|
|
(t/is (= "Origin" (get headers "vary")))
|
|
(t/is (= "content-type, retry-after, x-rate-limit-remaining, x-rate-limit-reset"
|
|
(get headers "access-control-expose-headers")))
|
|
(t/is (not (str/includes?
|
|
(get headers "access-control-allow-headers" "")
|
|
"cookie")))))
|
|
|
|
(t/deftest cors-middleware-non-allowlisted-origin
|
|
(let [handler (#'app.http.middleware/wrap-cors
|
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
|
#{"https://trusted.example"})
|
|
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://attacker.example"}))
|
|
headers (::yres/headers resp)]
|
|
|
|
(t/is (= 200 (::yres/status resp)))
|
|
(t/is (nil? (get headers "access-control-allow-origin")))
|
|
(t/is (nil? (get headers "access-control-allow-credentials")))
|
|
(t/is (nil? (get headers "access-control-allow-headers")))
|
|
(t/is (nil? (get headers "access-control-expose-headers")))
|
|
(t/is (= "Origin" (get headers "vary")))))
|
|
|
|
(t/deftest cors-middleware-preflight-allowlisted
|
|
(let [handler (#'app.http.middleware/wrap-cors
|
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
|
#{"https://trusted.example"})
|
|
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://trusted.example"}))
|
|
headers (::yres/headers resp)]
|
|
|
|
(t/is (= 204 (::yres/status resp)))
|
|
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
|
|
(t/is (= "true" (get headers "access-control-allow-credentials")))))
|
|
|
|
(t/deftest cors-middleware-preflight-non-allowlisted
|
|
(let [handler (#'app.http.middleware/wrap-cors
|
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
|
#{"https://trusted.example"})
|
|
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://attacker.example"}))
|
|
headers (::yres/headers resp)]
|
|
|
|
(t/is (= 204 (::yres/status resp)))
|
|
(t/is (nil? (get headers "access-control-allow-origin")))
|
|
(t/is (nil? (get headers "access-control-allow-credentials")))))
|
|
|
|
(t/deftest cors-middleware-missing-origin
|
|
(let [handler (#'app.http.middleware/wrap-cors
|
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
|
#{"https://trusted.example"})
|
|
resp (handler (->MethodAwareDummyRequest :get {}))
|
|
headers (::yres/headers resp)]
|
|
|
|
(t/is (= 200 (::yres/status resp)))
|
|
(t/is (nil? (get headers "access-control-allow-origin")))
|
|
(t/is (nil? (get headers "access-control-allow-credentials")))))
|
|
|
|
(t/deftest session-authz
|
|
(let [cfg th/*system*
|
|
manager (session/inmemory-manager)
|
|
profile (th/create-profile* 1)
|
|
handler (-> (fn [req] req)
|
|
(#'session/wrap-authz {::session/manager manager})
|
|
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)}))
|
|
|
|
session (->> (session/create-session manager {:profile-id (:id profile)
|
|
:user-agent "user agent"})
|
|
(#'session/assign-token cfg))
|
|
|
|
response (handler (th/make-dummy-request {:cookies {"auth-token" (:token session)}}))
|
|
|
|
{:keys [token claims] token-type :type}
|
|
(get response ::http/auth-data)]
|
|
|
|
(t/is (= :cookie token-type))
|
|
(t/is (= (:token session) token))
|
|
(t/is (= "authentication" (:iss claims)))
|
|
(t/is (= "penpot" (:aud claims)))
|
|
(t/is (= (:id session) (:sid claims)))
|
|
(t/is (= (:id profile) (:uid claims)))))
|
|
|
|
(t/deftest session-token-contains-exp-claim
|
|
(let [cfg th/*system*
|
|
manager (session/inmemory-manager)
|
|
profile (th/create-profile* 1)
|
|
session (->> (session/create-session manager {:profile-id (:id profile)
|
|
:user-agent "user agent"})
|
|
(#'session/assign-token cfg))
|
|
claims (tokens/decode cfg (:token session))
|
|
exp (:exp claims)]
|
|
(t/is (some? exp) "session token should contain :exp claim")
|
|
(t/is (ct/inst? exp) "exp should be an instant")))
|
|
|
|
(t/deftest session-token-exp-based-on-created-at
|
|
(let [cfg th/*system*
|
|
manager (session/inmemory-manager)
|
|
profile (th/create-profile* 1)
|
|
session (->> (session/create-session manager {:profile-id (:id profile)
|
|
:user-agent "user agent"})
|
|
(#'session/assign-token cfg))
|
|
claims (tokens/decode cfg (:token session))
|
|
expected-exp (ct/plus (:created-at session) (ct/duration {:days 30}))]
|
|
(t/is (some? (:exp claims)) "session token should contain :exp claim")
|
|
(t/is (= (inst-ms (:exp claims))
|
|
(inst-ms expected-exp))
|
|
"exp should equal created-at + 30 days")))
|
|
|
|
(t/deftest session-token-past-exp-is-rejected
|
|
(let [cfg th/*system*
|
|
manager (session/inmemory-manager)
|
|
profile (th/create-profile* 1)
|
|
session (->> (session/create-session manager {:profile-id (:id profile)
|
|
:user-agent "user agent"})
|
|
(#'session/assign-token cfg))
|
|
claims (tokens/decode cfg (:token session))
|
|
past-claims (assoc claims :exp (ct/minus (ct/now) (ct/duration {:days 1})))
|
|
past-token (tokens/generate cfg past-claims {:kid 1 :ver 1})]
|
|
(t/is (nil? (session/decode-token cfg past-token))
|
|
"token with exp in the past should be rejected")))
|
|
|
|
(t/deftest session-renewal-preserves-original-exp
|
|
(let [cfg th/*system*
|
|
profile (th/create-profile* 1)
|
|
created (ct/minus (ct/now) (ct/duration {:days 1}))
|
|
session {:id (uuid/random)
|
|
:profile-id (:id profile)
|
|
:user-agent "user agent"
|
|
:created-at created
|
|
:modified-at (ct/minus (ct/now) (ct/duration {:hours 7}))}
|
|
manager (reify session/ISessionManager
|
|
(read-session [_ _] session)
|
|
(create-session [_ _] session)
|
|
(update-session [_ s] (assoc s :modified-at (ct/now)))
|
|
(delete-session [_ _] nil))
|
|
|
|
old-token (:token (#'session/assign-token cfg session))
|
|
original-exp (:exp (tokens/decode cfg old-token))
|
|
handler (-> (fn [req] req)
|
|
(#'session/wrap-authz (assoc th/*system* ::session/manager manager))
|
|
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)}))
|
|
response (handler (th/make-dummy-request {:cookies {"auth-token" old-token}}))
|
|
renewed-token (get-in response [::yres/cookies "auth-token" :value])
|
|
renewed-claims (tokens/decode cfg renewed-token)]
|
|
(t/is (some? original-exp) "original token should have :exp")
|
|
(t/is (not= old-token renewed-token) "renewal should issue a new token string")
|
|
(t/is (= (inst-ms original-exp) (inst-ms (:exp renewed-claims)))
|
|
"renewed token should preserve the original :exp, not extend it")))
|
|
|
|
(t/deftest session-renewal-preserves-exp-with-db-manager
|
|
(let [cfg th/*system*
|
|
manager (::session/manager th/*system*)
|
|
profile (th/create-profile* 1)
|
|
created (session/create-session manager {:profile-id (:id profile)
|
|
:user-agent "user agent"})
|
|
_ (th/db-exec-one! ["UPDATE http_session_v2
|
|
SET modified_at = now() - interval '7 hours'
|
|
WHERE id = ?" (:id created)])
|
|
stale (session/read-session manager (:id created))
|
|
old-token (:token (#'session/assign-token cfg stale))
|
|
original-exp (:exp (tokens/decode cfg old-token))
|
|
handler (-> (fn [req] req)
|
|
(#'session/wrap-authz cfg)
|
|
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)}))
|
|
response (handler (th/make-dummy-request {:cookies {"auth-token" old-token}}))
|
|
renewed (get-in response [::yres/cookies "auth-token" :value])
|
|
renewed-exp (:exp (tokens/decode cfg renewed))
|
|
expected-exp (ct/plus (:created-at created) (ct/duration {:days 30}))
|
|
current (session/read-session manager (:id created))]
|
|
(t/is (some? original-exp) "original token should have :exp")
|
|
(t/is (some? renewed) "renewal should issue a new cookie token")
|
|
(t/is (not= old-token renewed) "renewal should issue a new token string")
|
|
(t/is (= (inst-ms original-exp) (inst-ms renewed-exp))
|
|
"renewed token should preserve the original :exp, not extend it")
|
|
(t/is (= (inst-ms expected-exp) (inst-ms renewed-exp))
|
|
"renewed :exp should equal created-at + 30 days")
|
|
(t/is (some? current) "session row must still exist after renewal")
|
|
(t/is (pos? (compare (:modified-at current) (:modified-at stale)))
|
|
"persisted modified_at must move forward on renewal")))
|
|
|
|
(t/deftest session-renewal-cookie-expires-diverges-from-token-exp
|
|
(let [cfg th/*system*
|
|
manager (::session/manager th/*system*)
|
|
profile (th/create-profile* 1)
|
|
created (session/create-session manager {:profile-id (:id profile)
|
|
:user-agent "user agent"})
|
|
_ (th/db-exec-one! ["UPDATE http_session_v2
|
|
SET created_at = now() - interval '29 days',
|
|
modified_at = now() - interval '7 hours'
|
|
WHERE id = ?" (:id created)])
|
|
stale (session/read-session manager (:id created))
|
|
old-token (:token (#'session/assign-token cfg stale))
|
|
handler (-> (fn [req] req)
|
|
(#'session/wrap-authz cfg)
|
|
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)}))
|
|
response (handler (th/make-dummy-request {:cookies {"auth-token" old-token}}))
|
|
cookie (get-in response [::yres/cookies "auth-token"])
|
|
renewed (:value cookie)
|
|
renewed-exp (:exp (tokens/decode cfg renewed))
|
|
expected-exp (ct/plus (:created-at stale) (ct/duration {:days 30}))
|
|
close-to? (fn [a b tolerance-ms]
|
|
(<= (Math/abs (- (inst-ms a) (inst-ms b))) tolerance-ms))]
|
|
(t/is (some? renewed) "renewal should issue a new cookie token")
|
|
(t/is (not= old-token renewed) "renewal should issue a new token string")
|
|
(t/is (= (inst-ms expected-exp) (inst-ms renewed-exp))
|
|
"renewed :exp should equal created-at + 30 days")
|
|
(t/is (close-to? renewed-exp (ct/plus (ct/now) (ct/duration {:days 1}))
|
|
(* 10 60 1000))
|
|
"renewed :exp should be ~1 day out (absolute cap is near)")
|
|
(t/is (close-to? (:expires cookie) (ct/plus (ct/now) (ct/duration {:days 7}))
|
|
(* 10 60 1000))
|
|
"cookie Expires should slide ~7 days out from now")
|
|
(t/is (pos? (compare (:expires cookie) renewed-exp))
|
|
"cookie Expires should stay ahead of the token :exp")))
|
|
|
|
(t/deftest legacy-session-token-is-rejected
|
|
(let [cfg th/*system*
|
|
manager (session/inmemory-manager)
|
|
handler (-> (fn [req] req)
|
|
(#'session/wrap-authz {::session/manager manager})
|
|
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)}))
|
|
token (tokens/generate cfg {:sid "legacy-session-id"} {:kid 0 :ver 0})
|
|
response (handler (th/make-dummy-request {:cookies {"auth-token" token}}))]
|
|
(t/is (nil? (get response ::http/auth-data))
|
|
"legacy tokens must not be attached as auth data")
|
|
(t/is (nil? (::session/profile-id response))
|
|
"legacy tokens must not authenticate")))
|
|
|
|
(t/deftest session-gc-deletes-idle-and-absolute-expired-rows
|
|
(let [profile (th/create-profile* 1)
|
|
fresh (uuid/random)
|
|
idle (uuid/random)
|
|
absolute (uuid/random)
|
|
valid (uuid/random)]
|
|
|
|
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
|
VALUES (?, ?, now(), now())"
|
|
fresh (:id profile)])
|
|
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
|
VALUES (?, ?, now() - interval '1 day', now() - interval '8 days')"
|
|
idle (:id profile)])
|
|
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
|
VALUES (?, ?, now() - interval '31 days', now())"
|
|
absolute (:id profile)])
|
|
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
|
VALUES (?, ?, now() - interval '1 day', now() - interval '6 days')"
|
|
valid (:id profile)])
|
|
|
|
(db/tx-run! th/*system*
|
|
(fn [cfg]
|
|
(#'session/collect-expired-tasks
|
|
(assoc cfg
|
|
:app.http.session.tasks/max-age (ct/duration {:days 7})
|
|
:app.http.session.tasks/max-age-absolute (ct/duration {:days 30})))))
|
|
|
|
(let [ids (->> (th/db-exec! ["SELECT id FROM http_session_v2 WHERE profile_id = ?" (:id profile)])
|
|
(map :id)
|
|
(set))]
|
|
(t/is (contains? ids fresh) "fresh session must be kept")
|
|
(t/is (contains? ids valid) "session within both windows must be kept")
|
|
(t/is (not (contains? ids idle)) "idle session must be deleted")
|
|
(t/is (not (contains? ids absolute)) "session past the absolute cap must be deleted"))))
|
|
|
|
(t/deftest session-gc-config-wiring
|
|
(let [idle (ct/duration {:days 3})
|
|
absolute (ct/duration {:days 10})]
|
|
(with-redefs [cf/get (fn
|
|
([k] (case k
|
|
:auth-token-cookie-max-age idle
|
|
:auth-token-cookie-max-age-absolute absolute
|
|
nil))
|
|
([k default] (case k
|
|
:auth-token-cookie-max-age idle
|
|
:auth-token-cookie-max-age-absolute absolute
|
|
default)))]
|
|
(let [expanded (ig/expand-key :app.http.session.tasks/gc {})]
|
|
(t/is (= idle
|
|
(get-in expanded [:app.http.session.tasks/gc
|
|
:app.http.session.tasks/max-age]))
|
|
"task max-age should carry the configured idle window")
|
|
(t/is (= absolute
|
|
(get-in expanded [:app.http.session.tasks/gc
|
|
:app.http.session.tasks/max-age-absolute]))
|
|
"task max-age-absolute should carry the configured absolute cap")))
|
|
(with-redefs [cf/get (fn
|
|
([_k] nil)
|
|
([_k default] default))]
|
|
(let [expanded (ig/expand-key :app.http.session.tasks/gc {})]
|
|
(t/is (= session/default-cookie-max-age
|
|
(get-in expanded [:app.http.session.tasks/gc
|
|
:app.http.session.tasks/max-age]))
|
|
"task max-age should fall back to the default idle window")
|
|
(t/is (= session/default-cookie-max-age-absolute
|
|
(get-in expanded [:app.http.session.tasks/gc
|
|
:app.http.session.tasks/max-age-absolute]))
|
|
"task max-age-absolute should fall back to the default absolute cap")))))
|
|
|
|
(t/deftest session-gc-rejects-absolute-below-idle
|
|
(let [pool (:app.db/pool th/*system*)
|
|
params {:app.db/pool pool
|
|
:app.http.session.tasks/max-age (ct/duration {:days 7})
|
|
:app.http.session.tasks/max-age-absolute (ct/duration {:days 30})}]
|
|
(t/is (nil? (ig/assert-key :app.http.session.tasks/gc params))
|
|
"absolute cap above the idle window should pass")
|
|
(t/is (nil? (ig/assert-key :app.http.session.tasks/gc
|
|
(assoc params
|
|
:app.http.session.tasks/max-age-absolute
|
|
(ct/duration {:days 7}))))
|
|
"absolute cap equal to the idle window should pass")
|
|
(t/is (thrown? IllegalArgumentException
|
|
(ig/assert-key :app.http.session.tasks/gc
|
|
(assoc params
|
|
:app.http.session.tasks/max-age-absolute
|
|
(ct/duration {:days 3}))))
|
|
"absolute cap below the idle window should fail fast")))
|
|
|
|
(t/deftest idle-expired-session-is-rejected
|
|
(let [cfg th/*system*
|
|
profile (th/create-profile* 1)
|
|
updated? (atom false)
|
|
session {:id (uuid/random)
|
|
:profile-id (:id profile)
|
|
:user-agent "user agent"
|
|
:created-at (ct/now)
|
|
:modified-at (ct/minus (ct/now) (ct/duration {:days 8}))}
|
|
manager (reify session/ISessionManager
|
|
(read-session [_ _] session)
|
|
(create-session [_ _] session)
|
|
(update-session [_ s] (reset! updated? true) s)
|
|
(delete-session [_ _] nil))
|
|
token (:token (#'session/assign-token cfg session))
|
|
handler (-> (fn [req] req)
|
|
(#'session/wrap-authz (assoc cfg ::session/manager manager))
|
|
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)}))
|
|
cookie-r (handler (th/make-dummy-request {:cookies {"auth-token" token}}))
|
|
bearer-r (handler (th/make-dummy-request {:headers {"authorization" (str "Bearer " token)}}))]
|
|
(t/is (nil? (::session/profile-id cookie-r))
|
|
"idle-expired session must not authenticate via cookie")
|
|
(t/is (nil? (::session/session cookie-r))
|
|
"idle-expired session must not be attached via cookie")
|
|
(t/is (nil? (::session/profile-id bearer-r))
|
|
"idle-expired session must not authenticate via bearer")
|
|
(t/is (false? @updated?)
|
|
"idle-expired session must not be renewed")))
|
|
|
|
(t/deftest idle-session-within-window-is-accepted
|
|
(let [cfg th/*system*
|
|
profile (th/create-profile* 1)
|
|
session {:id (uuid/random)
|
|
:profile-id (:id profile)
|
|
:user-agent "user agent"
|
|
:created-at (ct/now)
|
|
:modified-at (ct/minus (ct/now) (ct/duration {:days 6}))}
|
|
manager (reify session/ISessionManager
|
|
(read-session [_ _] session)
|
|
(create-session [_ _] session)
|
|
(update-session [_ s] (assoc s :modified-at (ct/now)))
|
|
(delete-session [_ _] nil))
|
|
token (:token (#'session/assign-token cfg session))
|
|
handler (-> (fn [req] req)
|
|
(#'session/wrap-authz (assoc cfg ::session/manager manager))
|
|
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)}))
|
|
response (handler (th/make-dummy-request {:cookies {"auth-token" token}}))]
|
|
(t/is (= (:id profile) (::session/profile-id response))
|
|
"session within the idle window must authenticate")))
|
|
|
|
(t/deftest parse-request-illegal-argument-exception
|
|
;; clojure.data.json raises IllegalArgumentException (case
|
|
;; fall-through) on several kinds of malformed input. The
|
|
;; parse-request middleware should convert any such IAE into a
|
|
;; 400 :malformed-json validation error rather than letting it
|
|
;; surface as a 500 internal error. Because the conversion is
|
|
;; done by raising an ex-info (caught by the top-level error
|
|
;; handler in app.http/router-handler), this test asserts on
|
|
;; the ex-info thrown by wrap-parse-request directly.
|
|
(let [handler (#'app.http.middleware/wrap-parse-request
|
|
(fn [_] {::yres/status 200 ::yres/body :ok}))
|
|
;; Body contains the bytes for: {"x": "\}"} -- a string
|
|
;; value with a backslash followed by '}', which
|
|
;; clojure.data.json v0.5.x cannot handle.
|
|
body (.getBytes "{\"x\": \"\\}\"}" "UTF-8")
|
|
request (th/make-dummy-request
|
|
{:method :post
|
|
:headers {"content-type" "application/json"}
|
|
:body-bytes body})
|
|
ex (try
|
|
(handler request)
|
|
(catch clojure.lang.ExceptionInfo e e))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
|
(t/is (= :validation (-> ex ex-data :type)))
|
|
(t/is (= :malformed-json (-> ex ex-data :code)))
|
|
(t/is (= "invalid JSON in request body" (-> ex ex-data :hint)))))
|
|
|
|
(t/deftest parse-request-request-too-big-exception
|
|
;; When RequestTooBigException is raised (e.g. the request body
|
|
;; exceeded the configured size limit), the middleware should
|
|
;; convert it to a 413 :request-body-too-large validation
|
|
;; error.
|
|
(let [handler (#'app.http.middleware/wrap-parse-request
|
|
(fn [_] (throw (RequestTooBigException. "too large"))))
|
|
request (th/make-dummy-request
|
|
{:method :post
|
|
:headers {"content-type" "application/json"}
|
|
:body-bytes (.getBytes "{}" "UTF-8")})
|
|
ex (try
|
|
(handler request)
|
|
(catch clojure.lang.ExceptionInfo e e))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
|
(t/is (= :validation (-> ex ex-data :type)))
|
|
(t/is (= :request-body-too-large (-> ex ex-data :code)))
|
|
(t/is (= "request body exceeds size limit" (-> ex ex-data :hint)))))
|
|
|
|
(t/deftest parse-request-eof-exception
|
|
;; When java.io.EOFException is raised (e.g. the body stream
|
|
;; was closed before the parser could read it), the middleware
|
|
;; should convert it to a 400 :malformed-json validation error.
|
|
(let [handler (#'app.http.middleware/wrap-parse-request
|
|
(fn [_] (throw (java.io.EOFException. "stream closed"))))
|
|
request (th/make-dummy-request
|
|
{:method :post
|
|
:headers {"content-type" "application/json"}
|
|
:body-bytes (.getBytes "{}" "UTF-8")})
|
|
ex (try
|
|
(handler request)
|
|
(catch clojure.lang.ExceptionInfo e e))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
|
(t/is (= :validation (-> ex ex-data :type)))
|
|
(t/is (= :malformed-json (-> ex ex-data :code)))
|
|
(t/is (= "unexpected end of request body" (-> ex ex-data :hint)))))
|
|
|
|
(t/deftest parse-request-runtime-exception-with-cause
|
|
;; When a RuntimeException with a non-nil ex-cause is raised,
|
|
;; the middleware should recurse on the cause and dispatch
|
|
;; through the specific-exception branches. Here we wrap an
|
|
;; IllegalArgumentException in a RuntimeException and verify
|
|
;; it surfaces as :malformed-json.
|
|
(let [iae (IllegalArgumentException. "No matching clause: 99")
|
|
wrapped (doto (RuntimeException. "wrapped")
|
|
(.initCause iae))
|
|
handler (#'app.http.middleware/wrap-parse-request
|
|
(fn [_] (throw wrapped)))
|
|
request (th/make-dummy-request
|
|
{:method :post
|
|
:headers {"content-type" "application/json"}
|
|
:body-bytes (.getBytes "{}" "UTF-8")})
|
|
ex (try
|
|
(handler request)
|
|
(catch clojure.lang.ExceptionInfo e e))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
|
(t/is (= :validation (-> ex ex-data :type)))
|
|
(t/is (= :malformed-json (-> ex ex-data :code)))))
|
|
|
|
(t/deftest parse-request-runtime-exception-without-cause
|
|
;; When a bare RuntimeException (no ex-cause) is raised, the
|
|
;; middleware should fall through to errors/handle's :default
|
|
;; path and return a 500 with :type :server-error :code
|
|
;; :unexpected. This is the "true internal error" path.
|
|
(let [handler (#'app.http.middleware/wrap-parse-request
|
|
(fn [_] (throw (RuntimeException. "boom"))))
|
|
request (th/make-dummy-request
|
|
{:method :post
|
|
:headers {"content-type" "application/json"}
|
|
:body-bytes (.getBytes "{}" "UTF-8")})
|
|
response (handler request)
|
|
body (::yres/body response)]
|
|
(t/is (= 500 (::yres/status response)))
|
|
(t/is (= :server-error (:type body)))
|
|
(t/is (= :unexpected (:code body)))
|
|
(t/is (nil? (:hint body)))))
|
|
|
|
(t/deftest parse-request-non-runtime-throwable
|
|
;; When a non-RuntimeException Throwable is raised (e.g. an
|
|
;; Error subclass or a non-RuntimeException checked-style
|
|
;; exception), the middleware should fall through to the
|
|
;; :else branch and call errors/handle. java.io.IOException
|
|
;; has a dedicated handle-exception method that returns 500
|
|
;; with :code :io-exception.
|
|
(let [handler (#'app.http.middleware/wrap-parse-request
|
|
(fn [_] (throw (java.io.IOException. "network gone"))))
|
|
request (th/make-dummy-request
|
|
{:method :post
|
|
:headers {"content-type" "application/json"}
|
|
:body-bytes (.getBytes "{}" "UTF-8")})
|
|
response (handler request)
|
|
body (::yres/body response)]
|
|
(t/is (= 500 (::yres/status response)))
|
|
(t/is (= :server-error (:type body)))
|
|
(t/is (= :io-exception (:code body)))
|
|
(t/is (nil? (:hint body)))))
|
|
|
|
(t/deftest internal-error-strips-sensitive-fields
|
|
;; When an :internal error is raised with :state, :path, and
|
|
;; :context, those fields must not appear in the response body.
|
|
;; :hint is part of the error protocol and is preserved.
|
|
(let [cause (ex-info "internal error"
|
|
{:type :internal
|
|
:code :test-error
|
|
:hint "safe user-facing hint"
|
|
:state "XX000"
|
|
:path "/data/penpot/storage"
|
|
:context {:backend :s3 :bucket "prod"}})
|
|
response (http-errors/handle cause {})
|
|
body (::yres/body response)]
|
|
(t/is (= 500 (::yres/status response)))
|
|
(t/is (= :server-error (:type body)))
|
|
(t/is (= :test-error (:code body)))
|
|
(t/is (= "safe user-facing hint" (:hint body)))
|
|
(t/is (nil? (:state body)))
|
|
(t/is (nil? (:path body)))
|
|
(t/is (nil? (:context body)))))
|
|
|
|
(t/deftest unhandled-exinfo-strips-sensitive-fields
|
|
;; When an ex-info with an unregistered :type (dispatches through
|
|
;; handle-exception :default :else) carries :state and :path,
|
|
;; those fields must not appear in the response body.
|
|
;; :hint is part of the error protocol and is preserved.
|
|
(let [cause (ex-info "something broke"
|
|
{:type :unregistered-type
|
|
:code :custom-code
|
|
:hint "safe user-facing hint"
|
|
:state "internal-state"
|
|
:path "/internal/path"})
|
|
response (http-errors/handle cause {})
|
|
body (::yres/body response)]
|
|
(t/is (= 500 (::yres/status response)))
|
|
(t/is (= :server-error (:type body)))
|
|
(t/is (= :custom-code (:code body)))
|
|
(t/is (= "safe user-facing hint" (:hint body)))
|
|
(t/is (nil? (:state body)))
|
|
(t/is (nil? (:path body)))))
|