;; This Source Code Form is subject to the terms of the Mozilla Public ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns backend-tests.http-middleware-test (:require [app.common.time :as ct] [app.db :as db] [app.http :as-alias http] [app.http.access-token] [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] [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) (defrecord DummyRequest [headers cookies method body-stream remote-addr server-name server-port scheme protocol path query ssl-client-cert] yreq/IRequestCookies (get-cookie [_ name] {:value (get cookies name)}) yreq/IRequest (get-header [_ name] (get headers name)) (method [_] method) (body [_] body-stream) (path [_] path) (query [_] query) (server-port [_] server-port) (server-name [_] server-name) (remote-addr [_] remote-addr) (ssl-client-cert [_] ssl-client-cert) (scheme [_] scheme) (protocol [_] protocol)) (defn- make-dummy-request "Constructs a DummyRequest from an options map. Every key is optional; missing values fall back to sensible defaults. New fields added to DummyRequest won't break existing call sites as long as this constructor keeps its `:or` defaults in sync. Recognized keys: :headers — map of header name → value :cookies — map of cookie name → value :method — HTTP method keyword (default :get) :body-stream — InputStream for the body (used directly) :body-bytes — bytes or string for the body; wrapped in a ByteArrayInputStream if :body-stream is not given :remote-addr — string (default \"127.0.0.1\") :server-name — string (default \"test\") :server-port — long (default 0) :scheme — keyword (default :http) :protocol — string (default \"HTTP/1.1\") :path — string (default \"/test\") :query — string or nil (default nil) :ssl-client-cert — X509Certificate or nil (default nil)" [{:keys [headers cookies method body-stream body-bytes remote-addr server-name server-port scheme protocol path query ssl-client-cert] :or {headers {} cookies {} method :get body-stream nil remote-addr "127.0.0.1" server-name "test" server-port 0 scheme :http protocol "HTTP/1.1" path "/test" query nil ssl-client-cert nil}}] (let [body-stream (or body-stream (when body-bytes (java.io.ByteArrayInputStream. (if (string? body-bytes) (.getBytes ^String body-bytes "UTF-8") body-bytes))))] (->DummyRequest headers cookies method body-stream remote-addr server-name server-port scheme protocol path query ssl-client-cert))) (t/deftest auth-middleware-1 (let [request (volatile! nil) handler (#'app.http.middleware/wrap-auth (fn [req] (vreset! request req)) {})] (handler (make-dummy-request {})) (t/is (nil? (::http/auth-data @request))) (handler (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 (make-dummy-request {})) (t/is (nil? (::http/auth-data @request))) (handler (make-dummy-request {:headers {"authorization" "Bearer aaaa"}})) (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (t/is (= :bearer token-type)) (t/is (= "aaaa" token)) (t/is (nil? claims))))) (t/deftest auth-middleware-3 (let [request (volatile! nil) handler (#'app.http.middleware/wrap-auth (fn [req] (vreset! request req)) {})] (handler (make-dummy-request {})) (t/is (nil? (::http/auth-data @request))) (handler (make-dummy-request {:cookies {"auth-token" "foobar"}})) (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (t/is (= :cookie token-type)) (t/is (= "foobar" token)) (t/is (nil? claims))))) (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 (make-dummy-request {}))] (t/is (= 403 (::yres/status response)))) (let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))] (t/is (= 403 (::yres/status response)))) (let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key"}}))] (t/is (= 403 (::yres/status response)))) (let [response (handler (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" (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 (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 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 (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 (string? (-> 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 (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 (string? (-> 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 (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 (string? (-> 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 (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 (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 (= "boom" (: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 (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 (= "network gone" (:hint body)))))