mirror of
https://github.com/penpot/penpot.git
synced 2026-10-31 15:00:32 +00:00
* ♻️ Move devenv-only tools to a separate folder * 🎉 Add an MCP feedback channel Add a user-approved feedback tool and an MCP-token-authenticated backend endpoint that queues plain-text email. Enable the tool through the configured backend URI in both local and multi-user mode. Validate subject and feedback limits, enforce per-profile rate limits, and document configuration. Cover authentication, queueing, tool availability, and failure handling with backend and MCP tests. Closes #12490 AI-assisted-by: gpt-6 * ♻️ Move MCP feedback submission into RPC Use the standard RPC wrappers for feedback and remove the dedicated HTTP endpoint, route wiring, and custom rate limiter. Restrict MCP tokens to opted-in routes and commands. Add a shared RPC call helper to the MCP backend client and adapt the existing tests. AI-assisted-by: gpt-6 * 🐛 Harden MCP feedback auth, validation and messages Accept MCP tokens when either the mcp or the access-tokens flag is on, so instances that enable MCP without personal access tokens can send feedback. Personal tokens still need access-tokens. Keep the issuer in one constant, decode tokens without raising to reject unknown issuers, and name the issuer and type checks. Reject control characters in the subject, and in the feedback all but tabs and line breaks, in both the command and the tool, so a NUL answers 400 instead of failing when the mail job is stored. Answer 403 with profile-blocked or profile-not-active for unusable profiles, record only who sent feedback in the audit event, and add doc metadata. Replace on OAuth approval an MCP token issued before MCP tokens had their own issuer, or one this instance cannot decrypt, since the backend rejects both. In the MCP server, mention disabled access tokens on 401, drop the unused 503 case, and say that delivery happens in the background. Document the flags in the README. AI-assisted-by: claude-opus-5-5 * 📚 Document the MCP feedback tool and MCP token rules List submit_feedback among the MCP tools and add an MCP feedback section to the configuration guide covering PENPOT_MCP_BACKEND_URI, PENPOT_MCP_FEEDBACK_EMAIL, the flags and the email settings. Add commented examples of both variables to the compose file. In the agent memories, record when OAuth approval replaces an MCP token, how the MCP server reaches the backend, and which flags, routes and commands accept MCP tokens. AI-assisted-by: claude-opus-5-5
961 lines
39 KiB
Clojure
961 lines
39 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 INC Sucursal en España SL
|
|
|
|
(ns backend-tests.rpc-test
|
|
(:require
|
|
[app.common.exceptions :as ex]
|
|
[app.common.schema :as sm]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.http :as http]
|
|
[app.http.access-token :as actoken]
|
|
[app.http.session :as session]
|
|
[app.rpc :as rpc]
|
|
[app.rpc.cond :as cond]
|
|
[app.rpc.helpers :as rph]
|
|
[app.util.inet :as inet]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.test :as t]
|
|
[yetti.response :as yres]))
|
|
|
|
(defn- echo-handler
|
|
"Handler that returns the data map it receives, for inspection."
|
|
[data]
|
|
data)
|
|
|
|
(defn- make-handler
|
|
"Creates an RPC handler with a single :test-method backed by echo-handler.
|
|
Extra methods are merged in; each value is [metadata handler-fn]."
|
|
([] (make-handler {}))
|
|
([extra-methods]
|
|
(let [methods (merge {:test-method [{} echo-handler]}
|
|
extra-methods)]
|
|
(rpc/make-rpc-handler methods))))
|
|
|
|
(defn- make-request
|
|
"Builds a dummy request. Extra qualified-keyword keys are merged onto it."
|
|
[& {:keys [method path-params headers extras]
|
|
:or {method :post
|
|
path-params {:method-name "test-method"}
|
|
headers {}
|
|
extras {}}}]
|
|
(-> (th/make-dummy-request {:method method
|
|
:headers headers})
|
|
(assoc :path-params path-params
|
|
:params {})
|
|
(merge extras)))
|
|
|
|
(defn- call
|
|
"Invokes handler with a built request, returns the full response map."
|
|
([handler opts]
|
|
(handler (make-request opts)))
|
|
([handler]
|
|
(call handler {})))
|
|
|
|
(defn- call-body
|
|
"Invokes handler with a built request, returns ::yres/body from response."
|
|
([handler opts]
|
|
(::yres/body (call handler opts)))
|
|
([handler]
|
|
(::yres/body (call handler))))
|
|
|
|
(defn- call-ex
|
|
"Invokes handler, catches and returns the exception."
|
|
([handler opts]
|
|
(try (call handler opts) (catch Exception e e)))
|
|
([handler]
|
|
(call-ex handler {})))
|
|
|
|
(def fixed-time (ct/now))
|
|
(def fixed-uuid (uuid/custom 11111111 22222222))
|
|
|
|
;; --- BASIC HANDLER DISPATCH
|
|
|
|
(t/deftest handler-receives-handler-name
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (= "test-method" (::rpc/handler-name data))))))
|
|
|
|
(t/deftest handler-receives-ip-addr
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "10.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (= "10.0.0.1" (::rpc/ip-addr data))))))
|
|
|
|
(t/deftest handler-receives-request-at
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (= fixed-time (::rpc/request-at data))))))
|
|
|
|
(t/deftest handler-receives-request-id
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (= fixed-uuid (::rpc/request-id data))))))
|
|
|
|
(t/deftest unknown-handler-name-raises-not-found
|
|
(let [handler (make-handler)
|
|
ex (call-ex handler {:path-params {:method-name "unknown"}})]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :not-found))))
|
|
|
|
;; --- HANDLER-NAME EDGE CASES
|
|
|
|
(t/deftest nil-path-params-falls-to-default-handler
|
|
(let [handler (make-handler)
|
|
request (make-request {:path-params nil})
|
|
ex (try (handler request) (catch Exception e e))]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :not-found))))
|
|
|
|
(t/deftest missing-method-name-falls-to-default-handler
|
|
(let [handler (make-handler)
|
|
request (make-request {:path-params {}})
|
|
ex (try (handler request) (catch Exception e e))]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :not-found))))
|
|
|
|
(t/deftest empty-method-name-falls-to-default-handler
|
|
(let [handler (make-handler)
|
|
request (make-request {:path-params {:method-name ""}})
|
|
ex (try (handler request) (catch Exception e e))]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :not-found))))
|
|
|
|
(t/deftest get-with-exact-get-prefix-no-suffix-allowed
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler {:get- [{} echo-handler]})
|
|
data (call-body handler
|
|
{:method :get
|
|
:path-params {:method-name "get-"}})]
|
|
(t/is (= "get-" (::rpc/handler-name data))))))
|
|
|
|
;; --- AUTH: SESSION
|
|
|
|
(t/deftest session-auth-profile-id
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id pid}})]
|
|
(t/is (= pid (::rpc/profile-id data))))))
|
|
|
|
(t/deftest session-auth-type
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id pid}})]
|
|
(t/is (= :session (::rpc/auth-type data))))))
|
|
|
|
(t/deftest session-auth-no-token-perms
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id pid}})]
|
|
(t/is (not (contains? data ::rpc/token-perms))))))
|
|
|
|
(t/deftest nil-session-profile-id-not-used
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id nil}})]
|
|
(t/is (not (contains? data ::rpc/profile-id)))
|
|
(t/is (not (contains? data ::rpc/auth-type))))))
|
|
|
|
;; --- AUTH: TOKEN
|
|
|
|
(t/deftest token-auth-profile-id
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid}})]
|
|
(t/is (= pid (::rpc/profile-id data))))))
|
|
|
|
(t/deftest token-auth-type
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid}})]
|
|
(t/is (= :token (::rpc/auth-type data))))))
|
|
|
|
(t/deftest token-auth-with-perms
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid
|
|
::actoken/perms #{"view" "edit"}}})]
|
|
(t/is (= #{"view" "edit"} (::rpc/token-perms data))))))
|
|
|
|
(t/deftest token-auth-perms-default-to-empty-set
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid}})]
|
|
(t/is (= #{} (::rpc/token-perms data))))))
|
|
|
|
(t/deftest token-auth-perms-coerced-from-vector
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid
|
|
::actoken/perms ["view" "edit"]}})]
|
|
(t/is (set? (::rpc/token-perms data)))
|
|
(t/is (= #{"view" "edit"} (::rpc/token-perms data))))))
|
|
|
|
(t/deftest token-auth-perms-coerced-from-list
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid
|
|
::actoken/perms (list "view" "edit")}})]
|
|
(t/is (set? (::rpc/token-perms data)))
|
|
(t/is (= #{"view" "edit"} (::rpc/token-perms data))))))
|
|
|
|
(t/deftest token-auth-with-nil-perms-defaults-to-empty-set
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid
|
|
::actoken/perms nil}})]
|
|
(t/is (= #{} (::rpc/token-perms data))))))
|
|
|
|
(t/deftest token-auth-with-empty-perms
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id pid
|
|
::actoken/perms #{}}})]
|
|
(t/is (= #{} (::rpc/token-perms data))))))
|
|
|
|
(t/deftest nil-actoken-profile-id-not-used
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id nil}})]
|
|
(t/is (not (contains? data ::rpc/profile-id)))
|
|
(t/is (not (contains? data ::rpc/auth-type))))))
|
|
|
|
;; --- AUTH: SHARED KEY
|
|
|
|
(t/deftest key-auth-profile-id-is-zero
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::http/auth-key-id "some-key"}})]
|
|
(t/is (= uuid/zero (::rpc/profile-id data))))))
|
|
|
|
(t/deftest key-auth-key-id-in-data
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::http/auth-key-id "some-key"}})]
|
|
(t/is (= "some-key" (::rpc/auth-key-id data))))))
|
|
|
|
(t/deftest nil-auth-key-id-not-used
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::http/auth-key-id nil}})]
|
|
(t/is (not (contains? data ::rpc/profile-id)))
|
|
(t/is (not (contains? data ::rpc/auth-key-id))))))
|
|
|
|
;; --- AUTH: UNAUTHENTICATED
|
|
|
|
(t/deftest unauthenticated-no-profile-id
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (not (contains? data ::rpc/profile-id))))))
|
|
|
|
(t/deftest unauthenticated-no-auth-type
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (not (contains? data ::rpc/auth-type))))))
|
|
|
|
;; --- AUTH: PRIORITY
|
|
|
|
(t/deftest session-overrides-token
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [session-pid (uuid/custom 1 1)
|
|
token-pid (uuid/custom 2 2)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id session-pid
|
|
::actoken/profile-id token-pid}})]
|
|
(t/is (= session-pid (::rpc/profile-id data)))
|
|
(t/is (= :session (::rpc/auth-type data))))))
|
|
|
|
(t/deftest session-overrides-key
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [session-pid (uuid/custom 1 1)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id session-pid
|
|
::http/auth-key-id "key-id"}})]
|
|
(t/is (= session-pid (::rpc/profile-id data)))
|
|
(t/is (= :session (::rpc/auth-type data)))
|
|
(t/is (= "key-id" (::rpc/auth-key-id data))))))
|
|
|
|
(t/deftest token-overrides-key
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [token-pid (uuid/custom 2 2)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::actoken/profile-id token-pid
|
|
::http/auth-key-id "key-id"}})]
|
|
(t/is (= token-pid (::rpc/profile-id data)))
|
|
(t/is (= :token (::rpc/auth-type data)))
|
|
(t/is (= "key-id" (::rpc/auth-key-id data))))))
|
|
|
|
(t/deftest session-overrides-token-and-key
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [session-pid (uuid/custom 1 1)
|
|
token-pid (uuid/custom 2 2)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id session-pid
|
|
::actoken/profile-id token-pid
|
|
::http/auth-key-id "key-id"}})]
|
|
(t/is (= session-pid (::rpc/profile-id data)))
|
|
(t/is (= :session (::rpc/auth-type data))))))
|
|
|
|
;; --- REQUEST METADATA: SESSION-ID
|
|
|
|
(t/deftest session-id-parsed-from-header
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [sid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:headers {"x-session-id" (str sid)}})]
|
|
(t/is (= sid (::rpc/session-id data))))))
|
|
|
|
(t/deftest session-id-nil-when-no-header
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (nil? (::rpc/session-id data))))))
|
|
|
|
(t/deftest session-id-nil-when-invalid-uuid
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:headers {"x-session-id" "not-a-uuid"}})]
|
|
(t/is (nil? (::rpc/session-id data))))))
|
|
|
|
(t/deftest session-id-nil-for-empty-string
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:headers {"x-session-id" ""}})]
|
|
(t/is (nil? (::rpc/session-id data))))))
|
|
|
|
(t/deftest session-id-parses-uppercase-uuid
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [sid (uuid/next)
|
|
handler (make-handler)
|
|
upper (clojure.string/upper-case (str sid))
|
|
data (call-body handler
|
|
{:headers {"x-session-id" upper}})]
|
|
(t/is (= sid (::rpc/session-id data))))))
|
|
|
|
;; --- REQUEST METADATA: ETAG / COND KEY
|
|
|
|
(t/deftest cond-key-from-if-none-match
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler
|
|
{:headers {"if-none-match" "W/\"abc123\""}})]
|
|
(t/is (= "W/\"abc123\"" (::cond/key data))))))
|
|
|
|
(t/deftest cond-key-nil-when-no-header
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler)
|
|
data (call-body handler)]
|
|
(t/is (nil? (::cond/key data))))))
|
|
|
|
;; --- REQUEST METADATA: META
|
|
|
|
(t/deftest data-meta-contains-request
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [request (make-request {})
|
|
handler (make-handler)
|
|
data (call-body handler {})]
|
|
(t/is (= request (::http/request (meta data)))))))
|
|
|
|
;; --- DATA COMPLETENESS
|
|
|
|
(t/deftest data-contains-all-expected-keys
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [pid (uuid/next)
|
|
handler (make-handler)
|
|
data (call-body handler
|
|
{:extras {::session/profile-id pid
|
|
::http/auth-key-id "key"}})]
|
|
(t/is (contains? data ::rpc/handler-name))
|
|
(t/is (contains? data ::rpc/ip-addr))
|
|
(t/is (contains? data ::rpc/request-at))
|
|
(t/is (contains? data ::rpc/request-id))
|
|
(t/is (contains? data ::rpc/session-id))
|
|
(t/is (contains? data ::cond/key))
|
|
(t/is (contains? data ::rpc/profile-id))
|
|
(t/is (contains? data ::rpc/auth-type))
|
|
(t/is (contains? data ::rpc/auth-key-id)))))
|
|
|
|
;; --- GET/HEAD RESTRICTION
|
|
|
|
(t/deftest get-restriction-non-get-handler
|
|
(let [handler (make-handler)
|
|
ex (call-ex handler
|
|
{:method :get
|
|
:path-params {:method-name "foo"}})]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :restriction))
|
|
(t/is (th/ex-of-code? ex :method-not-allowed))))
|
|
|
|
(t/deftest head-restriction-non-get-handler
|
|
(let [handler (make-handler)
|
|
ex (call-ex handler
|
|
{:method :head
|
|
:path-params {:method-name "foo"}})]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :restriction))
|
|
(t/is (th/ex-of-code? ex :method-not-allowed))))
|
|
|
|
(t/deftest get-allowed-for-get-prefixed-handler
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler {:get-profile [{} echo-handler]})
|
|
data (call-body handler
|
|
{:method :get
|
|
:path-params {:method-name "get-profile"}})]
|
|
(t/is (= "get-profile" (::rpc/handler-name data))))))
|
|
|
|
(t/deftest head-allowed-for-get-prefixed-handler
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler {:get-files [{} echo-handler]})
|
|
data (call-body handler
|
|
{:method :head
|
|
:path-params {:method-name "get-files"}})]
|
|
(t/is (= "get-files" (::rpc/handler-name data))))))
|
|
|
|
(t/deftest post-always-allowed
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler {:any-name [{} echo-handler]})
|
|
data (call-body handler
|
|
{:method :post
|
|
:path-params {:method-name "any-name"}})]
|
|
(t/is (= "any-name" (::rpc/handler-name data))))))
|
|
|
|
(t/deftest put-always-allowed
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler {:any-name [{} echo-handler]})
|
|
data (call-body handler
|
|
{:method :put
|
|
:path-params {:method-name "any-name"}})]
|
|
(t/is (= "any-name" (::rpc/handler-name data))))))
|
|
|
|
(t/deftest delete-always-allowed
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler {:any-name [{} echo-handler]})
|
|
data (call-body handler
|
|
{:method :delete
|
|
:path-params {:method-name "any-name"}})]
|
|
(t/is (= "any-name" (::rpc/handler-name data))))))
|
|
|
|
(t/deftest patch-always-allowed
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [handler (make-handler {:any-name [{} echo-handler]})
|
|
data (call-body handler
|
|
{:method :patch
|
|
:path-params {:method-name "any-name"}})]
|
|
(t/is (= "any-name" (::rpc/handler-name data))))))
|
|
|
|
(t/deftest get-to-non-get-handler-restriction-checks-before-handler-execution
|
|
(let [called? (atom false)
|
|
handler (make-handler {:foo [{} (fn [data]
|
|
(reset! called? true)
|
|
data)]})
|
|
ex (call-ex handler
|
|
{:method :get
|
|
:path-params {:method-name "foo"}})]
|
|
(t/is (some? ex))
|
|
(t/is (false? @called?))))
|
|
|
|
;; --- RESPONSE: NIL -> 204
|
|
|
|
(t/deftest nil-response-returns-204
|
|
(let [handler (make-handler {:nil-method [{} (constantly nil)]})
|
|
response (call handler
|
|
{:path-params {:method-name "nil-method"}})]
|
|
(t/is (= 204 (::yres/status response)))))
|
|
|
|
(t/deftest nil-response-gets-stream-content-type
|
|
(let [handler (make-handler {:nil-method [{} (constantly nil)]})
|
|
response (call handler
|
|
{:path-params {:method-name "nil-method"}})]
|
|
(t/is (= "application/octet-stream"
|
|
(get (::yres/headers response) "content-type")))))
|
|
|
|
;; --- RESPONSE: MAP -> 200
|
|
|
|
(t/deftest map-response-returns-200
|
|
(let [handler (make-handler {:map-method [{} (constantly {:foo "bar"})]})
|
|
response (call handler
|
|
{:path-params {:method-name "map-method"}})]
|
|
(t/is (= 200 (::yres/status response)))
|
|
(t/is (= {:foo "bar"} (::yres/body response)))))
|
|
|
|
;; --- RESPONSE: FALSY NON-NIL VALUES
|
|
|
|
(t/deftest false-response-returns-200
|
|
(let [handler (make-handler {:false-method [{} (constantly false)]})
|
|
response (call handler
|
|
{:path-params {:method-name "false-method"}})]
|
|
(t/is (= 200 (::yres/status response)))
|
|
(t/is (false? (::yres/body response)))))
|
|
|
|
(t/deftest zero-response-returns-200
|
|
(let [handler (make-handler {:zero-method [{} (constantly 0)]})
|
|
response (call handler
|
|
{:path-params {:method-name "zero-method"}})]
|
|
(t/is (= 200 (::yres/status response)))
|
|
(t/is (= 0 (::yres/body response)))))
|
|
|
|
(t/deftest empty-map-response-returns-200
|
|
(let [handler (make-handler {:empty-method [{} (constantly {})]})
|
|
response (call handler
|
|
{:path-params {:method-name "empty-method"}})]
|
|
(t/is (= 200 (::yres/status response)))
|
|
(t/is (= {} (::yres/body response)))))
|
|
|
|
;; --- RESPONSE: CUSTOM STATUS
|
|
|
|
(t/deftest custom-status-from-metadata
|
|
(let [result (with-meta {:ok true} {::http/status 201})
|
|
handler (make-handler {:created [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "created"}})]
|
|
(t/is (= 201 (::yres/status response)))))
|
|
|
|
;; --- RESPONSE: CUSTOM HEADERS
|
|
|
|
(t/deftest custom-headers-from-metadata
|
|
(let [result (with-meta {:ok true} {::http/headers {"x-custom" "val"}})
|
|
handler (make-handler {:hdr-method [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "hdr-method"}})]
|
|
(t/is (= "val" (get (::yres/headers response) "x-custom")))))
|
|
|
|
;; --- RESPONSE: CUSTOM STATUS AND HEADERS TOGETHER
|
|
|
|
(t/deftest custom-status-and-headers-from-metadata
|
|
(let [result (with-meta {:ok true}
|
|
{::http/status 202
|
|
::http/headers {"x-request-id" "req-123"}})
|
|
handler (make-handler {:both [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "both"}})]
|
|
(t/is (= 202 (::yres/status response)))
|
|
(t/is (= "req-123" (get (::yres/headers response) "x-request-id")))))
|
|
|
|
;; --- RESPONSE: FUNCTION
|
|
|
|
(t/deftest function-response-called-with-request
|
|
(let [captured (atom nil)
|
|
resp-fn (fn [req]
|
|
(reset! captured req)
|
|
{::yres/status 200
|
|
::yres/body "from-fn"})
|
|
handler (make-handler {:fn-method [{} (constantly resp-fn)]})
|
|
request (make-request {:path-params {:method-name "fn-method"}})
|
|
response (handler request)]
|
|
(t/is (= request @captured))
|
|
(t/is (= 200 (::yres/status response)))
|
|
(t/is (= "from-fn" (::yres/body response)))))
|
|
|
|
(t/deftest function-response-returning-nil
|
|
(let [handler (make-handler {:fn-nil [{} (constantly (fn [_] nil))]})
|
|
response (call handler
|
|
{:path-params {:method-name "fn-nil"}})]
|
|
(t/is (nil? (::yres/body response)))))
|
|
|
|
(t/deftest function-response-uses-return-value-as-is
|
|
(let [resp-fn (fn [_req]
|
|
{:result "ok"})
|
|
handler (make-handler {:fn-meta [{} (constantly resp-fn)]})
|
|
response (call handler
|
|
{:path-params {:method-name "fn-meta"}})]
|
|
;; When handler returns a fn, handle-response calls (fn request)
|
|
;; and uses the return value directly as the response.
|
|
(t/is (= {:result "ok"} response))))
|
|
|
|
;; --- RESPONSE: METADATA WRAPPER
|
|
|
|
(t/deftest metadata-wrapper-unwrapped-in-response
|
|
(let [wrapped (rph/wrap {:data "value"})
|
|
handler (make-handler {:wrap-method [{} (constantly wrapped)]})
|
|
response (call handler
|
|
{:path-params {:method-name "wrap-method"}})]
|
|
(t/is (= {:data "value"} (::yres/body response)))))
|
|
|
|
(t/deftest metadata-wrapper-preserves-metadata
|
|
(let [wrapped (-> (rph/wrap {:data "value"})
|
|
(vary-meta assoc ::http/status 201))
|
|
handler (make-handler {:wrap-meta [{} (constantly wrapped)]})
|
|
response (call handler
|
|
{:path-params {:method-name "wrap-meta"}})]
|
|
(t/is (= 201 (::yres/status response)))))
|
|
|
|
(t/deftest metadata-wrapper-with-custom-headers
|
|
(let [wrapped (-> (rph/wrap {:data "value"})
|
|
(vary-meta assoc ::http/headers {"x-wrap" "yes"}))
|
|
handler (make-handler {:wrap-hdr [{} (constantly wrapped)]})
|
|
response (call handler
|
|
{:path-params {:method-name "wrap-hdr"}})]
|
|
(t/is (= "yes" (get (::yres/headers response) "x-wrap")))))
|
|
|
|
;; --- RESPONSE: STREAM BODY
|
|
|
|
(t/deftest stream-response-default-content-type
|
|
(let [stream (yres/stream-body (fn [_response _output] nil))
|
|
handler (make-handler {:stream-method [{} (constantly stream)]})
|
|
response (call handler
|
|
{:path-params {:method-name "stream-method"}})]
|
|
(t/is (= "application/octet-stream"
|
|
(get (::yres/headers response) "content-type")))))
|
|
|
|
(t/deftest stream-response-preserves-existing-content-type
|
|
(let [stream (with-meta
|
|
(yres/stream-body (fn [_response _output] nil))
|
|
{::http/headers {"content-type" "text/plain"}})
|
|
handler (make-handler {:stream-ct [{} (constantly stream)]})
|
|
response (call handler
|
|
{:path-params {:method-name "stream-ct"}})]
|
|
(t/is (= "text/plain"
|
|
(get (::yres/headers response) "content-type")))))
|
|
|
|
;; --- RESPONSE TRANSFORMATION
|
|
|
|
(t/deftest response-transform-fns-applied
|
|
(let [transform (fn [_req resp]
|
|
(assoc-in resp [::yres/headers "x-transformed"] "yes"))
|
|
result (with-meta {:ok true}
|
|
{::rpc/response-transform-fns [transform]})
|
|
handler (make-handler {:transform [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "transform"}})]
|
|
(t/is (= "yes" (get (::yres/headers response) "x-transformed")))))
|
|
|
|
(t/deftest response-transform-fns-applied-in-order
|
|
(let [t1 (fn [_req resp]
|
|
(assoc-in resp [::yres/headers "x-step"] "1"))
|
|
t2 (fn [_req resp]
|
|
(update-in resp [::yres/headers "x-step"] str "-2"))
|
|
result (with-meta {:ok true}
|
|
{::rpc/response-transform-fns [t1 t2]})
|
|
handler (make-handler {:multi-transform [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "multi-transform"}})]
|
|
(t/is (= "1-2" (get (::yres/headers response) "x-step")))))
|
|
|
|
(t/deftest transform-fn-can-modify-status
|
|
(let [transform (fn [_req _resp]
|
|
{::yres/status 418
|
|
::yres/body "teapot"
|
|
::yres/headers {}})
|
|
result (with-meta {:ok true}
|
|
{::rpc/response-transform-fns [transform]})
|
|
handler (make-handler {:teapot [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "teapot"}})]
|
|
(t/is (= 418 (::yres/status response)))))
|
|
|
|
(t/deftest empty-transform-fns-no-op
|
|
(let [result (with-meta {:ok true}
|
|
{::rpc/response-transform-fns []})
|
|
handler (make-handler {:empty-t [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "empty-t"}})]
|
|
(t/is (= 200 (::yres/status response)))))
|
|
|
|
(t/deftest transform-receives-request-and-response
|
|
(let [captured-req (atom nil)
|
|
captured-resp (atom nil)
|
|
transform (fn [req resp]
|
|
(reset! captured-req req)
|
|
(reset! captured-resp resp)
|
|
resp)
|
|
result (with-meta {:ok true}
|
|
{::rpc/response-transform-fns [transform]})
|
|
handler (make-handler {:cap-t [{} (constantly result)]})
|
|
request (make-request {:path-params {:method-name "cap-t"}})
|
|
response (handler request)]
|
|
(t/is (= request @captured-req))
|
|
(t/is (some? @captured-resp))
|
|
(t/is (= 200 (::yres/status @captured-resp)))))
|
|
|
|
;; --- BEFORE-COMPLETE HOOKS
|
|
|
|
(t/deftest before-complete-hooks-called
|
|
(let [called? (atom false)
|
|
hook (fn [] (reset! called? true))
|
|
result (with-meta {:ok true}
|
|
{::rpc/before-complete-fns [hook]})
|
|
handler (make-handler {:hook-method [{} (constantly result)]})]
|
|
(call handler {:path-params {:method-name "hook-method"}})
|
|
(t/is (true? @called?))))
|
|
|
|
(t/deftest multiple-hooks-all-called
|
|
(let [called-a (atom false)
|
|
called-b (atom false)
|
|
hook-a (fn [] (reset! called-a true))
|
|
hook-b (fn [] (reset! called-b true))
|
|
result (with-meta {:ok true}
|
|
{::rpc/before-complete-fns [hook-a hook-b]})
|
|
handler (make-handler {:multi-hook [{} (constantly result)]})]
|
|
(call handler {:path-params {:method-name "multi-hook"}})
|
|
(t/is (true? @called-a))
|
|
(t/is (true? @called-b))))
|
|
|
|
(t/deftest before-complete-hook-errors-ignored
|
|
(let [hook (fn [] (throw (Exception. "hook error")))
|
|
result (with-meta {:ok true}
|
|
{::rpc/before-complete-fns [hook]})
|
|
handler (make-handler {:hook-err [{} (constantly result)]})]
|
|
(let [response (call handler
|
|
{:path-params {:method-name "hook-err"}})]
|
|
(t/is (= 200 (::yres/status response))))))
|
|
|
|
(t/deftest hook-error-does-not-prevent-other-hooks
|
|
(let [called? (atom false)
|
|
hook-a (fn [] (throw (Exception. "boom")))
|
|
hook-b (fn [] (reset! called? true))
|
|
result (with-meta {:ok true}
|
|
{::rpc/before-complete-fns [hook-a hook-b]})
|
|
handler (make-handler {:hook-chain [{} (constantly result)]})]
|
|
(call handler {:path-params {:method-name "hook-chain"}})
|
|
(t/is (true? @called?))))
|
|
|
|
(t/deftest empty-hooks-no-op
|
|
(let [result (with-meta {:ok true}
|
|
{::rpc/before-complete-fns []})
|
|
handler (make-handler {:empty-hooks [{} (constantly result)]})
|
|
response (call handler
|
|
{:path-params {:method-name "empty-hooks"}})]
|
|
(t/is (= 200 (::yres/status response)))))
|
|
|
|
;; --- METHODS MAP FORMAT
|
|
|
|
(t/deftest methods-map-uses-peek-to-extract-handler
|
|
(with-redefs [ct/now (constantly fixed-time)
|
|
uuid/next (constantly fixed-uuid)
|
|
inet/parse-request (constantly "127.0.0.1")]
|
|
(let [metadata {:some-meta "value"}
|
|
methods {:peek-test [metadata echo-handler]}
|
|
handler (rpc/make-rpc-handler methods)
|
|
data (call-body handler
|
|
{:path-params {:method-name "peek-test"}})]
|
|
(t/is (= "peek-test" (::rpc/handler-name data))))))
|
|
|
|
;; --- COND/*ENABLED* BINDING
|
|
|
|
(t/deftest cond-enabled-bound-to-true-inside-handler
|
|
(let [captured (atom nil)
|
|
handler (make-handler
|
|
{:cond-test [{} (fn [data]
|
|
(reset! captured cond/*enabled*)
|
|
data)]})]
|
|
(call handler {:path-params {:method-name "cond-test"}})
|
|
(t/is (true? @captured))))
|
|
|
|
(t/deftest cond-enabled-false-outside-handler
|
|
(let [handler (make-handler)]
|
|
(call handler)
|
|
(t/is (false? cond/*enabled*))))
|
|
|
|
(t/deftest cond-enabled-restored-after-handler-throws
|
|
(let [handler (make-handler
|
|
{:throwing [{} (fn [_]
|
|
(ex/raise :type :test :code :x))]})]
|
|
(try (call handler {:path-params {:method-name "throwing"}})
|
|
(catch Exception _))
|
|
(t/is (false? cond/*enabled*))))
|
|
|
|
;; --- HANDLER EXCEPTION PROPAGATION
|
|
|
|
(t/deftest handler-exception-propagates-with-type-and-code
|
|
(let [handler (make-handler
|
|
{:fail [{} (fn [_]
|
|
(ex/raise :type :test
|
|
:code :boom))]})
|
|
ex (call-ex handler {:path-params {:method-name "fail"}})]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :test))
|
|
(t/is (th/ex-of-code? ex :boom))))
|
|
|
|
;; --- DEFAULT HANDLER
|
|
|
|
(t/deftest default-handler-for-missing-method
|
|
(let [handler (rpc/make-rpc-handler {})
|
|
ex (try (handler (make-request
|
|
{:path-params {:method-name "nonexistent"}}))
|
|
(catch Exception e e))]
|
|
(t/is (some? ex))
|
|
(t/is (th/ex-of-type? ex :not-found))))
|
|
|
|
;; --- TOKEN TYPE RESTRICTIONS
|
|
|
|
(t/deftest mcp-token-authentication
|
|
(let [pid (uuid/custom 1 1)
|
|
method (fn [mdata]
|
|
(rpc/make-rpc-handler
|
|
{:test-method [mdata (partial (#'rpc/wrap-authentication
|
|
nil (fn [_ params] params) mdata)
|
|
nil)]}))
|
|
handler (method {::rpc/auth-type :token ::rpc/token-type "mcp"})]
|
|
(doseq [extras [{::session/profile-id pid}
|
|
{::actoken/profile-id pid}
|
|
{::actoken/profile-id pid ::actoken/type "personal"}]]
|
|
(t/is (th/ex-of-code? (call-ex handler {:extras extras}) :token-auth-required)))
|
|
(let [extras {::actoken/profile-id pid ::actoken/type "mcp"}]
|
|
(t/is (= "mcp" (::rpc/token-type (call-body handler {:extras extras}))))
|
|
(t/is (th/ex-of-code? (call-ex (method {}) {:extras extras}) :token-auth-required)))
|
|
(t/is (= pid (::rpc/profile-id
|
|
(call-body (method {}) {:extras {::actoken/profile-id pid
|
|
::actoken/type "personal"}}))))))
|
|
|
|
;; --- PARAMS VALIDATION: SERVER AUTH CONTEXT VS CLIENT PARAMS
|
|
|
|
(t/deftest client-qualified-keys-do-not-override-server-auth-context
|
|
(let [captured (atom nil)
|
|
inner (fn [_cfg params] (reset! captured params) {:ok true})
|
|
schema [:map [:name :string]]
|
|
wrap-validate @#'rpc/wrap-params-validation
|
|
wrapped (wrap-validate nil inner {::sm/params schema})
|
|
server-pid (uuid/custom 1 1)
|
|
attacker-pid (uuid/custom 2 2)
|
|
request {:params {:name "valid"
|
|
:app.rpc/profile-id attacker-pid
|
|
:app.rpc/auth-type :token
|
|
:app.rpc/token-perms #{"admin"}
|
|
:app.rpc/token-type "mcp"}}
|
|
params (with-meta {::rpc/profile-id server-pid
|
|
::rpc/auth-type :session}
|
|
{::http/request request})]
|
|
(wrapped nil params)
|
|
(t/is (= server-pid (::rpc/profile-id @captured)))
|
|
(t/is (= :session (::rpc/auth-type @captured)))
|
|
(t/is (not (contains? @captured ::rpc/token-perms)))
|
|
(t/is (not (contains? @captured ::rpc/token-type)))
|
|
(t/is (= "valid" (:name @captured)))))
|
|
|
|
;; --- RESPONSE: NIL BODY WITH CUSTOM HEADERS
|
|
|
|
(t/deftest nil-body-with-custom-headers
|
|
(let [wrapped (-> (rph/wrap nil)
|
|
(vary-meta assoc ::http/headers {"x-custom" "val"}))
|
|
handler (make-handler {:nil-hdr [{} (constantly wrapped)]})
|
|
response (call handler
|
|
{:path-params {:method-name "nil-hdr"}})]
|
|
(t/is (= 204 (::yres/status response)))
|
|
(t/is (= "val" (get (::yres/headers response) "x-custom")))))
|