summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-04-22 08:27:17 -0300
committerEuAndreh <eu@euandre.org>2026-04-22 08:27:17 -0300
commitf6f6167d54c2644b51a71f964adb768682e8227e (patch)
tree68d33fb79a50d3ff813ea12bdc177a96d54c379a
parentm (diff)
downloadpapod-f6f6167d54c2644b51a71f964adb768682e8227e.tar.gz
papod-f6f6167d54c2644b51a71f964adb768682e8227e.tar.xz
m
-rw-r--r--src/papod.clj279
-rw-r--r--tests/unit.clj246
2 files changed, 431 insertions, 94 deletions
diff --git a/src/papod.clj b/src/papod.clj
index 63cfab9..aaa5c23 100644
--- a/src/papod.clj
+++ b/src/papod.clj
@@ -142,11 +142,13 @@
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/value}
+ {:db/ident :papod.channel.type/public}
+ {:db/ident :papod.channel.type/private}
+ {:db/ident :papod.channel.type/unlisted}
{:db/ident :papod.channel/type
- :db/valueType :db.type/string
+ :db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
- :db/index true
- :db.attr/preds `non-empty?}
+ :db/index true}
{:db/ident :papod.channel/label
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
@@ -238,6 +240,69 @@
:db/unique :db.unique/value
:db/tupleAttrs [:papod.membership/channel
:papod.membership/nick]}
+ ;; Session (auth record, references cracha session)
+ {:db/ident :papod.session/id
+ :db/valueType :db.type/uuid
+ :db/cardinality :db.cardinality/one
+ :db/unique :db.unique/identity}
+ {:db/ident :papod.session/nick
+ :db/valueType :db.type/string
+ :db/cardinality :db.cardinality/one
+ :db.attr/preds `non-empty?}
+ {:db/ident :papod.session/created-at
+ :db/valueType :db.type/instant
+ :db/cardinality :db.cardinality/one}
+ {:db/ident :papod.session/finished-at
+ :db/valueType :db.type/instant
+ :db/cardinality :db.cardinality/one}
+ ;; Connection (TCP socket lifecycle)
+ {:db/ident :papod.connection/id
+ :db/valueType :db.type/uuid
+ :db/cardinality :db.cardinality/one
+ :db/unique :db.unique/identity}
+ {:db/ident :papod.connection/process
+ :db/valueType :db.type/ref
+ :db/cardinality :db.cardinality/one
+ :db/index true}
+ {:db/ident :papod.connection/created-at
+ :db/valueType :db.type/instant
+ :db/cardinality :db.cardinality/one}
+ {:db/ident :papod.connection/finished-at
+ :db/valueType :db.type/instant
+ :db/cardinality :db.cardinality/one}
+ ;; Logon (session + connection junction)
+ {:db/ident :papod.logon/id
+ :db/valueType :db.type/uuid
+ :db/cardinality :db.cardinality/one
+ :db/unique :db.unique/identity}
+ {:db/ident :papod.logon/session
+ :db/valueType :db.type/ref
+ :db/cardinality :db.cardinality/one
+ :db/index true}
+ {:db/ident :papod.logon/connection
+ :db/valueType :db.type/ref
+ :db/cardinality :db.cardinality/one
+ :db/index true}
+ {:db/ident :papod.logon/session+connection
+ :db/valueType :db.type/tuple
+ :db/cardinality :db.cardinality/one
+ :db/unique :db.unique/value
+ :db/tupleAttrs [:papod.logon/session
+ :papod.logon/connection]}
+ ;; Process (server instance)
+ {:db/ident :papod.process/id
+ :db/valueType :db.type/uuid
+ :db/cardinality :db.cardinality/one
+ :db/unique :db.unique/identity}
+ {:db/ident :papod.process/pid
+ :db/valueType :db.type/long
+ :db/cardinality :db.cardinality/one}
+ {:db/ident :papod.process/hostname
+ :db/valueType :db.type/string
+ :db/cardinality :db.cardinality/one}
+ {:db/ident :papod.process/started-at
+ :db/valueType :db.type/instant
+ :db/cardinality :db.cardinality/one}
;; Event
{:db/ident :papod.event/id
:db/valueType :db.type/uuid
@@ -309,6 +374,23 @@
:papod.membership/channel
:papod.membership/nick
:papod.membership/joined-at}}
+ {:db/ident :papod.session/attrs
+ :db.entity/attrs #{:papod.session/id
+ :papod.session/nick
+ :papod.session/created-at}}
+ {:db/ident :papod.process/attrs
+ :db.entity/attrs #{:papod.process/id
+ :papod.process/pid
+ :papod.process/hostname
+ :papod.process/started-at}}
+ {:db/ident :papod.connection/attrs
+ :db.entity/attrs #{:papod.connection/id
+ :papod.connection/process
+ :papod.connection/created-at}}
+ {:db/ident :papod.logon/attrs
+ :db.entity/attrs #{:papod.logon/id
+ :papod.logon/session
+ :papod.logon/connection}}
{:db/ident :papod.event/attrs
:db.entity/attrs #{:papod.event/id
:papod.event/type
@@ -592,7 +674,8 @@
(ffirst
(d/q '{:find [?e]
:in [$ ?id]
- :where [[?e :papod.channel/id ?id]]}
+ :where [[?e :papod.channel/id ?id]
+ [?e :papod.channel/type :papod.channel.type/private]]}
db uuid)))
(catch Exception _ nil))
@@ -600,7 +683,9 @@
(ffirst
(d/q '{:find [?e]
:in [$ ?name]
- :where [[?e :papod.channel/name ?name]]}
+ :where [[?e :papod.channel/name ?name]
+ [?e :papod.channel/type ?t]
+ [(not= ?t :papod.channel.type/private)]]}
db handle))))
(defn- has-access?
@@ -635,33 +720,19 @@
(catch Exception _)))
(defn- handle-pass
- [params client]
- (cond
- (:registered? @client)
- [(numeric-reply client "462" ":You may not reregister")]
-
- (empty? params)
- [(numeric-reply client "461" "PASS :Not enough parameters")]
-
- :else
- (do (swap! client assoc :pass (first params))
- [])))
-
-(defn- handle-network
[params client components]
(cond
(:registered? @client)
[(numeric-reply client "462" ":You may not reregister")]
(empty? params)
- [(numeric-reply client "461" "NETWORK :Not enough parameters")]
+ [(numeric-reply client "461" "PASS :Not enough parameters")]
:else
(let [uuid-str (first params)
conn (:conn components)]
(if-not conn
- (do (swap! client assoc :network-id nil)
- [])
+ []
(try
(let [uuid (java.util.UUID/fromString uuid-str)
db (d/db conn)
@@ -686,12 +757,23 @@
(defn- authenticate!
[client components]
(or (:authenticated? @client)
- (let [{:keys [pass user]} @client
- {:keys [username]} user
- conn (cracha-conn components)]
- (if-not conn
- true
- (uuid? (cracha/login! conn username pass "irc"))))))
+ (not (cracha-conn components))))
+
+(defn- create-session+logon!
+ [conn client session-id]
+ (when (and conn session-id (:connection-id @client))
+ (let [nick (client-target client)]
+ @(d/transact conn
+ [{:db/ensure :papod.session/attrs
+ :db/id "new-session"
+ :papod.session/id session-id
+ :papod.session/nick nick
+ :papod.session/created-at (java.util.Date.)}
+ {:db/ensure :papod.logon/attrs
+ :papod.logon/id (java.util.UUID/randomUUID)
+ :papod.logon/session "new-session"
+ :papod.logon/connection [:papod.connection/id
+ (:connection-id @client)]}]))))
(defn- maybe-register!
[client components]
@@ -764,7 +846,7 @@
[(str ":" +server-name+ " PONG " +server-name+ " :" (first params))]))
(defconst- +sasl-mechanisms+
- "PLAIN")
+ "PLAIN,RESUME")
(defn- handle-cap
[params client components]
@@ -815,11 +897,58 @@
(cracha/login! conn authcid password "sasl"))]
(swap! client assoc :sasl-state nil)
(if (uuid? result)
- (do (swap! client assoc :authenticated? true)
+ (do (swap! client assoc :authenticated? true
+ :session-id result)
+ (create-session+logon! (:conn components) client result)
[(numeric-reply client "900"
(str nick "!" nick "@localhost " authcid
" :You are now logged in as " authcid))
(numeric-reply client "903"
+ ":SASL authentication successful")
+ (str ":" +server-name+ " NOTICE " nick
+ " :Session ID: " result)])
+ [(numeric-reply client "904"
+ ":SASL authentication failed")]))
+ (catch Exception _
+ (swap! client assoc :sasl-state nil)
+ [(numeric-reply client "904" ":SASL authentication failed")]))))
+
+(defn- handle-sasl-resume
+ [base64-data client components]
+ (let [nick (client-target client)
+ conn (:conn components)]
+ (try
+ (let [decoded (String.
+ (.decode (java.util.Base64/getDecoder) ^String base64-data)
+ "UTF-8")
+ session-id (java.util.UUID/fromString (string/trim decoded))
+ db (when conn (d/db conn))
+ session-eid (when db
+ (ffirst
+ (d/q '{:find [?s]
+ :in [$ ?id]
+ :where [[?s :papod.session/id ?id]]}
+ db session-id)))
+ session (when session-eid (d/entity db session-eid))
+ valid? (and session
+ (= nick (:papod.session/nick session))
+ (nil? (:papod.session/finished-at session)))]
+ (swap! client assoc :sasl-state nil)
+ (if valid?
+ (do (swap! client assoc :authenticated? true
+ :session-id session-id)
+ ;; Create logon for resumed session
+ (when (:connection-id @client)
+ @(d/transact conn
+ [{:db/ensure :papod.logon/attrs
+ :papod.logon/id (java.util.UUID/randomUUID)
+ :papod.logon/session session-eid
+ :papod.logon/connection [:papod.connection/id
+ (:connection-id @client)]}]))
+ [(numeric-reply client "900"
+ (str nick "!" nick "@localhost " nick
+ " :You are now logged in as " nick))
+ (numeric-reply client "903"
":SASL authentication successful")])
[(numeric-reply client "904"
":SASL authentication failed")]))
@@ -843,18 +972,29 @@
[(numeric-reply client "906" ":SASL authentication aborted")])
(nil? (:sasl-state @client))
- (if (= "PLAIN" (some-> arg string/upper-case))
- (do (swap! client assoc :sasl-state :authenticating)
- ["AUTHENTICATE +"])
- [(numeric-reply client "908"
- (str +sasl-mechanisms+ " :are available SASL mechanisms"))
- (numeric-reply client "904" ":SASL authentication failed")])
+ (let [mechanism (some-> arg string/upper-case)]
+ (case mechanism
+ "PLAIN"
+ (do (swap! client assoc :sasl-state :authenticating
+ :sasl-mechanism "PLAIN")
+ ["AUTHENTICATE +"])
+ "RESUME"
+ (do (swap! client assoc :sasl-state :authenticating
+ :sasl-mechanism "RESUME")
+ ["AUTHENTICATE +"])
+ [(numeric-reply client "908"
+ (str +sasl-mechanisms+ " :are available SASL mechanisms"))
+ (numeric-reply client "904" ":SASL authentication failed")]))
(= :authenticating (:sasl-state @client))
(if (or (nil? arg) (= "+" arg))
- (do (swap! client assoc :sasl-state nil)
+ (do (swap! client assoc :sasl-state nil :sasl-mechanism nil)
[(numeric-reply client "904" ":SASL authentication failed")])
- (handle-sasl-plain arg client components)))))
+ (let [mechanism (:sasl-mechanism @client)]
+ (swap! client assoc :sasl-mechanism nil)
+ (case mechanism
+ "PLAIN" (handle-sasl-plain arg client components)
+ "RESUME" (handle-sasl-resume arg client components)))))))
(defn- nickserv-notice
[client text]
@@ -909,8 +1049,11 @@
[(nickserv-notice client "Invalid password")]
:unconfirmed-user
[(nickserv-notice client "Nick is not confirmed")]
- (do (swap! client assoc :identified? true)
- [(nickserv-notice client "You are now identified")]))))))
+ (do (swap! client assoc :identified? true
+ :session-id result)
+ (create-session+logon! (:conn components) client result)
+ [(nickserv-notice client "You are now identified")
+ (nickserv-notice client (str "Session ID: " result))]))))))
(defn- handle-nickserv
[content client components]
@@ -1559,7 +1702,7 @@
:papod.channel/network [:papod.network/id
(:network-id @client)]
:papod.channel/name handle
- :papod.channel/type "public"
+ :papod.channel/type :papod.channel.type/public
:papod.channel/description ""
:papod.channel/created-at (java.util.Date.)}))))
;; UPDATE in-memory
@@ -1579,15 +1722,21 @@
(let [command (string/upper-case (:command message))
params (clean-params (:params message))]
(case command
- "PASS" (handle-pass params client)
- "NETWORK" (handle-network params client components)
+ "PASS" (handle-pass params client components)
"NICK" (handle-nick params client components)
"USER" (handle-user params client components)
"PING" (handle-ping params client)
"CAP" (handle-cap params client components)
"AUTHENTICATE" (handle-authenticate params client components)
- (if-not (:registered? @client)
+ (cond
+ (not (:registered? @client))
[(numeric-reply client "451" ":You have not registered")]
+
+ (not (:network-id @client))
+ [(numeric-reply client "451"
+ ":No network selected (use PASS <network-uuid>)")]
+
+ :else
(case command
"PRIVMSG" (handle-privmsg params client components)
"JOIN" (handle-join params client components)
@@ -1624,19 +1773,38 @@
(defn- init!
[db-uri cracha-state]
(d/create-database db-uri)
- (let [conn (d/connect db-uri)]
- @(d/transact conn schema)
- {:conn conn
- :cracha cracha-state
- :clients (atom {})
- :channels (atom {})}))
+ (let [conn (d/connect db-uri)
+ _ @(d/transact conn schema)
+ process-id (java.util.UUID/randomUUID)
+ _ @(d/transact conn
+ [{:db/ensure :papod.process/attrs
+ :papod.process/id process-id
+ :papod.process/pid (.pid (java.lang.ProcessHandle/current))
+ :papod.process/hostname (.getHostName
+ (java.net.InetAddress/getLocalHost))
+ :papod.process/started-at (java.util.Date.)}])]
+ {:conn conn
+ :cracha cracha-state
+ :process-id process-id
+ :clients (atom {})
+ :channels (atom {})}))
(defn- client-loop!
[socket components]
- (let [r (.getInputStream socket)
- w (.getOutputStream socket)
- b (make-array Byte/TYPE +buffer-size+)
- client (atom {:nick nil :user nil :pass nil :registered? false :w w})]
+ (let [r (.getInputStream socket)
+ w (.getOutputStream socket)
+ b (make-array Byte/TYPE +buffer-size+)
+ conn-id (java.util.UUID/randomUUID)
+ client (atom {:nick nil :user nil :pass nil :registered? false
+ :w w :connection-id conn-id})]
+ ;; Record connection start
+ (when-let [conn (:conn components)]
+ @(d/transact conn
+ [{:db/ensure :papod.connection/attrs
+ :papod.connection/id conn-id
+ :papod.connection/process [:papod.process/id
+ (:process-id components)]
+ :papod.connection/created-at (java.util.Date.)}]))
(try
(loop [acc ""]
(let [n (.read r b)]
@@ -1646,6 +1814,11 @@
client
components)))))
(finally
+ ;; Record connection end
+ (when-let [conn (:conn components)]
+ @(d/transact conn
+ [[:db/add [:papod.connection/id conn-id]
+ :papod.connection/finished-at (java.util.Date.)]]))
(when-let [nick (:nick @client)]
(when (:clients components)
(swap! (:clients components) dissoc nick))
diff --git a/tests/unit.clj b/tests/unit.clj
index af13931..8e8eb0a 100644
--- a/tests/unit.clj
+++ b/tests/unit.clj
@@ -91,6 +91,45 @@
(defn client [] (atom {:nick nil :user nil :pass nil :registered? false}))
(def no-conn {})
+(defn test-components
+ []
+ (let [fiinha-state (fiinha/initdb!
+ (str "datomic:mem://fiinha-" (java.util.UUID/randomUUID)))
+ cracha-state (cracha/init!
+ (str "datomic:mem://cracha-" (java.util.UUID/randomUUID))
+ fiinha-state)
+ papod-uri (str "datomic:mem://papod-" (java.util.UUID/randomUUID))
+ _ (d/create-database papod-uri)
+ conn (d/connect papod-uri)]
+ @(d/transact conn @#'papod/schema)
+ (let [proc-id (java.util.UUID/randomUUID)]
+ @(d/transact conn
+ [{:db/ensure :papod.process/attrs
+ :papod.process/id proc-id
+ :papod.process/pid 0
+ :papod.process/hostname "test"
+ :papod.process/started-at (java.util.Date.)}])
+ {:conn conn :cracha cracha-state :process-id proc-id
+ :clients (atom {}) :channels (atom {})})))
+
+(defn test-network!
+ [conn]
+ (let [net-id (java.util.UUID/randomUUID)]
+ @(d/transact conn
+ [{:db/ensure :papod.network/attrs
+ :papod.network/id net-id
+ :papod.network/name (str "test-" net-id)
+ :papod.network/description ""
+ :papod.network/type "public"
+ :papod.network/created-at (java.util.Date.)}])
+ net-id))
+
+(defn test-components-with-network
+ []
+ (let [components (test-components)
+ net-id (test-network! (:conn components))]
+ (assoc components :test-network-id net-id)))
+
(deftest test_clean-params
(testing "removes leading empty strings"
(is (= (clean-params ["" "a" "b"]) ["a" "b"]))
@@ -98,12 +137,21 @@
(is (= (clean-params []) []))))
(deftest test_replies-for!
- (testing "PASS stores password"
- (let [c (client)]
- (is (= (replies-for! {:command "PASS" :params ["" "secret"]}
- c no-conn)
+ (testing "PASS selects network"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ c (client)]
+ (is (= (replies-for! {:command "PASS"
+ :params ["" (str test-network-id)]}
+ c components)
[]))
- (is (= (:pass @c) "secret"))))
+ (is (= (:network-id @c) test-network-id))))
+ (testing "PASS with invalid UUID"
+ (let [components (test-components)
+ c (client)]
+ (is (string/includes?
+ (first (replies-for! {:command "PASS" :params ["" "not-a-uuid"]}
+ c components))
+ "403"))))
(testing "PASS with no params"
(let [c (client)]
(is (string/includes?
@@ -180,38 +228,6 @@
c no-conn))
"409")))))
-(defn test-components
- []
- (let [fiinha-state (fiinha/initdb!
- (str "datomic:mem://fiinha-" (java.util.UUID/randomUUID)))
- cracha-state (cracha/init!
- (str "datomic:mem://cracha-" (java.util.UUID/randomUUID))
- fiinha-state)
- papod-uri (str "datomic:mem://papod-" (java.util.UUID/randomUUID))
- _ (d/create-database papod-uri)
- conn (d/connect papod-uri)]
- @(d/transact conn @#'papod/schema)
- {:conn conn :cracha cracha-state
- :clients (atom {}) :channels (atom {})}))
-
-(defn test-network!
- [conn]
- (let [net-id (java.util.UUID/randomUUID)]
- @(d/transact conn
- [{:db/ensure :papod.network/attrs
- :papod.network/id net-id
- :papod.network/name (str "test-" net-id)
- :papod.network/description ""
- :papod.network/type "public"
- :papod.network/created-at (java.util.Date.)}])
- net-id))
-
-(defn test-components-with-network
- []
- (let [components (test-components)
- net-id (test-network! (:conn components))]
- (assoc components :test-network-id net-id)))
-
(defn registered-client
([nick w]
(atom {:nick nick :user {:username nick} :pass nil :registered? true :w w}))
@@ -265,7 +281,7 @@
[{:papod.channel/id (java.util.UUID/randomUUID)
:papod.channel/network [:papod.network/id test-network-id]
:papod.channel/name "#test"
- :papod.channel/type "public"
+ :papod.channel/type :papod.channel.type/public
:papod.channel/description ""
:papod.channel/created-at (java.util.Date.)}])
(handle-privmsg ["#test" ":hi everyone"] sender components)
@@ -437,9 +453,10 @@
;; Send credentials
(let [creds (b64 "\u0000alice\u0000mypass")
replies (handle-authenticate [creds] c2 components)]
- (is (= 2 (count replies)))
+ (is (= 3 (count replies)))
(is (string/includes? (first replies) "900"))
(is (string/includes? (second replies) "903"))
+ (is (string/includes? (nth replies 2) "Session ID"))
(is (:authenticated? @c2)))
;; CAP END completes registration
(let [replies (handle-cap ["END"] c2 components)]
@@ -688,7 +705,7 @@
:db/id "new-channel"
:papod.channel/id chan-id
:papod.channel/network [:papod.network/id test-network-id]
- :papod.channel/type "private"
+ :papod.channel/type :papod.channel.type/private
:papod.channel/label "secret-project"
:papod.channel/description ""
:papod.channel/created-at (java.util.Date.)}
@@ -714,7 +731,7 @@
[{:db/ensure :papod.channel/attrs
:papod.channel/id (java.util.UUID/randomUUID)
:papod.channel/network [:papod.network/id test-network-id]
- :papod.channel/type "private"
+ :papod.channel/type :papod.channel.type/private
:papod.channel/label "bob-firing-wg"
:papod.channel/description ""
:papod.channel/created-at (java.util.Date.)}])]
@@ -811,6 +828,153 @@
:papod.network/id (java.util.UUID/randomUUID)
:papod.network/name "incomplete"}]))))))
+(deftest test_session-tracking
+ (testing "SASL auth creates session + logon"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ out (java.io.ByteArrayOutputStream.)
+ c (client)]
+ ;; Register user in cracha first
+ (swap! c assoc :nick "alice" :registered? true :w out
+ :network-id test-network-id)
+ (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)
+ ;; Fresh connection with connection-id (simulating client-loop!)
+ (let [conn-id (java.util.UUID/randomUUID)
+ conn (:conn components)
+ _ @(d/transact conn
+ [{:db/ensure :papod.connection/attrs
+ :papod.connection/id conn-id
+ :papod.connection/process [:papod.process/id
+ (:process-id components)]
+ :papod.connection/created-at (java.util.Date.)}])
+ c2 (atom {:nick "alice" :caps #{"sasl"}
+ :w out :connection-id conn-id})]
+ (handle-authenticate ["PLAIN"] c2 components)
+ (let [creds (b64 "\u0000alice\u0000mypass")
+ replies (handle-authenticate [creds] c2 components)]
+ (is (string/includes? (first replies) "900"))
+ (is (some? (:session-id @c2)))
+ ;; Verify session in DB
+ (let [db (d/db conn)
+ sessions (d/q '{:find [?nick]
+ :where [[?s :papod.session/nick ?nick]]}
+ db)]
+ (is (= #{["alice"]} sessions)))
+ ;; Verify logon in DB
+ (let [db (d/db conn)
+ logons (d/q '{:find [?sid ?cid]
+ :where [[?l :papod.logon/session ?s]
+ [?l :papod.logon/connection ?c]
+ [?s :papod.session/id ?sid]
+ [?c :papod.connection/id ?cid]]}
+ db)]
+ (is (= 1 (count logons)))
+ (is (= conn-id (second (first logons)))))))))
+ (testing "NickServ IDENTIFY creates session + logon"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ out (java.io.ByteArrayOutputStream.)
+ conn (:conn components)
+ conn-id (java.util.UUID/randomUUID)
+ _ @(d/transact conn
+ [{:db/ensure :papod.connection/attrs
+ :papod.connection/id conn-id
+ :papod.connection/process [:papod.process/id
+ (:process-id components)]
+ :papod.connection/created-at (java.util.Date.)}])
+ c (registered-client "bob" out test-network-id)]
+ (swap! c assoc :connection-id conn-id)
+ ;; Register user
+ (handle-privmsg ["NickServ" ":REGISTER secret"] c components)
+ ;; Identify
+ (let [replies (handle-privmsg ["NickServ" ":IDENTIFY secret"]
+ c components)]
+ (is (string/includes? (first replies) "now identified"))
+ (is (some? (:session-id @c)))
+ ;; Session + logon in DB
+ (let [db (d/db conn)
+ logons (d/q '{:find [?nick ?cid]
+ :where [[?l :papod.logon/session ?s]
+ [?l :papod.logon/connection ?c]
+ [?s :papod.session/nick ?nick]
+ [?c :papod.connection/id ?cid]]}
+ db)]
+ (is (= 1 (count logons)))
+ (is (= ["bob" conn-id] (vec (first logons)))))))
+ (testing "SASL RESUME resumes existing session"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ out (java.io.ByteArrayOutputStream.)
+ conn (:conn components)
+ ;; First: register + auth to create a session
+ conn-id1 (java.util.UUID/randomUUID)
+ _ @(d/transact conn
+ [{:db/ensure :papod.connection/attrs
+ :papod.connection/id conn-id1
+ :papod.connection/process [:papod.process/id
+ (:process-id components)]
+ :papod.connection/created-at (java.util.Date.)}])
+ c1 (atom {:nick "alice" :registered? true :w out
+ :caps #{"sasl"} :connection-id conn-id1
+ :network-id test-network-id})]
+ ;; Register user in cracha
+ (handle-privmsg ["NickServ" ":REGISTER mypass"] c1 components)
+ ;; Auth via SASL PLAIN to get session
+ (handle-authenticate ["PLAIN"] c1 components)
+ (handle-authenticate [(b64 "\u0000alice\u0000mypass")] c1 components)
+ (let [session-id (:session-id @c1)]
+ (is (some? session-id))
+ ;; Now simulate reconnect: new connection, RESUME with session ID
+ (let [conn-id2 (java.util.UUID/randomUUID)
+ _ @(d/transact conn
+ [{:db/ensure :papod.connection/attrs
+ :papod.connection/id conn-id2
+ :papod.connection/process [:papod.process/id
+ (:process-id components)]
+ :papod.connection/created-at (java.util.Date.)}])
+ c2 (atom {:nick "alice" :w out
+ :caps #{"sasl"} :connection-id conn-id2})]
+ (handle-authenticate ["RESUME"] c2 components)
+ (let [replies (handle-authenticate
+ [(b64 (str session-id))] c2 components)]
+ (is (string/includes? (first replies) "900"))
+ (is (:authenticated? @c2))
+ (is (= session-id (:session-id @c2)))
+ ;; Two logons for the same session (two connections)
+ (let [db (d/db conn)
+ logons (d/q '{:find [?cid]
+ :in [$ ?sid]
+ :where [[?l :papod.logon/session ?s]
+ [?l :papod.logon/connection ?c]
+ [?s :papod.session/id ?sid]
+ [?c :papod.connection/id ?cid]]}
+ db session-id)]
+ (is (= 2 (count logons)))
+ (is (= #{[conn-id1] [conn-id2]} logons))))))))
+ (testing "SASL RESUME fails with wrong nick"
+ (let [components (test-components-with-network)
+ out (java.io.ByteArrayOutputStream.)
+ conn (:conn components)
+ conn-id (java.util.UUID/randomUUID)
+ _ @(d/transact conn
+ [{:db/ensure :papod.connection/attrs
+ :papod.connection/id conn-id
+ :papod.connection/process [:papod.process/id
+ (:process-id components)]
+ :papod.connection/created-at (java.util.Date.)}])
+ ;; Create a session for "alice"
+ session-id (java.util.UUID/randomUUID)
+ _ @(d/transact conn
+ [{:db/ensure :papod.session/attrs
+ :papod.session/id session-id
+ :papod.session/nick "alice"
+ :papod.session/created-at (java.util.Date.)}])
+ ;; Try to resume as "bob"
+ c (atom {:nick "bob" :w out :caps #{"sasl"}
+ :connection-id conn-id})]
+ (handle-authenticate ["RESUME"] c components)
+ (let [replies (handle-authenticate
+ [(b64 (str session-id))] c components)]
+ (is (string/includes? (first replies) "904"))
+ (is (not (:authenticated? @c))))))))
+
(defn -main
[& _args]
(binding [*out* *err*]