diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index c3057118d5..f6b23d0387 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -9,7 +9,7 @@ - LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow. - Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI. - Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. -- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/http-storage-filedata-subtleties`. +- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. ## Permission model @@ -37,4 +37,4 @@ - Enable LDAP login locally with frontend flag `enable-login-with-ldap`; the devenv includes a configured test LDAP service. - OIDC testing requires external provider app credentials plus matching backend/frontend config. - Backend domain tests usually live under `backend/test/backend_tests/rpc/commands/*_test.clj` or nearby backend test namespaces. Use focused `clojure -M:dev:test --focus ...` from `backend/` when possible. -- For auth/session or HTTP behavior, combine backend tests with the HTTP/session notes in `mem:backend/http-storage-filedata-subtleties` because RPC-level tests may not exercise cookie/header transforms. +- For auth/session or HTTP behavior, combine backend tests with the HTTP/session notes in `mem:backend/subtleties` because RPC-level tests may not exercise cookie/header transforms. diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 44dc8f3b75..da67831d4d 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -4,9 +4,8 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m ## Focused memories -- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties` +- Cross-cutting backend subtleties (RPC, DB, workers, cron, HTTP/sessions, storage, file data): `mem:backend/subtleties` - Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`. -- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`. - Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment` - Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains` - Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`. @@ -44,13 +43,13 @@ Database migrations live in `backend/src/app/migrations/`; pure SQL migrations a For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`). -For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`. +For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/subtleties`. ## Background tasks A task handler is an Integrant component with `ig/assert-key`, `ig/expand-key`, and `ig/init-key`, returning the function run by the worker. New tasks also need wiring in `app.main`: handler config, worker registry entry, and cron entry if scheduled. -For worker dispatch, cron, retry semantics, deduplication, and queue internals: `mem:backend/rpc-db-worker-subtleties`. +For worker dispatch, cron, retry semantics, deduplication, and queue internals: `mem:backend/subtleties`. ## REPL diff --git a/.serena/memories/backend/graph-experiment.md b/.serena/memories/backend/graph-experiment.md index a7d382b91f..3c95560e01 100644 --- a/.serena/memories/backend/graph-experiment.md +++ b/.serena/memories/backend/graph-experiment.md @@ -13,8 +13,7 @@ ## Memory Links - Read `mem:backend/core` for backend architecture, HTTP routes, DB rules, and test commands. -- Read `mem:backend/rpc-db-worker-subtleties` for RPC and message bus behavior. -- Read `mem:backend/http-storage-filedata-subtleties` for file data loading and realization. +- Read `mem:backend/subtleties` for RPC and message bus behavior, and for file data loading and realization. - Read `mem:common/changes-architecture` for the change record vocabulary. - Read `mem:frontend/routing-app-shell-subtleties` for the existing notification WebSocket. - Read `mem:prod-infra/core` for Redis or Valkey message bus topology. diff --git a/.serena/memories/backend/http-storage-filedata-subtleties.md b/.serena/memories/backend/http-storage-filedata-subtleties.md deleted file mode 100644 index 188ece7277..0000000000 --- a/.serena/memories/backend/http-storage-filedata-subtleties.md +++ /dev/null @@ -1,28 +0,0 @@ -# Backend HTTP, Storage, Media, and File Data Subtleties - -## Config and HTTP/session middleware - -- `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them. -- `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag. -- The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags. -- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. -- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling. -- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`; JWTs with `kid=1` and `ver=1` are decoded as v1 session tokens, otherwise they are treated as legacy tokens. -- Shared-key auth requires `x-shared-key` as ` ` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. -- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. DB sessions support both legacy string ids and v2 UUID session ids. -- Session cookies are renewed when using a legacy string id or when `modified-at` is older than the renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config. - -## Storage and media - -- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. -- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. -- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. -- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. -- Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts. - -## File data persistence - -- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. -- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. -- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. -- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. diff --git a/.serena/memories/backend/rpc-db-worker-subtleties.md b/.serena/memories/backend/rpc-db-worker-subtleties.md deleted file mode 100644 index 2ba9389310..0000000000 --- a/.serena/memories/backend/rpc-db-worker-subtleties.md +++ /dev/null @@ -1,26 +0,0 @@ -# Backend RPC/DB/Worker Subtleties - -## RPC exposure and wrappers - -- RPC commands are discovered from vars created by `app.util.services/defmethod`; adding a command namespace is not enough unless `backend/src/app/rpc.clj` includes it in `resolve-methods`. -- `GET`/`HEAD` RPC calls are only allowed for method names starting with `get-`. Other methods are method-not-allowed even if they are read-only internally. -- RPC auth defaults to enabled. Public endpoints must set `::auth false` metadata explicitly. -- The wrapper stack does auth before params validation, then auditing/rate/concurrency/metrics/retry/condition handling, with DB transaction handling inside that stack. `::db/transaction` metadata controls transaction wrapping. -- Params with `::sm/params` are decoded/conformed through the JSON transformer and successful IObj results get `:encode/json` metadata. Legacy spec conforming only applies when no Malli params schema exists. -- Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set. - -## DB helpers - -- Most `app.db` helpers accept a pool, connection, or map containing `::db/pool` / `::db/conn`; preserve that convention in shared code. -- `db/tx-run!` uses `next.jdbc.transaction/*nested-tx* :ignore`: nested transaction calls reuse the outer transaction, not a savepoint. Use explicit savepoints when nested rollback semantics matter. -- `db/run!` opens/reuses one connection but does not create a transaction. -- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Worker task props use Transit and are decoded with `decode-transit-pgobject`. -- Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash. - -## Workers and cron - -- Task queues are tenant-prefixed. Submit dedupe only removes not-yet-due `new` tasks with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. -- The dispatcher selects `new`/`retry` tasks with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes Redis payload `[id scheduled-at]`. The runner skips Redis messages whose scheduled timestamp no longer matches DB state. -- Lost `scheduled` tasks are rescheduled after about 5 minutes; `running` tasks older than about 24 hours are marked failed as orphans. -- A task handler that is missing or returns an invalid result currently defaults to completed after warning. Throwing with `ex-data :type ::retry` controls retry behavior; `:strategy ::noop` retries without incrementing retry count. -- Cron jobs lock their `scheduled_task` row with `FOR UPDATE SKIP LOCKED`, disable statement/idle-in-transaction timeouts locally, and reschedule themselves in `finally` unless interrupted. Worker, dispatcher, and cron components do not start when the DB pool is read-only. \ No newline at end of file diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md new file mode 100644 index 0000000000..c00117666a --- /dev/null +++ b/.serena/memories/backend/subtleties.md @@ -0,0 +1,53 @@ +# Backend Subtleties + +## RPC exposure and wrappers + +- RPC commands are discovered from vars created by `app.util.services/defmethod`; adding a command namespace is not enough unless `backend/src/app/rpc.clj` includes it in `resolve-methods`. +- `GET`/`HEAD` RPC calls are only allowed for method names starting with `get-`. Other methods are method-not-allowed even if they are read-only internally. +- RPC auth defaults to enabled. Public endpoints must set `::auth false` metadata explicitly. +- The wrapper stack does auth before params validation, then auditing/rate/concurrency/metrics/retry/condition handling, with DB transaction handling inside that stack. `::db/transaction` metadata controls transaction wrapping. +- Params with `::sm/params` are decoded/conformed through the JSON transformer and successful IObj results get `:encode/json` metadata. Legacy spec conforming only applies when no Malli params schema exists. Client params are stripped of qualified keys (`d/without-qualified`) before merging with the server auth context, so request bodies cannot override `::profile-id`, `::auth-type`, or `::token-perms`. +- Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set. + +## DB helpers + +- Most `app.db` helpers accept a pool, connection, or map containing `::db/pool` / `::db/conn`; preserve that convention in shared code. +- `db/tx-run!` uses `next.jdbc.transaction/*nested-tx* :ignore`: nested transaction calls reuse the outer transaction, not a savepoint. Use explicit savepoints when nested rollback semantics matter. +- `db/run!` opens/reuses one connection but does not create a transaction. +- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Worker task props use Transit and are decoded with `decode-transit-pgobject`. +- Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash. + +## Workers and cron + +- Task queues are tenant-prefixed. Submit dedupe only removes not-yet-due `new` tasks with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work. +- The dispatcher selects `new`/`retry` tasks with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes Redis payload `[id scheduled-at]`. The runner skips Redis messages whose scheduled timestamp no longer matches DB state. +- Lost `scheduled` tasks are rescheduled after about 5 minutes; `running` tasks older than about 24 hours are marked failed as orphans. +- A task handler that is missing or returns an invalid result currently defaults to completed after warning. Throwing with `ex-data :type ::retry` controls retry behavior; `:strategy ::noop` retries without incrementing retry count. +- Cron jobs lock their `scheduled_task` row with `FOR UPDATE SKIP LOCKED`, disable statement/idle-in-transaction timeouts locally, and reschedule themselves in `finally` unless interrupted. Worker, dispatcher, and cron components do not start when the DB pool is read-only. + +## Config and HTTP/session middleware + +- `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them. +- `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag. +- The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags. +- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. +- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling. +- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`; JWTs with `kid=1` and `ver=1` are decoded as v1 session tokens, otherwise they are treated as legacy tokens. +- Shared-key auth requires `x-shared-key` as ` ` and stores the lowercased key id on the request. If no shared keys are configured it always rejects. +- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. DB sessions support both legacy string ids and v2 UUID session ids. +- Session cookies are renewed when using a legacy string id or when `modified-at` is older than the renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config. + +## Storage and media + +- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. +- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. +- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. +- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. +- Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts. + +## File data persistence + +- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. +- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. +- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. +- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. \ No newline at end of file diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 6c7b1a480a..a558315376 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -92,12 +92,12 @@ (handle-response-transformation request mdata) (handle-before-comple-hook mdata)))) -(defn- make-rpc-handler +(defn make-rpc-handler "Ring handler that dispatches cmd requests and convert between internal async flow into ring async flow." [methods] (let [methods (update-vals methods peek)] - (fn [{:keys [params path-params method] :as request}] + (fn [{:keys [path-params method] :as request}] (let [handler-name (:method-name path-params) etag (yreq/get-header request "if-none-match") session-id (yreq/get-header request "x-session-id") @@ -111,7 +111,7 @@ ip-addr (inet/parse-request request) - data (-> params + data (-> {} (assoc ::handler-name handler-name) (assoc ::ip-addr ip-addr) (assoc ::request-at (ct/now)) @@ -218,17 +218,6 @@ f) f)) -(defn- wrap-spec-conform - [_ f mdata] - ;; NOTE: skip spec conform operation on rpc methods that already - ;; uses malli validation mechanism. - (if (contains? mdata ::sm/params) - f - (if-let [spec (ex/ignoring (s/spec (::sv/spec mdata)))] - (fn [cfg params] - (f cfg (us/conform spec params))) - f))) - (defn- wrap-params-validation [_ f mdata] (if-let [schema (::sm/params mdata)] @@ -237,16 +226,15 @@ decode (sm/decoder schema sm/json-transformer) encode (sm/encoder schema sm/json-transformer)] (fn [cfg params] - (let [params (decode params)] - (if (validate params) - (let [result (f cfg params)] + (let [request-params (-> params meta ::http/request :params decode)] + (if (validate request-params) + (let [result (f cfg (merge params (d/without-qualified request-params)))] (if (instance? clojure.lang.IObj result) (vary-meta result assoc :encode/json encode) result)) - (let [params (d/without-qualified params)] - (ex/raise :type :validation - :code :params-validation - ::sm/explain (explain params))))))) + (ex/raise :type :validation + :code :params-validation + ::sm/explain (explain request-params)))))) f)) @@ -346,10 +334,9 @@ (wrap-metrics cfg $ mdata) (rlimit/wrap cfg $ mdata) (wrap-audit cfg $ mdata) - (wrap-spec-conform cfg $ mdata) + (wrap-nitrate-sso cfg $ mdata) (wrap-params-validation cfg $ mdata) - (wrap-authentication cfg $ mdata) - (wrap-nitrate-sso cfg $ mdata))) + (wrap-authentication cfg $ mdata))) (defn- wrap-management [cfg f mdata] @@ -359,12 +346,9 @@ (climit/wrap cfg $ mdata) (wrap-metrics cfg $ mdata) (wrap-audit cfg $ mdata) - (wrap-spec-conform cfg $ mdata) + (wrap-nitrate-sso cfg $ mdata) (wrap-params-validation cfg $ mdata) - (wrap-authentication cfg $ mdata) - (wrap-nitrate-sso cfg $ mdata))) - - + (wrap-authentication cfg $ mdata))) (defn- process-method [cfg wrap-fn [f mdata]] diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index a1537dc501..b9bff3cbd7 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -135,7 +135,7 @@ (def ^:private schema:import-binfile [:and - [:map {:title "import-binfile"} + [:map {:title "import-binfile" :closed true} [:name [:or [:string {:max 250}] [:map-of ::sm/uuid [:string {:max 250}]]]] [:project-id ::sm/uuid] diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 37abedc5a8..8f26f95a5c 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -49,7 +49,7 @@ [promesa.core :as p] [promesa.exec :as px] [ring.core.protocols :as rcp] - [yetti.request :as yrq] + [yetti.request :as yreq] [yetti.response :as yres]) (:import java.io.PipedInputStream @@ -379,6 +379,83 @@ {:error (handle-error e#) :result nil}))) +(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 options. Called with keyword + arguments, a single options map, or no arguments; every key is + optional and 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))) + +(defn- prepare-rpc-params + [data] + (let [params (reduce-kv (fn [params k v] + (if (qualified-keyword? k) + (assoc params k v) + params)) + {} + (dissoc data ::type)) + request (-> (make-dummy-request) + (assoc :params (d/without-qualified data)))] + + (-> params + (assoc :app.rpc/request-at (ct/now)) + (with-meta {:app.http/request request})))) + (defn command! [{:keys [::type] :as data}] (let [[mdata method-fn] (get-in *system* [:app.rpc/methods type])] @@ -387,10 +464,8 @@ :code :rpc-method-not-found :hint (str/ffmt "rpc method '%' not found" (name type)))) - ;; (app.common.pprint/pprint (:app.rpc/methods *system*)) - (try-on! (method-fn (-> data - (dissoc ::type) - (assoc :app.rpc/request-at (ct/now))))))) + (let [params (prepare-rpc-params data)] + (try-on! (method-fn params))))) (defn management-command! [{:keys [::type] :as data}] @@ -399,9 +474,8 @@ (ex/raise :type :assertion :code :rpc-method-not-found :hint (str/ffmt "management rpc method '%' not found" (name type)))) - (try-on! (method-fn (-> data - (dissoc ::type) - (assoc :app.rpc/request-at (ct/now))))))) + (let [params (prepare-rpc-params data)] + (try-on! (method-fn params))))) (defn run-task! ([name] @@ -627,66 +701,3 @@ (parse-sse (slurp' input))) (finally (.close input))))) - -;; ---- Dummy Request Helpers - -(defrecord DummyRequest [headers cookies method body-stream - remote-addr server-name server-port - scheme protocol path query ssl-client-cert] - yrq/IRequestCookies - (get-cookie [_ name] - {:value (get cookies name)}) - - yrq/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))) diff --git a/backend/test/backend_tests/helpers_test.clj b/backend/test/backend_tests/helpers_test.clj new file mode 100644 index 0000000000..4aef915a91 --- /dev/null +++ b/backend/test/backend_tests/helpers_test.clj @@ -0,0 +1,92 @@ +;; 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.helpers-test + "Focused unit tests for the shared test request constructor. + + `make-dummy-request` is called with three styles across the suite + (no arguments, a single options map, and keyword arguments). These + tests pin that contract so a future redefinition or reordering of + the helper fails loudly here instead of as a flood of unrelated + test errors." + (:require + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.request :as yreq])) + +(defn- utf8-bytes + ^bytes + [^String s] + (.getBytes s "UTF-8")) + +;; --- DEFAULTS (ZERO ARGUMENTS) + +(t/deftest make-dummy-request-zero-args-uses-defaults + (let [req (th/make-dummy-request)] + (t/is (instance? backend_tests.helpers.DummyRequest req)) + (t/is (= :get (yreq/method req))) + (t/is (= "/test" (yreq/path req))) + (t/is (= :http (yreq/scheme req))) + (t/is (= "HTTP/1.1" (yreq/protocol req))) + (t/is (= "127.0.0.1" (yreq/remote-addr req))) + (t/is (= "test" (yreq/server-name req))) + (t/is (= 0 (yreq/server-port req))) + (t/is (nil? (yreq/query req))) + (t/is (nil? (yreq/body req))))) + +;; --- SINGLE OPTIONS MAP + +(t/deftest make-dummy-request-single-map-arg-overrides-given-keys + (let [req (th/make-dummy-request {:method :post + :headers {"x-test" "yes"} + :path "/custom" + :query "a=1"})] + (t/is (= :post (yreq/method req))) + (t/is (= "yes" (yreq/get-header req "x-test"))) + (t/is (= "/custom" (yreq/path req))) + (t/is (= "a=1" (yreq/query req))))) + +(t/deftest make-dummy-request-single-map-arg-keeps-defaults-for-omitted-keys + (let [req (th/make-dummy-request {:method :post})] + (t/is (= "/test" (yreq/path req))) + (t/is (= :http (yreq/scheme req))) + (t/is (= "test" (yreq/server-name req))))) + +;; --- KEYWORD ARGUMENTS + +(t/deftest make-dummy-request-keyword-args-override-given-keys + (let [req (th/make-dummy-request :method :delete + :path "/by-id" + :server-port 8080)] + (t/is (= :delete (yreq/method req))) + (t/is (= "/by-id" (yreq/path req))) + (t/is (= 8080 (yreq/server-port req))))) + +;; --- BODY + +(t/deftest make-dummy-request-string-body-bytes-wrapped-in-stream + (let [req (th/make-dummy-request :method :post :body-bytes "hello")] + (t/is (instance? java.io.ByteArrayInputStream (yreq/body req))) + (t/is (= "hello" (slurp (yreq/body req)))))) + +(t/deftest make-dummy-request-byte-array-body-bytes-wrapped-in-stream + (let [req (th/make-dummy-request :body-bytes (utf8-bytes "raw"))] + (t/is (= "raw" (slurp (yreq/body req)))))) + +(t/deftest make-dummy-request-body-stream-takes-precedence-over-body-bytes + (let [stream (java.io.ByteArrayInputStream. (utf8-bytes "stream")) + req (th/make-dummy-request :body-stream stream :body-bytes "bytes")] + (t/is (identical? stream (yreq/body req))) + (t/is (= "stream" (slurp (yreq/body req)))))) + +;; --- COOKIES + +(t/deftest make-dummy-request-cookie-readable-via-get-cookie + (let [req (th/make-dummy-request {:cookies {"auth-token" "abc"}})] + (t/is (= {:value "abc"} (yreq/get-cookie req "auth-token"))))) + +(t/deftest make-dummy-request-missing-cookie-returns-nil-value + (t/is (= {:value nil} (yreq/get-cookie (th/make-dummy-request) "missing")))) diff --git a/backend/test/backend_tests/rpc_auth_override_test.clj b/backend/test/backend_tests/rpc_auth_override_test.clj new file mode 100644 index 0000000000..3b6fbeeecf --- /dev/null +++ b/backend/test/backend_tests/rpc_auth_override_test.clj @@ -0,0 +1,37 @@ +;; 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-auth-override-test + (:require + [app.common.time :as ct] + [app.http :as-alias http] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; --- RPC: client params cannot override the server auth context +;; +;; Exercises the real wrapped :get-profile method. The handler only +;; reads ::rpc/profile-id, and its params schema ([:map]) accepts +;; anything, so before the fix a session authenticated as A asking +;; for B's profile-id received B's profile (id + email). + +(t/deftest get-profile-ignores-client-supplied-profile-id + (let [attacker (th/create-profile* 1) + victim (th/create-profile* 2) + [_ method-fn] (get-in th/*system* [:app.rpc/methods :get-profile]) + ;; Simulates what wrap-parse-request leaves in + ;; (:params request) after a body carrying a qualified key. + body {:app.rpc/profile-id (:id victim)} + params (with-meta {::rpc/profile-id (:id attacker) + ::rpc/request-at (ct/now)} + {::http/request {:params body}}) + result (method-fn params)] + (t/is (= (:id attacker) (:id result))) + (t/is (= (:email attacker) (:email result))))) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index 879696fb0e..b1a144ab70 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -11,7 +11,8 @@ [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] [backend-tests.helpers :as th] - [clojure.test :as t])) + [clojure.test :as t] + [datoteka.fs :as fs])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -21,16 +22,24 @@ (let [schema @#'binfile/schema:import-binfile validator (sm/lazy-validator schema) - valid-params {:name "test" - :project-id (uuid/random) - :version 3 - :upload-id (uuid/random)}] + valid-params + {:name "test" + :project-id (uuid/random) + :version 3 + :upload-id (uuid/random)} + + params-with-file-id + (assoc valid-params :file-id (uuid/random))] (t/is (true? (validator valid-params)) "params without file-id should be valid") (t/is (not (contains? (sm/keys (second schema)) :file-id)) - "file-id should not be a declared parameter"))) + "file-id should not be a declared parameter") + + ;; Params with file-id should fail (schema closed) + (t/is (false? (validator params-with-file-id)) + "params with file-id should be rejected"))) (t/deftest import-binfile-schema-rejects-unsupported-version ;; T1-N2-03: version parameter should be restricted to supported values (1 or 3) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 936e354910..fd64185a2a 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -1318,8 +1318,11 @@ body)) (let [[_ method-fn] (get-in th/*system* [:app.rpc/methods :get-nitrate-activation-code-request]) - result (method-fn {::rpc/profile-id (:id profile) - ::rpc/request-at now}) + request (assoc (th/make-dummy-request) :params {}) + params (-> {::rpc/profile-id (:id profile) + ::rpc/request-at now} + (with-meta {:app.http/request request})) + result (method-fn params) headers (::http/headers (meta result))] (t/is (rph/wrapped? result)) (t/is (= "text/plain" (get headers "content-type"))) diff --git a/backend/test/backend_tests/rpc_test.clj b/backend/test/backend_tests/rpc_test.clj new file mode 100644 index 0000000000..233ffdd8e2 --- /dev/null +++ b/backend/test/backend_tests/rpc_test.clj @@ -0,0 +1,937 @@ +;; 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)))) + +;; --- 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"}}} + 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 (= "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"))))) diff --git a/backend/test/e2e/auth-flow.test.mjs b/backend/test/e2e/auth-flow.test.mjs index 7f9fbb34fd..ae72917cda 100644 --- a/backend/test/e2e/auth-flow.test.mjs +++ b/backend/test/e2e/auth-flow.test.mjs @@ -10,7 +10,7 @@ import { rpcPost } from "./helpers/client.mjs"; describe("auth flow", () => { it("creates a demo profile", async () => { const { email, password } = await createDemoProfile(); - assert.match(email, /^demo-.*\.demo@example\.com$/); + assert.match(email, /^demo-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}@demo\.example\.com$/); assert.ok(password.length > 0); }); diff --git a/backend/test/e2e/helpers/client.mjs b/backend/test/e2e/helpers/client.mjs index 1bfe8750d6..5484813ab1 100644 --- a/backend/test/e2e/helpers/client.mjs +++ b/backend/test/e2e/helpers/client.mjs @@ -25,10 +25,10 @@ export function extractCookie(setCookieHeader, name = "auth-token") { return match ? match[1] : null; } -export async function rpcPost(method, body = {}, { cookieToken, accessToken } = {}) { +export async function rpcPost(method, body = {}, { cookieToken, accessToken, contentType, accept, query } = {}) { const headers = { - "Content-Type": "application/json", - Accept: "application/json", + "Content-Type": contentType || "application/json", + Accept: accept || "application/json", }; if (cookieToken) { headers.Cookie = `auth-token=${cookieToken}`; @@ -37,10 +37,14 @@ export async function rpcPost(method, body = {}, { cookieToken, accessToken } = headers.Authorization = `Token ${accessToken}`; } - const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, { + const url = query + ? `${config.baseUrl}/api/main/methods/${method}?${query}` + : `${config.baseUrl}/api/main/methods/${method}`; + + const response = await fetch(url, { method: "POST", headers, - body: JSON.stringify(body), + body: typeof body === "string" ? body : JSON.stringify(body), }); return parseResponse(response); diff --git a/backend/test/e2e/rpc-auth-override.test.mjs b/backend/test/e2e/rpc-auth-override.test.mjs new file mode 100644 index 0000000000..991e2b1472 --- /dev/null +++ b/backend/test/e2e/rpc-auth-override.test.mjs @@ -0,0 +1,47 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +describe("RPC auth context override", () => { + it("transit body cannot override server profile-id (get-profile)", async () => { + // ATTACK SCENARIO: + // 1. Attacker A logs in and gets a session cookie (server binds + // profile-id A to the request). + // 2. A POSTs a transit body smuggling the namespaced key + // :app.rpc/profile-id with victim B's id. Transit is a + // superset of JSON, so the body is hand-written, no client + // library needed: {"~:app.rpc/profile-id": "~u"}. + // 3. get-profile only reads ::rpc/profile-id and its schema + // ([:map]) accepts anything. + // + // EXPECTED BEHAVIOR AFTER FIX: the response holds A's profile. + // Before the fix it held B's profile (id + email leak). + const attacker = await setupTestProfile(); + const victim = await setupTestProfile(); + + const transitBody = + `{"~:app.rpc/profile-id": "~u${victim.profile.id}"}`; + + const res = await rpcPost("get-profile", transitBody, { + cookieToken: attacker.cookie, + contentType: "application/transit+json", + accept: "application/transit+json", + query: "transit_verbose=1", + }); + + assert.equal(res.status, 200); + // Verbose transit map, dependency-free parse: + // {"~:id":"~u","~:email":"...",...} + const data = JSON.parse(res.body); + assert.equal( + data["~:id"], + `~u${attacker.profile.id}`, + "profile must come from the session, not the request body" + ); + assert.ok( + !res.body.includes(victim.profile.id), + "victim id must not leak into the response" + ); + }); +});