add auth + remote nrepl
CI / Test + coverage (push) Successful in 39s

This commit is contained in:
2026-09-19 07:21:19 -09:00
parent 3b0b608d7d
commit d9499594bf
21 changed files with 932 additions and 14 deletions
+1
View File
@@ -4,3 +4,4 @@ target
node_modules node_modules
.shadow-cljs .shadow-cljs
resources/public/js resources/public/js
.env
+33
View File
@@ -0,0 +1,33 @@
# Copy to .env and fill in: cp .env-template .env
# script/server loads .env automatically; with Docker pass it via `docker run --env-file .env`
# (so keep values unquoted). .env is git-ignored and excluded from the image; never commit it.
# --- Server ---------------------------------------------------------------
# PORT=3000
# --- Gitea OAuth login ------------------------------------------------------
# Register an OAuth2 application in Gitea (Settings > Applications) as a confidential
# client, with the redirect URI <BASE_URL>/auth/callback
GITEA_URL=https://gitea.example.com
GITEA_CLIENT_ID=
GITEA_CLIENT_SECRET=
# Public URL of this app, as the browser reaches it. Use https in production: that also
# marks the session cookie Secure.
BASE_URL=http://localhost:3000
# Signs and encrypts session cookies. Generate with: openssl rand -hex 32
# Unset = a random key per start, so everyone is logged out on every restart.
SESSION_SECRET=
# --- Admin API --------------------------------------------------------------
# Comma-separated Gitea usernames allowed to use the admin API (POST /nrepl).
# Empty = nobody can.
ADMIN_USERS=
# --- nREPL debug server (POST /nrepl) -----------------------------------------
# nREPL has no authentication and evaluates arbitrary code, so it listens on loopback
# only by default. Changing NREPL_BIND to 0.0.0.0 exposes it to the network: only do that
# behind a firewall or an SSH tunnel.
# NREPL_PORT=7888
# NREPL_BIND=127.0.0.1
+2
View File
@@ -5,3 +5,5 @@ node_modules
# Compiled UI (script/ui-build) # Compiled UI (script/ui-build)
resources/public/js resources/public/js
.nrepl-port .nrepl-port
# Local secrets (copy .env-template to .env)
.env
+49 -1
View File
@@ -45,12 +45,56 @@ docker run --rm -p 3000:3000 tbd
| `GET` | `/api/add?x=2&y=3` | Adds two numbers, returns EDN: `{:result 5}` (400 if either is missing or not a number) | | `GET` | `/api/add?x=2&y=3` | Adds two numbers, returns EDN: `{:result 5}` (400 if either is missing or not a number) |
| `GET` | `/api-docs` | Swagger UI | | `GET` | `/api-docs` | Swagger UI |
| `GET` | `/swagger.json` | OpenAPI/Swagger spec | | `GET` | `/swagger.json` | OpenAPI/Swagger spec |
| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) |
| `GET` | `/auth/callback` | OAuth redirect target: completes the login, sets the session cookie, redirects to `/` |
| `POST` | `/auth/logout` | Ends the session |
| `GET` | `/auth/me` | The logged-in user, `{:login "alice" :id 7 :admin? true}` (401 if not logged in) |
| `POST` | `/nrepl` | **Admin only.** Body `{"enabled": true}` (JSON or EDN) starts the nREPL debug server, `false` stops it; returns `{:enabled true :bind "127.0.0.1" :port 7888}` |
```bash ```bash
curl localhost:3000/api/ping # pong curl localhost:3000/api/ping # pong
curl 'localhost:3000/api/add?x=2&y=3' # {:result 5} curl 'localhost:3000/api/add?x=2&y=3' # {:result 5}
``` ```
## Authentication and the admin API
Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance.
Anyone with a Gitea account can log in, but routes marked `:admin? true` (currently
`POST /nrepl`) also require the user to be on the `ADMIN_USERS` allowlist: **401** without a
login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin.
1. In Gitea (Settings > Applications) create an OAuth2 application, a confidential client, with
the redirect URI `<BASE_URL>/auth/callback`.
2. `cp .env-template .env` and fill it in. `script/server` loads `.env`; for Docker use
`docker run --env-file .env`. `.env` is git-ignored and kept out of the image.
| Variable | Purpose |
|----------|---------|
| `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` | The Gitea OAuth2 application. Without all three (and `BASE_URL`), `/auth/login` answers 503 |
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure` |
| `ADMIN_USERS` | Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API |
| `SESSION_SECRET` | Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart |
| `NREPL_PORT`, `NREPL_BIND` | Where the debug nREPL listens (default `7888` on `127.0.0.1`) |
Sessions live in an encrypted, signed, `HttpOnly`, `SameSite=Lax` cookie and expire after 8 hours
(enforced server-side). The cookie is stateless, so `POST /auth/logout` clears it in the browser but
can't revoke a copy someone already captured; it just expires on its own.
### The nREPL endpoint
```bash
# after logging in in a browser, copy the `tbd-session` cookie value:
curl -XPOST localhost:3000/nrepl -H 'Cookie: tbd-session=...' \
-H 'Content-Type: application/json' -d '{"enabled": true}'
clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.2.0"}}}' -M -m nrepl.cmdline --connect --host 127.0.0.1 --port 7888
```
nREPL has no authentication and evaluates arbitrary code, so enabling it is effectively remote code
execution for whoever can reach the port. It therefore binds to loopback by default; reach it through
an SSH tunnel (or `docker exec`) rather than setting `NREPL_BIND=0.0.0.0`. Each enable/disable is
logged with the admin's Gitea username. It stays on until an admin disables it (or the server
restarts), so switch it off when you're done.
## UI ## UI
`ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via `ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via
@@ -109,7 +153,10 @@ src/tbd/
core.clj Router, Ring app, and server startup core.clj Router, Ring app, and server startup
<group>/routes.clj Each route group (health, math, ...) owns its route subtree <group>/routes.clj Each route group (health, math, ...) owns its route subtree
<group>/core.clj Business logic for the group, with no HTTP concerns <group>/core.clj Business logic for the group, with no HTTP concerns
auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes
nrepl/ On-demand nREPL server (core) and the admin-only POST /nrepl route
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored) resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
test/ Unit tests test/ Unit tests
integration-test/ Dockerized integration tests and coverage harness integration-test/ Dockerized integration tests and coverage harness
@@ -117,4 +164,5 @@ script/ Bash entry points (server, ui-build, test, coverage, repl,
``` ```
To add a new group, create `tbd.<group>.routes` with a `routes` fn that returns a reitit route To add a new group, create `tbd.<group>.routes` with a `routes` fn that returns a reitit route
vector, then add it to the router in `src/tbd/core.clj`. vector, then add it to the router in `src/tbd/core.clj`. To make a route admin-only, put
`:admin? true` in its route data.
+4 -3
View File
@@ -3,13 +3,14 @@
{org.clojure/clojure {:mvn/version "1.12.0"} {org.clojure/clojure {:mvn/version "1.12.0"}
ring/ring-jetty-adapter {:mvn/version "1.12.2"} ring/ring-jetty-adapter {:mvn/version "1.12.2"}
ring/ring-defaults {:mvn/version "0.4.0"} ring/ring-defaults {:mvn/version "0.4.0"}
metosin/reitit {:mvn/version "0.7.2"}} metosin/reitit {:mvn/version "0.7.2"}
;; Runtime dep: POST /nrepl starts an nREPL server on demand (see tbd.nrepl.core).
nrepl/nrepl {:mvn/version "1.2.0"}}
:aliases :aliases
{:dev {:dev
{:extra-deps {:extra-deps
{nrepl/nrepl {:mvn/version "1.2.0"} {cider/cider-nrepl {:mvn/version "0.48.0"}
cider/cider-nrepl {:mvn/version "0.48.0"}
org.babashka/http-client {:mvn/version "0.4.25"}}} org.babashka/http-client {:mvn/version "0.4.25"}}}
:test :test
{:extra-paths ["test"] {:extra-paths ["test"]
+7
View File
@@ -61,6 +61,13 @@ bb integration-test/integration_test.bb http://localhost:3000 --watch --interval
| 8 | `GET /api-docs` | 200, `text/html` | | 8 | `GET /api-docs` | 200, `text/html` |
| 9 | `GET /api/add?x=2&y=3` | 200, EDN `{:result 5}` | | 9 | `GET /api/add?x=2&y=3` | 200, EDN `{:result 5}` |
| 10 | `GET /api/add` with bad params | 400 for a missing or non-numeric param | | 10 | `GET /api/add` with bad params | 400 for a missing or non-numeric param |
| 11 | `GET /auth/login` | 302 to Gitea's authorize URL with the client id and a state; `HttpOnly` cookie |
| 12 | `GET /auth/callback` with no login in progress | 400 |
| 13 | `GET /auth/me` without a session | 401 |
| 14 | `POST /nrepl` without a session | 401, even with an invalid body |
The app container gets dummy Gitea settings from `docker-compose.yml`, so tests 11-14 cover the
unauthenticated surface only; completing a login needs a real Gitea (the unit tests stub one).
Test 4 needs the compiled UI, which the Docker image includes (`script/integration-test` Test 4 needs the compiled UI, which the Docker image includes (`script/integration-test`
builds it). Against a locally running server, run `script/ui-build` first. builds it). Against a locally running server, run `script/ui-build` first.
+7
View File
@@ -10,6 +10,13 @@ services:
- apparmor:unconfined - apparmor:unconfined
environment: environment:
- PORT=${TBD_PORT:-3000} - PORT=${TBD_PORT:-3000}
# Dummy OAuth settings so /auth/login can be exercised; there's no Gitea to complete a login.
- BASE_URL=http://tbd-app:${TBD_PORT:-3000}
- GITEA_URL=http://gitea.invalid
- GITEA_CLIENT_ID=integration-client
- GITEA_CLIENT_SECRET=integration-secret
- ADMIN_USERS=integration-admin
- SESSION_SECRET=integration-session-secret
# No container healthcheck: healthchecks run via `docker exec`, which hits the # No container healthcheck: healthchecks run via `docker exec`, which hits the
# same LXC/AppArmor runc bug (opencontainers/runc#5007) as image builds and # same LXC/AppArmor runc bug (opencontainers/runc#5007) as image builds and
# always fails here. integration_test.bb polls the server until it's ready. # always fails here. integration_test.bb polls the server until it's ready.
+35
View File
@@ -18,6 +18,17 @@
(defn head* [base-url] (defn head* [base-url]
(http/head base-url {:throw false})) (http/head base-url {:throw false}))
;; The login redirect points at Gitea; we want to see it, not follow it.
(def no-redirect-client (http/client {:follow-redirects :never}))
(defn get-no-redirect* [url]
(http/get url {:throw false :client no-redirect-client}))
(defn post-json* [url body]
(http/post url {:throw false
:headers {"Content-Type" "application/json"}
:body body}))
(defn wait-for-server [{:keys [base-url max-retries retry-delay-ms]}] (defn wait-for-server [{:keys [base-url max-retries retry-delay-ms]}]
(println (str "=== Waiting for server at " base-url " ===")) (println (str "=== Waiting for server at " base-url " ==="))
(loop [i 1] (loop [i 1]
@@ -122,6 +133,30 @@
(assert-eq "Non-numeric x" 400 (:status (get* (str base-url "/api/add?x=abc&y=3")))) (assert-eq "Non-numeric x" 400 (:status (get* (str base-url "/api/add?x=abc&y=3"))))
(println) (println)
(println "Test 11: GET /auth/login redirects to Gitea's authorize page")
(let [resp (get-no-redirect* (str base-url "/auth/login"))
location (get (:headers resp) "location" "")]
(assert-eq "HTTP status" 302 (:status resp))
(assert-contains "Location" "/login/oauth/authorize?" location)
(assert-contains "Location has the client id" "client_id=integration-client" location)
(assert-contains "Location has a state" "state=" location)
(assert-contains "Session cookie is HttpOnly" "HttpOnly" (str (get (:headers resp) "set-cookie" ""))))
(println)
(println "Test 12: GET /auth/callback with no login in progress returns 400")
(assert-eq "HTTP status" 400 (:status (get-no-redirect* (str base-url "/auth/callback?code=x&state=y"))))
(println)
(println "Test 13: GET /auth/me without a session returns 401")
(assert-eq "HTTP status" 401 (:status (get* (str base-url "/auth/me"))))
(println)
(println "Test 14: POST /nrepl without a session returns 401 and starts nothing")
(assert-eq "Valid body" 401 (:status (post-json* (str base-url "/nrepl") "{\"enabled\":true}")))
(assert-eq "Invalid body (auth is checked first)" 401 (:status (post-json* (str base-url "/nrepl") "nope")))
(assert-eq "A query-string admin flag is no bypass" 401 (:status (post-json* (str base-url "/nrepl?admin=true") "{\"enabled\":true}")))
(println)
(println "================================") (println "================================")
(println (format " Results: %d passed, %d failed" @pass @fail)) (println (format " Results: %d passed, %d failed" @pass @fail))
(println "================================") (println "================================")
+8 -1
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start the server. Usage: script/server [port] # Start the server. Usage: script/server [port]
# The port defaults to $PORT, then 3000. # The port defaults to $PORT, then 3000. Settings in .env (see .env-template) are loaded first.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# Load local settings (Gitea OAuth, admin users, ...) from .env; see .env-template.
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
port=${1:-${PORT:-3000}} port=${1:-${PORT:-3000}}
if [[ ! $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then if [[ ! $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "usage: script/server [port] (port must be 1-65535, got '$port')" >&2 echo "usage: script/server [port] (port must be 1-65535, got '$port')" >&2
+130
View File
@@ -0,0 +1,130 @@
(ns tbd.auth.core
"Gitea OAuth2 (authorization-code flow) and the admin allowlist. No Ring here; the
routes and middleware own that."
(:require [clojure.string :as str]
[muuntaja.core :as m])
(:import (java.net URI URLEncoder)
(java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers HttpRequest$BodyPublishers)
(java.nio.charset StandardCharsets)
(java.security MessageDigest SecureRandom)
(java.time Duration)
(java.util Base64)))
(def ^:private http-client
(delay (-> (HttpClient/newBuilder) (.connectTimeout (Duration/ofSeconds 5)) .build)))
(defn java-http
"Default `:http` fn: {:method :get|:post, :url, :headers, :body} -> {:status, :body}.
Configs can swap it out, which is how the tests stub Gitea."
[{:keys [method url headers body]}]
(let [builder (-> (HttpRequest/newBuilder (URI/create url)) (.timeout (Duration/ofSeconds 10)))]
(doseq [[k v] headers] (.header builder k v))
(case method
:get (.GET builder)
:post (.POST builder (HttpRequest$BodyPublishers/ofString body)))
(let [resp (.send ^HttpClient @http-client (.build builder) (HttpResponse$BodyHandlers/ofString))]
{:status (.statusCode resp) :body (.body resp)})))
(defn- env-val [env k]
(let [v (get env k)]
(when-not (str/blank? v) (str/trim v))))
(defn- strip-trailing-slashes [s]
(some-> s (str/replace #"/+$" "")))
(defn env-config
"Auth config from environment variables:
GITEA_URL, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET the Gitea OAuth2 application
BASE_URL public URL of this app; <BASE_URL>/auth/callback is the redirect URI to register in Gitea
ADMIN_USERS comma-separated Gitea usernames allowed to use the admin API (none by default)
SESSION_SECRET signs/encrypts session cookies (random per start when unset, so logins don't survive restarts)"
([] (env-config (System/getenv)))
([env]
(let [base-url (strip-trailing-slashes (env-val env "BASE_URL"))]
{:gitea-url (strip-trailing-slashes (env-val env "GITEA_URL"))
:client-id (env-val env "GITEA_CLIENT_ID")
:client-secret (env-val env "GITEA_CLIENT_SECRET")
:redirect-uri (some-> base-url (str "/auth/callback"))
:secure-cookies? (boolean (some-> base-url (str/starts-with? "https://")))
:admin-users (->> (str/split (or (env-val env "ADMIN_USERS") "") #",")
(map str/trim)
(remove str/blank?)
(map str/lower-case)
set)
:session-secret (env-val env "SESSION_SECRET")
:http java-http})))
(defn configured?
"True when everything needed to run the OAuth flow is set."
[config]
(every? config [:gitea-url :client-id :client-secret :redirect-uri]))
(defn admin?
"Gitea usernames are case-insensitive, so the allowlist is compared lower-cased."
[config login]
(contains? (:admin-users config) (str/lower-case login)))
(defn session-key
"Cookie stores need a 16-byte key; derive it from the secret, or make a throwaway one."
[secret]
(if secret
(byte-array (take 16 (.digest (MessageDigest/getInstance "SHA-256")
(.getBytes ^String secret StandardCharsets/UTF_8))))
(let [k (byte-array 16)]
(.nextBytes (SecureRandom.) k)
k)))
(defn new-state
"Random OAuth `state`, tying the callback to the browser that started the login."
[]
(let [bytes (byte-array 32)]
(.nextBytes (SecureRandom.) bytes)
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) bytes)))
(defn state-matches? [expected actual]
(and (string? expected)
(string? actual)
(MessageDigest/isEqual (.getBytes ^String expected StandardCharsets/UTF_8)
(.getBytes ^String actual StandardCharsets/UTF_8))))
(defn- form-encode [params]
(str/join "&" (map (fn [[k v]]
(str (URLEncoder/encode (name k) StandardCharsets/UTF_8) "="
(URLEncoder/encode (str v) StandardCharsets/UTF_8)))
params)))
(defn authorize-url [{:keys [gitea-url client-id redirect-uri]} state]
(str gitea-url "/login/oauth/authorize?"
(form-encode {:client_id client-id
:redirect_uri redirect-uri
:response_type "code"
:state state})))
(defn- parse-json [body]
(m/decode "application/json" body))
(defn exchange-code
"Trades the authorization code for an access token; nil if Gitea refuses it."
[{:keys [gitea-url client-id client-secret redirect-uri http]} code]
(let [{:keys [status body]} (http {:method :post
:url (str gitea-url "/login/oauth/access_token")
:headers {"Content-Type" "application/x-www-form-urlencoded"
"Accept" "application/json"}
:body (form-encode {:grant_type "authorization_code"
:client_id client-id
:client_secret client-secret
:redirect_uri redirect-uri
:code code})})]
(when (= 200 status)
(:access_token (parse-json body)))))
(defn fetch-user
"The Gitea user behind an access token as {:login :id}; nil if Gitea refuses the token."
[{:keys [gitea-url http]} token]
(let [{:keys [status body]} (http {:method :get
:url (str gitea-url "/api/v1/user")
:headers {"Authorization" (str "Bearer " token)
"Accept" "application/json"}})]
(when (= 200 status)
(let [{:keys [login id]} (parse-json body)]
(when login {:login login :id id})))))
+46
View File
@@ -0,0 +1,46 @@
(ns tbd.auth.middleware
(:require [ring.middleware.session :refer [wrap-session]]
[ring.middleware.session.cookie :refer [cookie-store]]
[tbd.auth.core :as auth]
[tbd.http :refer [edn-response]]))
(def session-ttl-ms (* 8 60 60 1000))
(defn session-middleware
"Cookie-backed sessions (encrypted and signed, so nothing is stored server-side). Lax, not
Strict: the OAuth callback is a cross-site redirect from Gitea and must still carry the
cookie that holds the login `state`."
[config]
(let [store (cookie-store {:key (auth/session-key (:session-secret config))})]
(fn [handler]
(wrap-session handler {:store store
:cookie-name "tbd-session"
:cookie-attrs {:http-only true
:same-site :lax
:secure (:secure-cookies? config)
:max-age (quot session-ttl-ms 1000)}}))))
(defn current-user
"The logged-in user from the session, or nil when there is none or it has expired. The
cookie is only a bearer of `:expires-at`, so the server enforces the lifetime here."
([req] (current-user req (System/currentTimeMillis)))
([req now]
(let [{:keys [expires-at] :as user} (get-in req [:session :user])]
(when (and user expires-at (< now expires-at))
user))))
(defn admin-middleware
"Reitit middleware for routes whose data has `:admin? true`: 401 without a login, 403 when
the user isn't on the ADMIN_USERS allowlist. Passes the user on as `:user`. Mount it ahead
of the parsing/coercion middleware so unauthenticated callers never reach them."
[config]
{:name ::admin
:compile (fn [route-data _opts]
(when (:admin? route-data)
(fn [handler]
(fn [req]
(if-let [user (current-user req)]
(if (auth/admin? config (:login user))
(handler (assoc req :user user))
(edn-response 403 {:error "admin access required"}))
(edn-response 401 {:error "login required"}))))))})
+63
View File
@@ -0,0 +1,63 @@
(ns tbd.auth.routes
(:require [ring.util.response :as response]
[tbd.auth.core :as auth]
[tbd.auth.middleware :as mw]
[tbd.http :refer [edn-response]]))
(def ^:private not-configured
(edn-response 503 {:error "OAuth is not configured (set GITEA_URL, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, BASE_URL)"}))
(defn- login-handler [config]
(fn [{:keys [session]}]
(if-not (auth/configured? config)
not-configured
(let [state (auth/new-state)]
(-> (response/redirect (auth/authorize-url config state))
(assoc :session (assoc session :oauth-state state)))))))
(defn- callback-handler [config]
(fn [{:keys [session parameters]}]
(let [{:keys [code state error]} (:query parameters)]
(cond
(not (auth/configured? config)) not-configured
(not (auth/state-matches? (:oauth-state session) state)) (edn-response 400 {:error "invalid OAuth state"})
error (edn-response 403 {:error "login was denied"})
(nil? code) (edn-response 400 {:error "missing authorization code"})
:else
(try
(let [user (some->> (auth/exchange-code config code) (auth/fetch-user config))]
(if user
;; A fresh session (not the old one plus a user) so nothing from before login carries over.
(-> (response/redirect "/")
(assoc :session {:user (assoc user :expires-at (+ (System/currentTimeMillis)
mw/session-ttl-ms))}))
(edn-response 401 {:error "login failed"})))
(catch Exception e
(println "[tbd] Gitea login failed:" (.getMessage e))
(edn-response 502 {:error "could not complete login with Gitea"})))))))
(defn- me-handler [config]
(fn [req]
(if-let [{:keys [login id]} (mw/current-user req)]
(edn-response 200 {:login login :id id :admin? (auth/admin? config login)})
(edn-response 401 {:error "login required"}))))
(defn routes [config]
["/auth"
["/login" {:get {:tags ["auth"]
:summary "Start the Gitea OAuth login (redirects to Gitea)"
:handler (login-handler config)}}]
["/callback" {:get {:tags ["auth"]
:summary "OAuth redirect target; completes the login and redirects to /"
:parameters {:query [:map
[:code {:optional true} :string]
[:state {:optional true} :string]
[:error {:optional true} :string]]}
:handler (callback-handler config)}}]
["/logout" {:post {:tags ["auth"]
:summary "End the session"
:handler (fn [_req]
(assoc (edn-response 200 {:logged-out true}) :session nil))}}]
["/me" {:get {:tags ["auth"]
:summary "The logged-in user (401 if not logged in)"
:handler (me-handler config)}}]])
+46 -9
View File
@@ -13,8 +13,14 @@
[reitit.ring.middleware.parameters :as parameters] [reitit.ring.middleware.parameters :as parameters]
[muuntaja.core :as m] [muuntaja.core :as m]
[ring.util.response :as response] [ring.util.response :as response]
[tbd.auth.core :as auth]
[tbd.auth.middleware :as auth-mw]
[tbd.auth.routes :as auth-routes]
[tbd.health.routes :as health] [tbd.health.routes :as health]
[tbd.math.routes :as math])) [tbd.http :as http]
[tbd.math.routes :as math]
[tbd.nrepl.core :as nrepl]
[tbd.nrepl.routes :as nrepl-routes]))
(defn- swagger-handler (defn- swagger-handler
"reitit emits `\"definitions\": null` when no route declares a schema, which "reitit emits `\"definitions\": null` when no route declares a schema, which
@@ -41,14 +47,22 @@
(-> (response/resource-response "public/index.html") (-> (response/resource-response "public/index.html")
(response/content-type "text/html; charset=utf-8"))) (response/content-type "text/html; charset=utf-8")))
(defn- decode-error-handler
"A request body that isn't valid for its Content-Type -> 400 in the same EDN shape."
[_e _req]
{:status 400
:headers {"Content-Type" "application/edn; charset=utf-8"}
:body (pr-str {:error "malformed request body"})})
(def ^:private exception-middleware (def ^:private exception-middleware
(exception/create-exception-middleware (exception/create-exception-middleware
(assoc exception/default-handlers (assoc exception/default-handlers
:reitit.coercion/request-coercion coercion-error-handler))) :reitit.coercion/request-coercion coercion-error-handler
:muuntaja/decode decode-error-handler)))
;; Each logical group (health, users, orders, ...) owns a `tbd.<group>.routes` ;; Each logical group (health, users, orders, ...) owns a `tbd.<group>.routes`
;; namespace that returns its own route subtree; they're concatenated here. ;; namespace that returns its own route subtree; they're concatenated here.
(def router (defn make-router [{:keys [auth nrepl]}]
(ring/router (ring/router
[["/swagger.json" [["/swagger.json"
{:get {:no-doc true {:get {:no-doc true
@@ -57,23 +71,40 @@
["/" {:get {:no-doc true ["/" {:get {:no-doc true
:handler index-handler}}] :handler index-handler}}]
(health/routes) (health/routes)
(math/routes)] (math/routes)
(auth-routes/routes auth)
(nrepl-routes/routes nrepl)]
{:data {:muuntaja m/instance {:data {:muuntaja m/instance
:coercion malli/coercion :coercion malli/coercion
:middleware [parameters/parameters-middleware ;; Admin auth goes first so unauthenticated callers to `:admin? true` routes
muuntaja-middleware/format-middleware ;; are turned away before any body parsing or coercion runs. The muuntaja
;; middleware is split around the exception middleware (rather than the combined
;; format-middleware) so a malformed request body is caught and answered with a 400.
:middleware [(auth-mw/admin-middleware auth)
parameters/parameters-middleware
muuntaja-middleware/format-negotiate-middleware
muuntaja-middleware/format-response-middleware
exception-middleware exception-middleware
muuntaja-middleware/format-request-middleware
coercion/coerce-request-middleware]}})) coercion/coerce-request-middleware]}}))
(def app (defn make-app [{:keys [auth] :as config}]
(-> (ring/ring-handler (-> (ring/ring-handler
router (make-router config)
(ring/routes (ring/routes
(swagger-ui/create-swagger-ui-handler {:path "/api-docs"}) (swagger-ui/create-swagger-ui-handler {:path "/api-docs"})
;; Compiled UI (script/ui-build writes resources/public/js). ;; Compiled UI (script/ui-build writes resources/public/js).
(ring/create-resource-handler {:path "/js" :root "public/js"}) (ring/create-resource-handler {:path "/js" :root "public/js"})
(ring/create-default-handler))) (ring/create-default-handler)))
wrap-head)) wrap-head
http/wrap-browser-viewable-edn
((auth-mw/session-middleware auth))))
(def config
{:auth (auth/env-config)
:nrepl (nrepl/env-config)})
(def app (make-app config))
(defn add-shutdown-hook [server] (defn add-shutdown-hook [server]
(.addShutdownHook (.addShutdownHook
@@ -84,6 +115,12 @@
(let [port (Integer/parseInt (str (or (System/getenv "PORT") (let [port (Integer/parseInt (str (or (System/getenv "PORT")
(get opts :port 3000))))] (get opts :port 3000))))]
(println (format "[tbd] starting server on port %d..." port)) (println (format "[tbd] starting server on port %d..." port))
(when-not (auth/configured? (:auth config))
(println "[tbd] WARNING: Gitea OAuth is not configured; login and the admin API are unavailable"))
(when (empty? (get-in config [:auth :admin-users]))
(println "[tbd] WARNING: ADMIN_USERS is empty; nobody can use the admin API"))
(when-not (get-in config [:auth :session-secret])
(println "[tbd] WARNING: SESSION_SECRET is not set; logins won't survive a restart"))
(try (try
(let [server (jetty/run-jetty (wrap-defaults app api-defaults) (let [server (jetty/run-jetty (wrap-defaults app api-defaults)
{:port port {:port port
+20
View File
@@ -0,0 +1,20 @@
(ns tbd.http)
(defn edn-response [status body]
{:status status
:headers {"Content-Type" "application/edn; charset=utf-8"}
:body (pr-str body)})
(defn wrap-browser-viewable-edn
"Browsers download `application/edn` instead of showing it. When the request comes from a
browser navigation (Accept includes text/html), relabel EDN responses as text/plain so they
render inline. Programmatic clients (fetch, curl, ...) still get `application/edn`."
[handler]
(fn [req]
(let [resp (handler req)
accept (get-in req [:headers "accept"] "")
content-type (get-in resp [:headers "Content-Type"] "")]
(if (and (re-find #"text/html" accept)
(.startsWith ^String content-type "application/edn"))
(assoc-in resp [:headers "Content-Type"] "text/plain; charset=utf-8")
resp))))
+45
View File
@@ -0,0 +1,45 @@
(ns tbd.nrepl.core
"The on-demand nREPL debug server. No HTTP here."
(:require [clojure.string :as str]
[nrepl.server :as nrepl]))
(def default-port 7888)
;; nREPL has no authentication and evaluates arbitrary code, so it only listens on loopback
;; unless the operator explicitly opens it up.
(def default-bind "127.0.0.1")
(defn env-config
"NREPL_PORT (default 7888) and NREPL_BIND (default 127.0.0.1)."
([] (env-config (System/getenv)))
([env]
(let [port (get env "NREPL_PORT")
bind (get env "NREPL_BIND")]
{:port (if (str/blank? port) default-port (parse-long (str/trim port)))
:bind (if (str/blank? bind) default-bind (str/trim bind))})))
(defonce ^:private state (atom nil))
(defn status []
(if-let [{:keys [port bind]} @state]
{:enabled true :bind bind :port port}
{:enabled false}))
(defn enable!
"Starts the server if it isn't running (a no-op otherwise); returns the status. Port 0
picks a free port, and the status reports the one actually bound."
[{:keys [port bind]}]
(locking state
(when-not @state
(let [server (nrepl/start-server :port port :bind bind)]
(reset! state {:server server :bind bind :port (:port server)})))
(status)))
(defn disable!
"Stops the server if it's running; returns the status."
[]
(locking state
(when-let [{:keys [server]} @state]
(nrepl/stop-server server)
(reset! state nil))
(status)))
+24
View File
@@ -0,0 +1,24 @@
(ns tbd.nrepl.routes
(:require [tbd.http :refer [edn-response]]
[tbd.nrepl.core :as nrepl]))
(defn- toggle-handler [config]
(fn [{{{:keys [enabled]} :body} :parameters {:keys [login]} :user}]
(try
(let [status (if enabled (nrepl/enable! config) (nrepl/disable!))]
(println (format "[tbd] nREPL %s by %s%s"
(if enabled "enabled" "disabled")
login
(if enabled (format " on %s:%d" (:bind status) (:port status)) "")))
(edn-response 200 status))
(catch Exception e
(println "[tbd] nREPL toggle failed:" (.getMessage e))
(edn-response 500 {:error (str "could not change nREPL state: " (.getMessage e))})))))
(defn routes [config]
["/nrepl"
{:post {:tags ["admin"]
:summary "Enable or disable the nREPL debug server (admin only)"
:admin? true
:parameters {:body [:map [:enabled {:description "true starts the server, false stops it"} :boolean]]}
:handler (toggle-handler config)}}])
+111
View File
@@ -0,0 +1,111 @@
(ns tbd.auth.core-test
(:require [clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[tbd.auth.core :as auth]))
(def full-env
{"GITEA_URL" "https://git.example.com/"
"GITEA_CLIENT_ID" "cid"
"GITEA_CLIENT_SECRET" "sec"
"BASE_URL" "https://tbd.example.com/"
"ADMIN_USERS" " Alice, bob ,,"
"SESSION_SECRET" "s3cret"})
(deftest env-config-test
(testing "a complete environment"
(let [config (auth/env-config full-env)]
(is (auth/configured? config))
(is (= "https://git.example.com" (:gitea-url config)) "trailing slash is dropped")
(is (= "https://tbd.example.com/auth/callback" (:redirect-uri config)))
(is (= #{"alice" "bob"} (:admin-users config)) "trimmed, lower-cased, blanks dropped")
(is (true? (:secure-cookies? config)) "https base URL => Secure cookies")
(is (= "s3cret" (:session-secret config)))))
(testing "plain http base URL does not mark cookies Secure"
(is (false? (:secure-cookies? (auth/env-config (assoc full-env "BASE_URL" "http://localhost:3000"))))))
(testing "an empty environment is unconfigured and has no admins"
(let [config (auth/env-config {})]
(is (not (auth/configured? config)))
(is (= #{} (:admin-users config)))
(is (nil? (:session-secret config)))))
(testing "blank values count as unset"
(is (not (auth/configured? (auth/env-config (assoc full-env "GITEA_CLIENT_SECRET" " "))))))
(testing "each of the four settings is required"
(doseq [k ["GITEA_URL" "GITEA_CLIENT_ID" "GITEA_CLIENT_SECRET" "BASE_URL"]]
(is (not (auth/configured? (auth/env-config (dissoc full-env k)))) k))))
(deftest admin-test
(let [config (auth/env-config full-env)]
(is (auth/admin? config "alice"))
(is (auth/admin? config "ALICE") "Gitea usernames are case-insensitive")
(is (not (auth/admin? config "mallory")))
(is (not (auth/admin? (auth/env-config {}) "alice")) "an empty allowlist admits nobody")))
(deftest session-key-test
(is (= 16 (count (auth/session-key "s3cret"))))
(is (= (seq (auth/session-key "s3cret")) (seq (auth/session-key "s3cret"))))
(is (not= (seq (auth/session-key "a")) (seq (auth/session-key "b"))))
(testing "without a secret each call yields a fresh random key"
(is (= 16 (count (auth/session-key nil))))
(is (not= (seq (auth/session-key nil)) (seq (auth/session-key nil))))))
(deftest state-test
(let [a (auth/new-state)]
(is (re-matches #"[A-Za-z0-9_-]{43}" a))
(is (not= a (auth/new-state))))
(is (auth/state-matches? "abc" "abc"))
(is (not (auth/state-matches? "abc" "abd")))
(is (not (auth/state-matches? "abc" "abcd")))
(testing "a missing state never matches, even against another missing one"
(is (not (auth/state-matches? nil nil)))
(is (not (auth/state-matches? "abc" nil)))
(is (not (auth/state-matches? nil "abc")))))
(deftest authorize-url-test
(let [url (auth/authorize-url (auth/env-config full-env) "st ate")]
(is (str/starts-with? url "https://git.example.com/login/oauth/authorize?"))
(is (str/includes? url "client_id=cid"))
(is (str/includes? url "response_type=code"))
(is (str/includes? url "redirect_uri=https%3A%2F%2Ftbd.example.com%2Fauth%2Fcallback"))
(is (str/includes? url "state=st+ate") "values are form-encoded")))
(defn- recording-http
"An :http stub that records requests and answers with `responses` in order."
[calls & responses]
(let [responses (atom responses)]
(fn [req]
(swap! calls conj req)
(let [[r & more] @responses]
(reset! responses more)
(if (instance? Throwable r) (throw r) r)))))
(deftest exchange-code-test
(let [calls (atom [])
config (assoc (auth/env-config full-env)
:http (recording-http calls
{:status 200 :body "{\"access_token\":\"tok\",\"token_type\":\"bearer\"}"}
{:status 400 :body "{\"error\":\"invalid_grant\"}"}))]
(is (= "tok" (auth/exchange-code config "the-code")))
(let [{:keys [method url headers body]} (first @calls)]
(is (= :post method))
(is (= "https://git.example.com/login/oauth/access_token" url))
(is (= "application/json" (get headers "Accept")))
(is (= "application/x-www-form-urlencoded" (get headers "Content-Type")))
(doseq [part ["grant_type=authorization_code" "client_id=cid" "client_secret=sec" "code=the-code"
"redirect_uri=https%3A%2F%2Ftbd.example.com%2Fauth%2Fcallback"]]
(is (str/includes? body part) part)))
(is (nil? (auth/exchange-code config "used-code")) "a refused code yields nil")))
(deftest fetch-user-test
(let [calls (atom [])
config (assoc (auth/env-config full-env)
:http (recording-http calls
{:status 200 :body "{\"login\":\"alice\",\"id\":7,\"email\":\"a@x\"}"}
{:status 401 :body "{}"}
{:status 200 :body "{\"id\":7}"}))]
(is (= {:login "alice" :id 7} (auth/fetch-user config "tok")))
(let [{:keys [method url headers]} (first @calls)]
(is (= :get method))
(is (= "https://git.example.com/api/v1/user" url))
(is (= "Bearer tok" (get headers "Authorization"))))
(is (nil? (auth/fetch-user config "bad")) "a refused token yields nil")
(is (nil? (auth/fetch-user config "odd")) "a response without a login yields nil")))
+158
View File
@@ -0,0 +1,158 @@
(ns tbd.auth.routes-test
(:require [clojure.edn :as edn]
[clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[tbd.auth.middleware :as mw]
[tbd.core :as core]))
(defn gitea-stub
"Stands in for Gitea: any code is exchanged for a token that resolves to `login`."
[login]
(fn [{:keys [url]}]
(cond
(str/ends-with? url "/login/oauth/access_token") {:status 200 :body "{\"access_token\":\"tok\"}"}
(str/ends-with? url "/api/v1/user") {:status 200 :body (format "{\"login\":\"%s\",\"id\":7}" login)})))
(defn make-app [http & {:as overrides}]
(core/make-app {:auth (merge {:gitea-url "https://gitea.test"
:client-id "cid"
:client-secret "sec"
:redirect-uri "http://tbd.test/auth/callback"
:admin-users #{"alice"}
:session-secret "test-secret"
:secure-cookies? false
:http http}
overrides)
:nrepl {:port 0 :bind "127.0.0.1"}}))
(defn request [app method uri & {:keys [cookie query]}]
(let [resp (app (cond-> {:request-method method :uri uri :query-string query :headers {}}
cookie (assoc-in [:headers "cookie"] cookie)))]
(cond-> resp
(and (string? (:body resp))
(str/includes? (get-in resp [:headers "Content-Type"] "") "edn"))
(update :body edn/read-string))))
(defn set-cookie
"The `name=value` pair from a response's Set-Cookie header."
[resp]
(let [h (get-in resp [:headers "Set-Cookie"])]
(first (str/split (first (if (string? h) [h] h)) #";"))))
(defn location-state [resp]
(second (re-find #"[?&]state=([^&]+)" (get-in resp [:headers "Location"]))))
(defn login!
"Runs the whole login dance and returns the session cookie."
[app]
(let [start (request app :get "/auth/login")
done (request app :get "/auth/callback"
:cookie (set-cookie start)
:query (str "code=abc&state=" (location-state start)))]
(set-cookie done)))
(deftest login-redirect-test
(let [resp (request (make-app (gitea-stub "alice")) :get "/auth/login")
location (get-in resp [:headers "Location"])]
(is (= 302 (:status resp)))
(is (str/starts-with? location "https://gitea.test/login/oauth/authorize?"))
(is (str/includes? location "client_id=cid"))
(is (some? (location-state resp)))
(let [cookie (get-in resp [:headers "Set-Cookie"])
cookie (first (if (string? cookie) [cookie] cookie))]
(is (str/starts-with? cookie "tbd-session="))
(is (re-find #"(?i)HttpOnly" cookie))
(is (re-find #"(?i)SameSite=Lax" cookie))
(is (not (re-find #"(?i);\s*Secure" cookie)) "http base URL, so no Secure flag"))))
(deftest secure-cookie-test
(let [resp (request (make-app (gitea-stub "alice") :secure-cookies? true) :get "/auth/login")
cookie (first (let [h (get-in resp [:headers "Set-Cookie"])] (if (string? h) [h] h)))]
(is (re-find #"(?i);\s*Secure" cookie))))
(deftest unconfigured-test
(let [app (make-app (gitea-stub "alice") :gitea-url nil)]
(is (= 503 (:status (request app :get "/auth/login"))))
(is (= 503 (:status (request app :get "/auth/callback" :query "code=x&state=y"))))))
(deftest callback-test
(let [app (make-app (gitea-stub "Alice"))
start (request app :get "/auth/login")
cookie (set-cookie start)
state (location-state start)]
(testing "a successful login redirects home and sets a session"
(let [resp (request app :get "/auth/callback" :cookie cookie :query (str "code=abc&state=" state))]
(is (= 302 (:status resp)))
(is (= "/" (get-in resp [:headers "Location"])))
(is (some? (set-cookie resp)))))
(testing "no session cookie (the callback wasn't preceded by a login here)"
(is (= 400 (:status (request app :get "/auth/callback" :query (str "code=abc&state=" state))))))
(testing "wrong or missing state"
(is (= 400 (:status (request app :get "/auth/callback" :cookie cookie :query "code=abc&state=nope"))))
(is (= 400 (:status (request app :get "/auth/callback" :cookie cookie :query "code=abc")))))
(testing "the user denied access at Gitea"
(is (= 403 (:status (request app :get "/auth/callback" :cookie cookie
:query (str "error=access_denied&state=" state))))))
(testing "missing code"
(is (= 400 (:status (request app :get "/auth/callback" :cookie cookie :query (str "state=" state))))))))
(deftest callback-gitea-failure-test
(let [login-with (fn [http]
(let [app (make-app http)
start (request app :get "/auth/login")
resp (atom nil)]
;; the 502 path logs; keep it out of the test output
(with-out-str
(reset! resp (request app :get "/auth/callback" :cookie (set-cookie start)
:query (str "code=abc&state=" (location-state start)))))
@resp))]
(testing "Gitea rejects the code"
(let [resp (login-with (fn [_] {:status 400 :body "{\"error\":\"invalid_grant\"}"}))]
(is (= 401 (:status resp)))
(is (nil? (get-in resp [:headers "Set-Cookie"])) "no session is created")))
(testing "Gitea rejects the token"
(is (= 401 (:status (login-with (fn [{:keys [url]}]
(if (str/ends-with? url "/access_token")
{:status 200 :body "{\"access_token\":\"tok\"}"}
{:status 401 :body "{}"})))))))
(testing "Gitea is unreachable or answers garbage"
(is (= 502 (:status (login-with (fn [_] (throw (java.net.ConnectException. "refused")))))))
(is (= 502 (:status (login-with (fn [_] {:status 200 :body "<html>not json</html>"}))))))))
(deftest me-and-logout-test
(let [app (make-app (gitea-stub "Alice"))
cookie (login! app)]
(testing "anonymous"
(is (= 401 (:status (request app :get "/auth/me")))))
(testing "logged in"
(let [resp (request app :get "/auth/me" :cookie cookie)]
(is (= 200 (:status resp)))
(is (= {:login "Alice" :id 7 :admin? true} (:body resp)))))
(testing "logged in as a non-admin"
(let [app (make-app (gitea-stub "bob"))]
(is (= {:login "bob" :id 7 :admin? false}
(:body (request app :get "/auth/me" :cookie (login! app)))))))
(testing "logout replaces the session with an empty one"
(let [resp (request app :post "/auth/logout" :cookie cookie)]
(is (= 200 (:status resp)))
(is (= 401 (:status (request app :get "/auth/me" :cookie (set-cookie resp)))))))))
(deftest forged-session-test
(let [app (make-app (gitea-stub "alice"))
cookie (login! app)]
(testing "a cookie tampered with is ignored"
(let [tampered (str (subs cookie 0 (- (count cookie) 4)) "AAAA")]
(is (= 401 (:status (request app :get "/auth/me" :cookie tampered))))))
(testing "a cookie minted under another SESSION_SECRET is ignored"
(let [other (make-app (gitea-stub "alice") :session-secret "other-secret")]
(is (= 401 (:status (request other :get "/auth/me" :cookie cookie))))))
(testing "garbage"
(is (= 401 (:status (request app :get "/auth/me" :cookie "tbd-session=not-a-real-cookie")))))))
(deftest current-user-expiry-test
(let [req {:session {:user {:login "alice" :expires-at 1000}}}]
(is (= {:login "alice" :expires-at 1000} (mw/current-user req 999)))
(is (nil? (mw/current-user req 1000)) "expired exactly at expires-at")
(is (nil? (mw/current-user req 5000)))
(is (nil? (mw/current-user {:session {:user {:login "alice"}}} 0)) "a user with no expiry is not trusted")
(is (nil? (mw/current-user {} 0)))))
+11
View File
@@ -26,6 +26,17 @@
(is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"]))) (is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(is (contains? (:body resp) :error)))))) (is (contains? (:body resp) :error))))))
(deftest add-route-browser-test
(testing "browser navigations get an inline-renderable type so Chrome doesn't download the EDN"
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"
:headers {"accept" "text/html,application/xhtml+xml,*/*;q=0.8"}})]
(is (= "text/plain; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(is (= {:result 5} (edn/read-string (:body resp))))))
(testing "programmatic clients still get application/edn"
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"
:headers {"accept" "*/*"}})]
(is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"]))))))
(deftest add-route-swagger-test (deftest add-route-swagger-test
(let [spec (-> (core/app {:request-method :get :uri "/swagger.json" (let [spec (-> (core/app {:request-method :get :uri "/swagger.json"
:headers {"accept" "application/json"}}) :headers {"accept" "application/json"}})
+40
View File
@@ -0,0 +1,40 @@
(ns tbd.nrepl.core-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[tbd.nrepl.core :as nrepl])
(:import (java.net ConnectException InetSocketAddress Socket ServerSocket)))
(use-fixtures :each (fn [t] (try (t) (finally (nrepl/disable!)))))
(defn listening? [port]
(try (with-open [s (Socket.)]
(.connect s (InetSocketAddress. "127.0.0.1" (int port)) 500)
true)
(catch java.io.IOException _ false)))
(deftest env-config-test
(is (= {:port 7888 :bind "127.0.0.1"} (nrepl/env-config {})))
(is (= {:port 7888 :bind "127.0.0.1"} (nrepl/env-config {"NREPL_PORT" " " "NREPL_BIND" ""})))
(is (= {:port 9000 :bind "0.0.0.0"} (nrepl/env-config {"NREPL_PORT" "9000" "NREPL_BIND" "0.0.0.0"}))))
(deftest enable-disable-test
(is (= {:enabled false} (nrepl/status)))
(let [{:keys [enabled bind port] :as status} (nrepl/enable! {:port 0 :bind "127.0.0.1"})]
(is (true? enabled))
(is (= "127.0.0.1" bind))
(is (pos? port) "port 0 reports the port actually bound")
(is (= status (nrepl/status)))
(is (listening? port))
(testing "enabling again is a no-op"
(is (= status (nrepl/enable! {:port 0 :bind "127.0.0.1"}))))
(testing "disabling closes the port"
(is (= {:enabled false} (nrepl/disable!)))
(is (= {:enabled false} (nrepl/status)))
(is (not (listening? port))))
(testing "disabling again is a no-op"
(is (= {:enabled false} (nrepl/disable!))))))
(deftest enable-failure-test
(with-open [taken (ServerSocket. 0 1 (java.net.InetAddress/getByName "127.0.0.1"))]
(is (thrown? java.net.BindException
(nrepl/enable! {:port (.getLocalPort taken) :bind "127.0.0.1"})))
(is (= {:enabled false} (nrepl/status)) "a failed start leaves nREPL disabled")))
+92
View File
@@ -0,0 +1,92 @@
(ns tbd.nrepl.routes-test
(:require [clojure.edn :as edn]
[clojure.test :refer [deftest is testing use-fixtures]]
[muuntaja.core :as m]
[tbd.auth.routes-test :as t]
[tbd.nrepl.core :as nrepl])
(:import (java.io ByteArrayInputStream)
(java.net InetAddress ServerSocket)))
(use-fixtures :each (fn [f] (try (f) (finally (nrepl/disable!)))))
(defn post-nrepl
"POST /nrepl with `body` (a string, or nil for none), as JSON unless `content-type` says otherwise.
The handler logs each toggle; that's kept out of the test output."
([app cookie body] (post-nrepl app cookie body "application/json"))
([app cookie body content-type]
(let [resp (atom nil)]
(with-out-str
(reset! resp (app (cond-> {:request-method :post :uri "/nrepl"
:headers (cond-> {"content-type" content-type}
cookie (assoc "cookie" cookie))}
body (assoc :body (ByteArrayInputStream. (.getBytes ^String body)))))))
(cond-> @resp (string? (:body @resp)) (update :body edn/read-string)))))
(deftest anonymous-test
(let [app (t/make-app (t/gitea-stub "alice"))]
(doseq [body ["{\"enabled\":true}" "{\"enabled\":\"junk\"}" nil]]
(let [resp (post-nrepl app nil body)]
(is (= 401 (:status resp)) (str "body: " body))))
(is (= {:enabled false} (nrepl/status)) "nothing was started")))
(deftest non-admin-test
(let [app (t/make-app (t/gitea-stub "mallory"))
cookie (t/login! app)]
(is (= 403 (:status (post-nrepl app cookie "{\"enabled\":true}"))))
(is (= {:enabled false} (nrepl/status)) "nothing was started")))
(deftest empty-allowlist-test
(let [app (t/make-app (t/gitea-stub "alice") :admin-users #{})
cookie (t/login! app)]
(is (= 403 (:status (post-nrepl app cookie "{\"enabled\":true}"))))))
(deftest admin-test
(let [app (t/make-app (t/gitea-stub "Alice"))
cookie (t/login! app)]
(testing "enable"
(let [resp (post-nrepl app cookie "{\"enabled\":true}")]
(is (= 200 (:status resp)))
(is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(is (= {:enabled true :bind "127.0.0.1"} (dissoc (:body resp) :port)))
(is (pos? (get-in resp [:body :port])))
(is (= (:body resp) (nrepl/status)))))
(testing "disable"
(let [resp (post-nrepl app cookie "{\"enabled\":false}")]
(is (= 200 (:status resp)))
(is (= {:enabled false} (:body resp)))
(is (= {:enabled false} (nrepl/status)))))
(testing "EDN bodies work too"
(let [resp (post-nrepl app cookie "{:enabled true}" "application/edn")]
(is (= 200 (:status resp)))
(is (true? (get-in resp [:body :enabled])))))))
(deftest invalid-body-test
(let [app (t/make-app (t/gitea-stub "alice"))
cookie (t/login! app)]
(doseq [body [nil "{}" "{\"enabled\":\"maybe\"}" "{\"enabled\":null}" "not json"]]
(let [resp (post-nrepl app cookie body)]
(is (= 400 (:status resp)) (str "body: " body))
(is (contains? (:body resp) :error) (str "body: " body))))
(is (= {:enabled false} (nrepl/status)))))
(deftest bind-failure-test
(with-open [taken (ServerSocket. 0 1 (InetAddress/getByName "127.0.0.1"))]
(let [app (tbd.core/make-app
{:auth {:gitea-url "https://gitea.test" :client-id "cid" :client-secret "sec"
:redirect-uri "http://tbd.test/auth/callback" :admin-users #{"alice"}
:session-secret "test-secret" :http (t/gitea-stub "alice")}
:nrepl {:port (.getLocalPort taken) :bind "127.0.0.1"}})
cookie (t/login! app)
resp (post-nrepl app cookie "{\"enabled\":true}")]
(is (= 500 (:status resp)))
(is (re-find #"could not change nREPL state" (get-in resp [:body :error])))
(is (= {:enabled false} (nrepl/status))))))
(deftest swagger-test
(let [app (t/make-app (t/gitea-stub "alice"))
spec (-> (app {:request-method :get :uri "/swagger.json" :headers {"accept" "application/json"}})
:body
(->> (m/decode "application/json")))
paths (into {} (map (fn [[k v]] [(str (namespace k) "/" (name k)) v])) (:paths spec))]
(is (contains? (get-in paths ["/nrepl"]) :post) "documented")
(is (contains? paths "/auth/login"))))