mirror of
https://github.com/penpot/penpot.git
synced 2026-09-23 20:36:15 +00:00
✨ Enable closed schemas for RPC methods (#11136)
* ✨ Enable closed schemas for RPC methods * 🐛 Fix duplicate make-dummy-request test helper definition The branch added a variadic DummyRequest/make-dummy-request pair but left the pre-existing single-arg definition in place. Because it was loaded last, zero-arg (make-dummy-request) calls added by prepare-rpc-params and rpc-nitrate-test threw ArityException, which broke 384 tests and caused 14 downstream assertion failures. Remove the stale duplicate so the variadic definition is the only one, and drop the now-unused yrq alias and duplicate yres alias. AI-assisted-by: deepseek-v4.1-flash * ✨ Add focused tests for make-dummy-request helper Pin the call contract of make-dummy-request, which the suite uses in three styles: no arguments, a single options map, and keyword arguments. The helper's redefinition shadowing in 8ca95adb98 was only caught by a full-suite run with hundreds of unrelated errors; these tests fail locally in a focused --focus run. Cover the zero-arg defaults, map and keyword overrides, the :body-bytes -> ByteArrayInputStream wrapping, :body-stream precedence, and cookie readback. Also clarify the docstring to list all supported call styles. AI-assisted-by: deepseek-v4.1-flash * 🚑 Prevent RPC client params from overriding auth context Strip qualified keys from decoded request params before merging them with the server-built auth context, so transit bodies can no longer override ::profile-id, ::auth-type or ::token-perms. Adds a regression test proving the override and the fix. AI-assisted-by: muse-spark-1.3-contributor * 📚 Merge backend subtleties memories under generic name Rename rpc-db-worker-subtleties to subtleties and fold in http-storage-filedata-subtleties, so the name no longer enumerates topics. Update all mem: references accordingly. AI-assisted-by: muse-spark-1.3-contributor * ✨ Add realistic tests for RPC auth override Cover the transit wire vector and the real wrapped :get-profile method with two database profiles, proving a session cannot read another profile by smuggling :app.rpc/profile-id in the body. AI-assisted-by: muse-spark-1.3-contributor * ✨ Add e2e test for RPC auth context override Parametrize rpcPost with contentType, accept and query so e2e can send hand-written transit bodies without new dependencies. The new test proves a transit-smuggled :app.rpc/profile-id no longer overrides the session in get-profile. Also fix the demo email assertion in auth-flow to the current uuid format. AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
parent
476e25e371
commit
2255266d45
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 `<key-id> <key>` 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.
|
||||
@ -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.
|
||||
53
.serena/memories/backend/subtleties.md
Normal file
53
.serena/memories/backend/subtleties.md
Normal file
@ -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 `<key-id> <key>` 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.
|
||||
@ -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]]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))
|
||||
|
||||
92
backend/test/backend_tests/helpers_test.clj
Normal file
92
backend/test/backend_tests/helpers_test.clj
Normal file
@ -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"))))
|
||||
37
backend/test/backend_tests/rpc_auth_override_test.clj
Normal file
37
backend/test/backend_tests/rpc_auth_override_test.clj
Normal file
@ -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)))))
|
||||
@ -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)
|
||||
|
||||
@ -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")))
|
||||
|
||||
937
backend/test/backend_tests/rpc_test.clj
Normal file
937
backend/test/backend_tests/rpc_test.clj
Normal file
@ -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")))))
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
47
backend/test/e2e/rpc-auth-override.test.mjs
Normal file
47
backend/test/e2e/rpc-auth-override.test.mjs
Normal file
@ -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<uuid>"}.
|
||||
// 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<uuid>","~: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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user