291 lines
15 KiB
Clojure
Executable File
291 lines
15 KiB
Clojure
Executable File
#!/usr/bin/env bb
|
|
|
|
(ns integration-test
|
|
(:require [babashka.http-client :as http]
|
|
[cheshire.core :as json]
|
|
[clojure.edn :as edn]
|
|
[clojure.string :as str]))
|
|
|
|
(def default-config
|
|
{:base-url "http://localhost:3000"
|
|
:max-retries 30
|
|
:retry-delay-ms 1000
|
|
:watch? false
|
|
:watch-interval-ms 5000})
|
|
|
|
(defn get* [base-url]
|
|
(http/get base-url {:throw false}))
|
|
|
|
(defn head* [base-url]
|
|
(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 post-form* [url params]
|
|
(http/post url {:throw false
|
|
:client no-redirect-client
|
|
:headers {"Content-Type" "application/x-www-form-urlencoded"}
|
|
:form-params params}))
|
|
|
|
(defn json* [url]
|
|
(json/parse-string (:body (http/get url))))
|
|
|
|
(defn wait-for-server [{:keys [base-url max-retries retry-delay-ms]}]
|
|
(println (str "=== Waiting for server at " base-url " ==="))
|
|
(loop [i 1]
|
|
(let [ready? (try (= 200 (:status (get* base-url))) (catch Exception _ false))]
|
|
(cond
|
|
ready? (do (println "Server is ready!") true)
|
|
(= i max-retries) (do (println (format "ERROR: Server did not become ready after %d attempts" max-retries))
|
|
false)
|
|
:else (do (Thread/sleep retry-delay-ms) (recur (inc i)))))))
|
|
|
|
(defn run-suite
|
|
"Runs the integration suite once against config's :base-url.
|
|
Returns {:pass n :fail n :errors [...]}"
|
|
[{:keys [base-url] :as config}]
|
|
(if-not (wait-for-server config)
|
|
{:pass 0 :fail 1 :errors ["server did not become ready"]}
|
|
(let [pass (atom 0)
|
|
fail (atom 0)
|
|
errors (atom [])
|
|
assert-eq (fn [label expected actual]
|
|
(if (= expected actual)
|
|
(do (println " ✓" label) (swap! pass inc))
|
|
(do (println " ✗" label)
|
|
(println " expected:" expected)
|
|
(println " actual: " actual)
|
|
(swap! fail inc)
|
|
(swap! errors conj (format "%s: expected '%s' but got '%s'" label expected actual)))))
|
|
assert-contains (fn [label needle haystack]
|
|
(if (str/includes? haystack needle)
|
|
(do (println " ✓" label) (swap! pass inc))
|
|
(do (println " ✗" label)
|
|
(println " expected to contain:" needle)
|
|
(println " actual: " haystack)
|
|
(swap! fail inc)
|
|
(swap! errors conj (format "%s: did not find '%s'" label needle)))))
|
|
assert-not-empty (fn [label value]
|
|
(if-not (str/blank? value)
|
|
(do (println " ✓" label) (swap! pass inc))
|
|
(do (println " ✗" label)
|
|
(println " expected non-empty value")
|
|
(swap! fail inc)
|
|
(swap! errors conj (format "%s: value was empty" label)))))]
|
|
(println)
|
|
(println "=== Running Integration Tests ===")
|
|
(println)
|
|
|
|
(println "Test 1: GET / returns 200")
|
|
(assert-eq "HTTP status" 200 (:status (get* base-url)))
|
|
(println)
|
|
|
|
(println "Test 2: Content-Type is text/html")
|
|
(assert-contains "Content-Type" "text/html" (get (:headers (get* base-url)) "content-type" ""))
|
|
(println)
|
|
|
|
(println "Test 3: Root serves the HTML shell for the UI")
|
|
(let [body (:body (get* base-url))]
|
|
(assert-contains "Body has the mount point" "<div id=\"app\"" body)
|
|
(assert-contains "Body loads the compiled UI" "<script src=\"/js/main.js\"" body))
|
|
(println)
|
|
|
|
(println "Test 4: GET /js/main.js serves the compiled UI")
|
|
(let [resp (get* (str base-url "/js/main.js"))]
|
|
(assert-eq "HTTP status" 200 (:status resp))
|
|
(assert-contains "Content-Type" "javascript" (get (:headers resp) "content-type" ""))
|
|
(assert-not-empty "Body is non-empty" (:body resp))
|
|
(assert-contains "Bundle calls the add API" "/api/add" (:body resp)))
|
|
(println)
|
|
|
|
(println "Test 5: HEAD / returns 200")
|
|
(assert-eq "HEAD status" 200 (:status (head* base-url)))
|
|
(println)
|
|
|
|
(println "Test 6: GET /api/ping returns 200 with body 'pong'")
|
|
(let [resp (get* (str base-url "/api/ping"))]
|
|
(assert-eq "HTTP status" 200 (:status resp))
|
|
(assert-eq "Body" "pong" (:body resp)))
|
|
(println)
|
|
|
|
(println "Test 7: GET /swagger.json returns a valid-looking spec")
|
|
(let [resp (get* (str base-url "/swagger.json"))
|
|
body (:body resp)]
|
|
(assert-eq "HTTP status" 200 (:status resp))
|
|
(assert-contains "Content-Type" "application/json" (get (:headers resp) "content-type" ""))
|
|
(assert-contains "Spec references /api/ping" "/api/ping" body))
|
|
(println)
|
|
|
|
(println "Test 8: GET /api-docs serves the Swagger UI")
|
|
(let [resp (get* (str base-url "/api-docs"))]
|
|
(assert-eq "HTTP status" 200 (:status resp))
|
|
(assert-contains "Content-Type" "text/html" (get (:headers resp) "content-type" "")))
|
|
(println)
|
|
|
|
(println "Test 9: GET /api/add?x=2&y=3 returns {:result 5} as EDN")
|
|
(let [resp (get* (str base-url "/api/add?x=2&y=3"))]
|
|
(assert-eq "HTTP status" 200 (:status resp))
|
|
(assert-contains "Content-Type" "application/edn" (get (:headers resp) "content-type" ""))
|
|
(assert-eq "Body" {:result 5} (edn/read-string (:body resp))))
|
|
(println)
|
|
|
|
(println "Test 9b: the API answers in the format the Accept header asks for, EDN by default")
|
|
(let [json (http/get (str base-url "/api/add?x=2&y=3") {:throw false :headers {"Accept" "application/json"}})
|
|
any (http/get (str base-url "/api/add?x=2&y=3") {:throw false :headers {"Accept" "*/*"}})
|
|
err (http/get (str base-url "/api/todos") {:throw false :headers {"Accept" "application/json"}})]
|
|
(assert-contains "JSON Content-Type" "application/json" (get (:headers json) "content-type" ""))
|
|
(assert-eq "JSON body" "{\"result\":5}" (:body json))
|
|
(assert-contains "Accept */* is EDN" "application/edn" (get (:headers any) "content-type" ""))
|
|
(assert-contains "Errors are JSON too" "application/json" (get (:headers err) "content-type" ""))
|
|
(assert-eq "Error body" "{\"error\":\"login required\"}" (:body err)))
|
|
(println)
|
|
|
|
(println "Test 10: GET /api/add with a missing or non-numeric param returns 400")
|
|
(assert-eq "Missing y" 400 (:status (get* (str base-url "/api/add?x=2"))))
|
|
(assert-eq "Non-numeric x" 400 (:status (get* (str base-url "/api/add?x=abc&y=3"))))
|
|
(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 "Test 15: the todo API without a session returns 401")
|
|
(assert-eq "GET list" 401 (:status (get* (str base-url "/api/todos"))))
|
|
(assert-eq "POST create" 401 (:status (post-json* (str base-url "/api/todos") "{\"title\":\"x\"}")))
|
|
(assert-eq "GET one" 401 (:status (get* (str base-url "/api/todos/1"))))
|
|
(println)
|
|
|
|
(println "Test 16: POST /mcp without a token returns 401 pointing at the OAuth metadata")
|
|
(let [resp (http/post (str base-url "/mcp")
|
|
{:throw false
|
|
:headers {"Content-Type" "application/json"
|
|
"Accept" "application/json, text/event-stream"}
|
|
:body "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"})
|
|
challenge (get (:headers resp) "www-authenticate" "")]
|
|
(assert-eq "HTTP status" 401 (:status resp))
|
|
(assert-contains "WWW-Authenticate" "Bearer resource_metadata=" challenge)
|
|
(assert-contains "WWW-Authenticate names the metadata document" "/.well-known/oauth-protected-resource/mcp" challenge))
|
|
(assert-eq "A bogus token is refused too" 401
|
|
(:status (http/post (str base-url "/mcp")
|
|
{:throw false
|
|
:headers {"Content-Type" "application/json" "Authorization" "Bearer nope"}
|
|
:body "{}"})))
|
|
(println)
|
|
|
|
(println "Test 17: MCP OAuth discovery, registration and the authorize/token endpoints")
|
|
(let [resource (json* (str base-url "/.well-known/oauth-protected-resource/mcp"))
|
|
server (json* (str base-url "/.well-known/oauth-authorization-server"))]
|
|
(assert-contains "Resource metadata names the MCP endpoint" "/mcp" (get resource "resource"))
|
|
(assert-eq "PKCE S256 only" ["S256"] (get server "code_challenge_methods_supported"))
|
|
(assert-contains "Registration endpoint" "/oauth/register" (get server "registration_endpoint")))
|
|
(let [reg (http/post (str base-url "/oauth/register")
|
|
{:throw false
|
|
:headers {"Content-Type" "application/json"}
|
|
:body "{\"redirect_uris\":[\"http://localhost:8123/callback\"],\"client_name\":\"integration\"}"})
|
|
client-id (get (json/parse-string (:body reg)) "client_id")]
|
|
(assert-eq "Registering a client" 201 (:status reg))
|
|
(assert-eq "Registering a web redirect over plain http is refused" 400
|
|
(:status (http/post (str base-url "/oauth/register")
|
|
{:throw false
|
|
:headers {"Content-Type" "application/json"}
|
|
:body "{\"redirect_uris\":[\"http://evil.example.com/cb\"]}"})))
|
|
(let [resp (get-no-redirect* (str base-url "/oauth/authorize?client_id=" client-id
|
|
"&redirect_uri=http%3A%2F%2Flocalhost%3A8123%2Fcallback&response_type=code"
|
|
"&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256"))]
|
|
(assert-eq "Authorize while logged out sends the user to log in" 302 (:status resp))
|
|
(assert-contains "Location" "/auth/login?return_to=" (get (:headers resp) "location" "")))
|
|
(assert-eq "Authorize with an unregistered redirect URI is an error page, not a redirect" 400
|
|
(:status (get-no-redirect* (str base-url "/oauth/authorize?client_id=" client-id
|
|
"&redirect_uri=https%3A%2F%2Fevil.example.com%2Fcb&response_type=code"))))
|
|
(assert-eq "A bad code is invalid_grant" "invalid_grant"
|
|
(get (json/parse-string (:body (post-form* (str base-url "/oauth/token")
|
|
{"grant_type" "authorization_code" "code" "nope" "client_id" client-id
|
|
"redirect_uri" "http://localhost:8123/callback"
|
|
"code_verifier" (apply str (repeat 43 "a"))})))
|
|
"error")))
|
|
(println)
|
|
|
|
(println "================================")
|
|
(println (format " Results: %d passed, %d failed" @pass @fail))
|
|
(println "================================")
|
|
|
|
(if (pos? @fail)
|
|
(do (println) (println "Failures:") (doseq [e @errors] (println (str " - " e))))
|
|
(do (println) (println "All integration tests passed! ✓")))
|
|
|
|
{:pass @pass :fail @fail :errors @errors})))
|
|
|
|
(defn parse-args
|
|
"Parses CLI args into a config override map.
|
|
Supported: [base-url] [--watch] [--interval <ms>]"
|
|
[args]
|
|
(loop [args args
|
|
overrides {}]
|
|
(if (empty? args)
|
|
overrides
|
|
(let [[arg & more] args]
|
|
(cond
|
|
(= arg "--watch") (recur more (assoc overrides :watch? true))
|
|
(= arg "--interval") (recur (rest more) (assoc overrides :watch-interval-ms (parse-long (first more))))
|
|
:else (recur more (assoc overrides :base-url arg)))))))
|
|
|
|
(defn -main [& args]
|
|
(let [config (merge default-config (parse-args args))
|
|
{:keys [watch?]} config]
|
|
(if watch?
|
|
(do
|
|
(println "Watch mode: watching for file changes. Press Ctrl+C to stop.")
|
|
(println "Watching: integration_test.bb")
|
|
(println)
|
|
;; Run once, then wait for inotify events
|
|
(run-suite config)
|
|
(println)
|
|
(println "Waiting for file changes...")
|
|
(println)
|
|
;; Use inotifywait to watch the file for modifications
|
|
(let [proc (Runtime/exec (into-array String ["inotifywait" "-e" "modify" "-q" "integration_test.bb"]))]
|
|
(try
|
|
(.waitFor proc)
|
|
(finally
|
|
(.destroy proc)))
|
|
(recur args)))
|
|
(let [{:keys [fail]} (run-suite config)]
|
|
(System/exit (if (pos? fail) 1 0))))))
|
|
|
|
|
|
(comment
|
|
(run-suite {:base-url "http://localhost:3000"}))
|
|
|
|
;; bb runs this file as a script, not via -m — nothing calls -main otherwise.
|
|
(apply -main *command-line-args*)
|
|
|