@@ -1,20 +1,24 @@
|
||||
(ns tbd.auth.routes-test
|
||||
(:require [clojure.edn :as edn]
|
||||
[clojure.string :as str]
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[tbd.auth.middleware :as mw]
|
||||
[tbd.core :as core]))
|
||||
[tbd.core :as core]
|
||||
[tbd.test-util :as test-util]))
|
||||
|
||||
(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)})))
|
||||
"Stands in for Gitea: any code is exchanged for a token that resolves to `login` (with Gitea id 7, or `id`)."
|
||||
([login] (gitea-stub login 7))
|
||||
([login id]
|
||||
(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\":%d}" login id)}))))
|
||||
|
||||
(defn make-app [http & {:as overrides}]
|
||||
(core/make-app {:auth (merge {:gitea-url "https://gitea.test"
|
||||
(defn make-app
|
||||
"The app against a Gitea stub. `:todo` (a todo config) is passed through; other overrides go into the auth config."
|
||||
[http & {:keys [todo] :as overrides}]
|
||||
(core/make-app {:todo todo
|
||||
:auth (merge {:gitea-url "https://gitea.test"
|
||||
:client-id "cid"
|
||||
:client-secret "sec"
|
||||
:redirect-uri "http://tbd.test/auth/callback"
|
||||
@@ -22,16 +26,13 @@
|
||||
:session-secret "test-secret"
|
||||
:secure-cookies? false
|
||||
:http http}
|
||||
overrides)
|
||||
(dissoc overrides :todo))
|
||||
: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))))
|
||||
(test-util/decode-body resp)))
|
||||
|
||||
(defn set-cookie
|
||||
"The `name=value` pair from a response's Set-Cookie header."
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
(ns tbd.math.routes-test
|
||||
(:require [clojure.edn :as edn]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[muuntaja.core :as m]
|
||||
[tbd.core :as core]))
|
||||
[tbd.core :as core]
|
||||
[tbd.test-util :as test-util]))
|
||||
|
||||
;; Coercion/validation is configured on the router in tbd.core, so go through the real app.
|
||||
(defn add [query]
|
||||
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string query})]
|
||||
(-> resp
|
||||
(update :body edn/read-string))))
|
||||
(test-util/decode-body (core/app {:request-method :get :uri "/api/add" :query-string query})))
|
||||
|
||||
(deftest add-route-test
|
||||
(testing "integers"
|
||||
@@ -31,7 +29,7 @@
|
||||
(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))))))
|
||||
(is (= {:result 5} (:body (test-util/decode-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" "*/*"}})]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
(ns tbd.nrepl.routes-test
|
||||
(:require [clojure.edn :as edn]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
(:require [clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[muuntaja.core :as m]
|
||||
[tbd.auth.routes-test :as t]
|
||||
[tbd.test-util :as test-util]
|
||||
[tbd.nrepl.core :as nrepl])
|
||||
(:import (java.io ByteArrayInputStream)
|
||||
(java.net InetAddress ServerSocket)))
|
||||
@@ -20,12 +20,12 @@
|
||||
: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)))))
|
||||
(test-util/decode-body @resp))))
|
||||
|
||||
(defn get-nrepl [app cookie]
|
||||
(let [resp (app {:request-method :get :uri "/nrepl"
|
||||
:headers (cond-> {} cookie (assoc "cookie" cookie))})]
|
||||
(cond-> resp (string? (:body resp)) (update :body edn/read-string))))
|
||||
(test-util/decode-body resp)))
|
||||
|
||||
(deftest anonymous-test
|
||||
(let [app (t/make-app (t/gitea-stub "alice"))]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
(ns tbd.test-util
|
||||
(:require [muuntaja.core :as m]
|
||||
[tbd.http :as http]))
|
||||
|
||||
(defn decode-body
|
||||
"Reads a response's body back into data by the response's own Content-Type (JSON, EDN or
|
||||
Transit). Bodies that aren't one of those, like text/plain, are left alone."
|
||||
[resp]
|
||||
(let [content-type (get-in resp [:headers "Content-Type"] "")
|
||||
body (:body resp)]
|
||||
(if (and body (not (string? body)) (re-find #"json|edn|transit" content-type))
|
||||
(update resp :body #(m/decode http/muuntaja content-type %))
|
||||
resp)))
|
||||
@@ -0,0 +1,41 @@
|
||||
(ns tbd.todo.core-test
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[tbd.todo.core :as todo]))
|
||||
|
||||
(deftest validate-title-test
|
||||
(testing "trims"
|
||||
(is (= {:ok "buy milk"} (todo/validate-title " buy milk\n"))))
|
||||
(testing "blank or missing"
|
||||
(doseq [title [nil "" " " "\t\n"]]
|
||||
(is (= {:error "title must not be blank"} (todo/validate-title title)) (pr-str title))))
|
||||
(testing "length is checked after trimming"
|
||||
(let [longest (apply str (repeat todo/max-title-length "x"))]
|
||||
(is (= {:ok longest} (todo/validate-title (str " " longest " "))))
|
||||
(is (contains? (todo/validate-title (str longest "x")) :error)))))
|
||||
|
||||
(deftest new-todo-test
|
||||
(is (= {:ok {:owner-id 7 :title "buy milk" :done false :created-at 1000 :updated-at 1000}}
|
||||
(todo/new-todo 7 " buy milk " 1000)))
|
||||
(is (= {:error "title must not be blank"} (todo/new-todo 7 " " 1000))))
|
||||
|
||||
(def stored {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1000 :updated-at 1000})
|
||||
|
||||
(deftest apply-changes-test
|
||||
(testing "title"
|
||||
(is (= {:ok (assoc stored :title "buy oat milk" :updated-at 2000)}
|
||||
(todo/apply-changes stored {:title " buy oat milk "} 2000))))
|
||||
(testing "done, both ways"
|
||||
(let [done (:ok (todo/apply-changes stored {:done true} 2000))]
|
||||
(is (= (assoc stored :done true :updated-at 2000) done))
|
||||
(is (= (assoc stored :updated-at 3000) (:ok (todo/apply-changes done {:done false} 3000))))))
|
||||
(testing "both at once"
|
||||
(is (= {:ok (assoc stored :title "x" :done true :updated-at 2000)}
|
||||
(todo/apply-changes stored {:title "x" :done true} 2000))))
|
||||
(testing "an invalid title changes nothing"
|
||||
(is (= {:error "title must not be blank"} (todo/apply-changes stored {:title " " :done true} 2000))))
|
||||
(testing "only :title and :done can change; the rest of the todo is untouched"
|
||||
(is (= {:ok (assoc stored :done true :updated-at 2000)}
|
||||
(todo/apply-changes stored {:done true :id 99 :owner-id 8 :created-at 0} 2000))))
|
||||
(testing "no changes"
|
||||
(doseq [changes [{} {:id 99} nil]]
|
||||
(is (contains? (todo/apply-changes stored changes 2000) :error) (pr-str changes)))))
|
||||
@@ -0,0 +1,69 @@
|
||||
(ns tbd.todo.db-test
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[tbd.todo.db :as db])
|
||||
(:import (java.nio.file Files)
|
||||
(java.nio.file.attribute FileAttribute)))
|
||||
|
||||
(defn temp-db-path
|
||||
"A path for a database file inside a fresh temp directory (which `open` has to create a
|
||||
subdirectory of, as with the default data/todo.db)."
|
||||
[]
|
||||
(str (Files/createTempDirectory "tbd-todo" (make-array FileAttribute 0)) "/data/todo.db"))
|
||||
|
||||
(defn todo [owner-id title]
|
||||
{:owner-id owner-id :title title :done false :created-at 1000 :updated-at 1000})
|
||||
|
||||
(deftest env-config-test
|
||||
(is (= {:db-path "data/todo.db"} (db/env-config {})))
|
||||
(is (= {:db-path "data/todo.db"} (db/env-config {"TODO_DB_PATH" " "})))
|
||||
(is (= {:db-path "/var/todo.db"} (db/env-config {"TODO_DB_PATH" " /var/todo.db "}))))
|
||||
|
||||
(deftest open-test
|
||||
(testing "creates the file and its directory"
|
||||
(let [path (temp-db-path)]
|
||||
(db/open path)
|
||||
(is (.exists (io/file path)))))
|
||||
(testing "reopening keeps the data"
|
||||
(let [path (temp-db-path)
|
||||
saved (db/insert! (db/open path) (todo 7 "buy milk"))]
|
||||
(is (= [saved] (db/list-todos (db/open path) 7)))))
|
||||
(testing "a path is required"
|
||||
(doseq [path [nil "" " "]]
|
||||
(is (thrown? clojure.lang.ExceptionInfo (db/open path))))))
|
||||
|
||||
(deftest crud-test
|
||||
(let [ds (db/open (temp-db-path))
|
||||
saved (db/insert! ds (todo 7 "buy milk"))
|
||||
id (:id saved)]
|
||||
(testing "insert returns the stored todo with an id"
|
||||
(is (= (assoc (todo 7 "buy milk") :id id) saved))
|
||||
(is (not= id (:id (db/insert! ds (todo 7 "walk dog"))))))
|
||||
(testing "find and list"
|
||||
(is (= saved (db/find-todo ds 7 id)))
|
||||
(is (= ["buy milk" "walk dog"] (map :title (db/list-todos ds 7)))))
|
||||
(testing "update"
|
||||
(is (= (assoc saved :title "buy oat milk" :done true :updated-at 2000)
|
||||
(db/update! ds 7 (assoc saved :title "buy oat milk" :done true :updated-at 2000))))
|
||||
(is (= true (:done (db/find-todo ds 7 id)))))
|
||||
(testing "missing ids"
|
||||
(is (nil? (db/find-todo ds 7 999)))
|
||||
(is (nil? (db/update! ds 7 (assoc saved :id 999))))
|
||||
(is (false? (db/delete! ds 7 999))))
|
||||
(testing "delete"
|
||||
(is (true? (db/delete! ds 7 id)))
|
||||
(is (nil? (db/find-todo ds 7 id)))
|
||||
(is (false? (db/delete! ds 7 id))))))
|
||||
|
||||
(deftest owner-scoping-test
|
||||
(let [ds (db/open (temp-db-path))
|
||||
mine (db/insert! ds (todo 7 "mine"))
|
||||
theirs (db/insert! ds (todo 8 "theirs"))]
|
||||
(is (= [mine] (db/list-todos ds 7)))
|
||||
(is (= [theirs] (db/list-todos ds 8)))
|
||||
(is (= [] (db/list-todos ds 9)))
|
||||
(testing "another owner's todo can't be found, changed, or deleted by its id"
|
||||
(is (nil? (db/find-todo ds 8 (:id mine))))
|
||||
(is (nil? (db/update! ds 8 (assoc mine :title "hijacked"))))
|
||||
(is (false? (db/delete! ds 8 (:id mine))))
|
||||
(is (= mine (db/find-todo ds 7 (:id mine)))))))
|
||||
@@ -0,0 +1,106 @@
|
||||
(ns tbd.todo.routes-test
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[tbd.auth.routes-test :as t]
|
||||
[tbd.test-util :as test-util]
|
||||
[tbd.todo.db-test :as db-test])
|
||||
(:import (java.io ByteArrayInputStream)))
|
||||
|
||||
(defn request
|
||||
"`body` is a JSON string (or nil). The response body is decoded by its Content-Type."
|
||||
[app method uri cookie & [body]]
|
||||
(let [resp (app (cond-> {:request-method method :uri uri :headers (cond-> {} cookie (assoc "cookie" cookie))}
|
||||
body (-> (assoc :body (ByteArrayInputStream. (.getBytes ^String body)))
|
||||
(assoc-in [:headers "content-type"] "application/json"))))]
|
||||
(test-util/decode-body resp)))
|
||||
|
||||
(defn app-for [db-path login id]
|
||||
(t/make-app (t/gitea-stub login id) :todo {:db-path db-path}))
|
||||
|
||||
(def ^:private todos "/api/todos")
|
||||
|
||||
(deftest anonymous-test
|
||||
(let [path (db-test/temp-db-path)
|
||||
app (app-for path "alice" 7)]
|
||||
(doseq [[method uri body] [[:get todos nil]
|
||||
[:post todos "{\"title\":\"x\"}"]
|
||||
[:post todos "nope"]
|
||||
[:get (str todos "/1") nil]
|
||||
[:patch (str todos "/1") "{\"done\":true}"]
|
||||
[:delete (str todos "/1") nil]]]
|
||||
(let [resp (request app method uri nil body)]
|
||||
(is (= 401 (:status resp)) (str method " " uri))
|
||||
(is (= {:error "login required"} (:body resp)))))
|
||||
(is (not (.exists (io/file path))) "the database isn't even opened")))
|
||||
|
||||
(deftest lifecycle-test
|
||||
(let [app (app-for (db-test/temp-db-path) "alice" 7)
|
||||
cookie (t/login! app)]
|
||||
(testing "starts empty"
|
||||
(let [resp (request app :get todos cookie)]
|
||||
(is (= 200 (:status resp)))
|
||||
(is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"])))
|
||||
(is (= {:todos []} (:body resp)))))
|
||||
(testing "create"
|
||||
(let [resp (request app :post todos cookie "{\"title\":\" buy milk \"}")
|
||||
{:keys [id] :as created} (:body resp)]
|
||||
(is (= 201 (:status resp)))
|
||||
(is (= {:owner-id 7 :title "buy milk" :done false} (select-keys created [:owner-id :title :done])))
|
||||
(is (pos? id))
|
||||
(testing "get"
|
||||
(is (= created (:body (request app :get (str todos "/" id) cookie)))))
|
||||
(testing "patch"
|
||||
(let [resp (request app :patch (str todos "/" id) cookie "{\"done\":true}")]
|
||||
(is (= 200 (:status resp)))
|
||||
(is (= (dissoc (assoc created :done true) :updated-at) (dissoc (:body resp) :updated-at))))
|
||||
(is (= "oat milk" (:title (:body (request app :patch (str todos "/" id) cookie "{\"title\":\"oat milk\"}"))))))
|
||||
(testing "list"
|
||||
(request app :post todos cookie "{\"title\":\"walk dog\"}")
|
||||
(is (= ["oat milk" "walk dog"] (map :title (:todos (:body (request app :get todos cookie)))))))
|
||||
(testing "delete"
|
||||
(is (= {:status 200 :body {:deleted true}}
|
||||
(select-keys (request app :delete (str todos "/" id) cookie) [:status :body])))
|
||||
(is (= 404 (:status (request app :get (str todos "/" id) cookie))))
|
||||
(is (= 404 (:status (request app :delete (str todos "/" id) cookie))))
|
||||
(is (= ["walk dog"] (map :title (:todos (:body (request app :get todos cookie)))))))))))
|
||||
|
||||
(deftest validation-test
|
||||
(let [app (app-for (db-test/temp-db-path) "alice" 7)
|
||||
cookie (t/login! app)
|
||||
created (:body (request app :post todos cookie "{\"title\":\"keep\"}"))
|
||||
one (str todos "/" (:id created))]
|
||||
(testing "bad titles on create"
|
||||
(doseq [body ["{\"title\":\"\"}" "{\"title\":\" \"}" "{}" "{\"title\":5}" "{\"title\":null}" "nope"
|
||||
(format "{\"title\":\"%s\"}" (apply str (repeat 201 "x")))]]
|
||||
(let [resp (request app :post todos cookie body)]
|
||||
(is (= 400 (:status resp)) body)
|
||||
(is (contains? (:body resp) :error)))))
|
||||
(testing "bad changes"
|
||||
(doseq [body ["{}" "{\"title\":\" \"}" "{\"done\":\"yes\"}" "{\"id\":99}"]]
|
||||
(is (= 400 (:status (request app :patch one cookie body))) body))
|
||||
(is (= 400 (:status (request app :patch one cookie))) "no body"))
|
||||
(testing "a non-numeric id"
|
||||
(doseq [method [:get :patch :delete]]
|
||||
(is (= 400 (:status (request app method (str todos "/abc") cookie "{\"done\":true}"))) (str method))))
|
||||
(is (= [created] (:todos (:body (request app :get todos cookie)))) "nothing above changed the todo")))
|
||||
|
||||
(deftest per-user-scoping-test
|
||||
(let [path (db-test/temp-db-path)
|
||||
alice-app (app-for path "alice" 7)
|
||||
bob-app (app-for path "bob" 8)
|
||||
alice (t/login! alice-app)
|
||||
bob (t/login! bob-app)
|
||||
{:keys [id] :as mine} (:body (request alice-app :post todos alice "{\"title\":\"alice's\"}"))
|
||||
one (str todos "/" id)]
|
||||
(testing "each user lists only their own"
|
||||
(is (= [] (:todos (:body (request bob-app :get todos bob)))))
|
||||
(request bob-app :post todos bob "{\"title\":\"bob's\"}")
|
||||
(is (= ["alice's"] (map :title (:todos (:body (request alice-app :get todos alice))))))
|
||||
(is (= ["bob's"] (map :title (:todos (:body (request bob-app :get todos bob)))))))
|
||||
(testing "someone else's todo is indistinguishable from a missing one"
|
||||
(let [missing (request bob-app :get (str todos "/999999") bob)]
|
||||
(doseq [[method body] [[:get nil] [:patch "{\"title\":\"hijacked\"}"] [:delete nil]]]
|
||||
(let [resp (request bob-app method one bob body)]
|
||||
(is (= 404 (:status resp)) (str method))
|
||||
(is (= (:body missing) (:body resp)))))))
|
||||
(is (= mine (:body (request alice-app :get one alice))) "alice's todo is untouched")))
|
||||
Reference in New Issue
Block a user