Dr. Dominik Jain c0f0b14540
🎉 Add direct MCP feedback channel via dedicated tool (#12551)
* ♻️ 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
2026-10-29 10:35:46 +01:00

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")))))