Offline-first key-value store with atom interface (swap!, deref, add-watch) that syncs to a SQLite-backed server over Transit. Server (CLJ): - SQLite storage with Nippy serialization preserving all Clojure types - GET /sync?group=G&since=T pull endpoint with prefix-based groups - POST /sync push endpoint with per-document version checking - Conflict detection (stale write rejection) - Token-based auth with per-user group access - CORS support, soft deletes, purge compaction Client (CLJS): - IndexedDB wrapper with Transit serialization - SyncedAtom implementing IAtom (IDeref, ISwap, IReset, IWatchable) - Write-through to IndexedDB on every swap! - Background sync loop (pull + push) with configurable interval - Online/offline detection with reconnect sync - Conflict resolution (accept server value) - ready? channel for initial load - Custom cache atom support (Reagent ratom compatible) 25 tests, 77 assertions across db, transit, server, and auth.
89 lines
3.2 KiB
Clojure
89 lines
3.2 KiB
Clojure
(ns pocketbook.auth-test
|
|
(:require [clojure.test :refer [deftest is testing use-fixtures]]
|
|
[pocketbook.server :as server]
|
|
[pocketbook.transit :as t])
|
|
(:import [java.io File]
|
|
[java.net URI]
|
|
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers HttpRequest$BodyPublishers]))
|
|
|
|
(def ^:dynamic *port* nil)
|
|
(def ^:dynamic *server* nil)
|
|
|
|
(defn- free-port []
|
|
(with-open [s (java.net.ServerSocket. 0)]
|
|
(.getLocalPort s)))
|
|
|
|
(def test-users
|
|
{"alice" {:token "alice-secret" :groups #{"todo" "settings"}}
|
|
"bob" {:token "bob-secret" :groups #{"todo"}}})
|
|
|
|
(use-fixtures :each
|
|
(fn [f]
|
|
(let [port (free-port)
|
|
db-path (str (File/createTempFile "pocketbook-auth-test" ".db"))
|
|
srv (server/start! {:port port :db-path db-path :users test-users})]
|
|
(Thread/sleep 200)
|
|
(try
|
|
(binding [*server* srv *port* port]
|
|
(f))
|
|
(finally
|
|
(server/stop! srv)
|
|
(.delete (File. db-path)))))))
|
|
|
|
(def ^:private client (HttpClient/newHttpClient))
|
|
|
|
(defn- url [path & [query]]
|
|
(str "http://localhost:" *port* path (when query (str "?" query))))
|
|
|
|
(defn- get-transit [path query & [token]]
|
|
(let [req (-> (HttpRequest/newBuilder)
|
|
(.uri (URI. (url path query)))
|
|
(.header "Accept" "application/transit+json")
|
|
(cond-> token (.header "Authorization" (str "Bearer " token)))
|
|
(.GET)
|
|
(.build))
|
|
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
|
|
{:status (.statusCode resp)
|
|
:body (t/decode (.body resp))}))
|
|
|
|
(defn- post-transit [path body & [token]]
|
|
(let [bytes (t/encode body)
|
|
req (-> (HttpRequest/newBuilder)
|
|
(.uri (URI. (url path)))
|
|
(.header "Content-Type" "application/transit+json")
|
|
(.header "Accept" "application/transit+json")
|
|
(cond-> token (.header "Authorization" (str "Bearer " token)))
|
|
(.POST (HttpRequest$BodyPublishers/ofByteArray bytes))
|
|
(.build))
|
|
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
|
|
{:status (.statusCode resp)
|
|
:body (t/decode (.body resp))}))
|
|
|
|
(deftest unauthorized-without-token
|
|
(let [resp (get-transit "/sync" "group=todo&since=0")]
|
|
(is (= 401 (:status resp)))))
|
|
|
|
(deftest unauthorized-with-bad-token
|
|
(let [resp (get-transit "/sync" "group=todo&since=0" "wrong-token")]
|
|
(is (= 401 (:status resp)))))
|
|
|
|
(deftest alice-can-access-todo
|
|
(let [resp (post-transit "/sync"
|
|
[{:id "todo:1" :value {:text "Alice todo"} :base-version 0}]
|
|
"alice-secret")]
|
|
(is (= 200 (:status resp)))
|
|
(is (= :ok (:status (first (:body resp)))))))
|
|
|
|
(deftest bob-cannot-access-settings
|
|
(let [resp (post-transit "/sync"
|
|
[{:id "settings:theme" :value {:dark true} :base-version 0}]
|
|
"bob-secret")]
|
|
(is (= 403 (:status resp)))))
|
|
|
|
(deftest alice-can-access-settings
|
|
(let [resp (post-transit "/sync"
|
|
[{:id "settings:theme" :value {:dark true} :base-version 0}]
|
|
"alice-secret")]
|
|
(is (= 200 (:status resp)))
|
|
(is (= :ok (:status (first (:body resp)))))))
|