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
+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 (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
(let [spec (-> (core/app {:request-method :get :uri "/swagger.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"))))