1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204 | (ns ird.p2p.db.browser-peer-test
"A real headless-Chrome peer replicating sealed records with a Node peer
THROUGH a Node circuit relay v2 hop, always on. Three peers, all running
ird.p2p.db.core: a relay (this library on Node with :relay-hop?, holding
an unrelated database), a Node peer that reserves a slot on it, and the
browser build of the test suite (target/browser-test, compiled by the
:browser-test shadow build with the esbuild vendor chunk) in Chrome,
launched by puppeteer, told where the relay and the Node peer are through
the page's query string. The browser side asserts its own view
(ird.p2p.db.browser.replication-test, run by cljs.test in the page — its
summary must be green); this side asserts the Node peer's view: the
browser joined through the hop, its record arrived, only ciphertext
crossed the wire, the relay saw nothing it could read."
(:require [clojure.string :as str]
[cljs.test :refer [deftest is testing]]
[cljs.core.async :as async :refer [go go-loop <!]]
[ird.p2p.async :as a]
[ird.p2p.db.core :as db]
[ird.p2p.db.test-util :as tu :refer [deftest-async]]
[ird.p2p.impl.bytes :as bytes]))
(def ^:private fs (js/require "node:fs"))
(def ^:private path (js/require "node:path"))
(def ^:private http (js/require "node:http"))
(def ^:private puppeteer (js/require "puppeteer"))
(def ^:private site-root "target/browser-test")
(def ^:private page-file "test/browser/index.html")
(defn- latin1 [b64] (.toString (bytes/b64->buffer b64) "latin1"))
;; ---- a static server for the compiled browser suite -------------------------------------------
(defn- content-type [f]
(cond (str/ends-with? f ".html") "text/html"
(str/ends-with? f ".map") "application/json"
:else "text/javascript"))
(defn- serve!
"Serve / from the test page and everything else from target/browser-test
on an ephemeral loopback port. Channel of {:port :close}."
[]
(let [srv (.createServer http
(fn [req res]
(let [p (first (str/split (.-url req) #"\?"))
f (if (= "/" p) page-file (.join path site-root (subs p 1)))]
(cond
(= "/favicon.ico" p) (do (.writeHead res 204) (.end res))
(and (.existsSync fs f) (.isFile (.statSync fs f)))
(do (.writeHead res 200 #js {"content-type" (content-type f)})
(.end res (.readFileSync fs f)))
:else (do (.writeHead res 404) (.end res))))))
c (async/promise-chan)]
(.listen srv 0 "127.0.0.1" (fn [] (async/put! c {:port (.-port (.address srv)) :close (fn [] (.close srv))})))
c))
;; ---- the page -------------------------------------------------------------------------------------
(defn- forward-console!
"Print a browser console message on this side, prefixed; an Error argument
(which puppeteer renders as JSHandle@error) is expanded to its stack."
[^js m]
(let [args (array-seq (.args m))]
(if (some (fn [^js a] (str/starts-with? (str a) "JSHandle@error")) args)
(-> (js/Promise.all (to-array (map (fn [^js a] (.evaluate a "e => (e && e.stack) || String(e)")) args)))
(.then (fn [vs] (println "[browser]" (str/join " " (array-seq vs)))))
(.catch (fn [_] (println "[browser]" (.text m)))))
(println "[browser]" (.text m)))))
(defn- <eval [page js] (a/promise->chan (.evaluate page js)))
(defn- <phase
"Channel of the browser's current {:phase :info}."
[page]
(go (let [v (<! (<eval page "({phase: window.__ird_phase || null, info: window.__ird_info || null})"))]
(when-not (tu/error? v) (js->clj v :keywordize-keys true)))))
(defn- wait-phase
"Channel of the browser's {:phase :info} once it announces PHASE, or nil at TIMEOUT-MS."
[page phase timeout-ms]
(let [deadline (+ (js/Date.now) timeout-ms)]
(go-loop []
(let [p (<! (<phase page))]
(cond
(= phase (:phase p)) p
(>= (js/Date.now) deadline) nil
:else (do (<! (async/timeout 200)) (recur)))))))
(defn- <summary
"Channel of the browser suite's cljs.test summary {:test :pass :fail :error}, or nil at TIMEOUT-MS."
[page timeout-ms]
(let [deadline (+ (js/Date.now) timeout-ms)]
(go-loop []
(let [v (<! (<eval page "window.__ird_test || null"))]
(cond
(and v (not (tu/error? v))) (js->clj v :keywordize-keys true)
(>= (js/Date.now) deadline) nil
:else (do (<! (async/timeout 200)) (recur)))))))
(defn- query [m]
(str/join "&" (map (fn [[k v]] (str (name k) "=" (js/encodeURIComponent v))) m)))
;; ---- the test ---------------------------------------------------------------------------------------
(deftest-async headless-chrome-peer-replicates-through-a-node-relay-hop
{:timeout-ms 240000}
(is (.existsSync fs (.join path site-root "js/test.js")) "the browser suite is compiled (shadow-cljs compile browser-test)")
(is (.existsSync fs (.join path site-root "vendor.js")) "the vendor chunk is bundled (npm run test:prepare)")
(let [secret (db/random-secret)
;; the hop: a Node peer of the swarm running the circuit relay service, holding an unrelated database
relay (<! (db/open {:secret (db/random-secret) :app-salt "test/v1" :name "relay-only"
:relay-hop? true :listen ["/ip4/127.0.0.1/tcp/0/ws"]}))
_ (is (not (tu/error? relay)) (some-> relay ex-message))
relay-addr (first (filter #(str/includes? % "/ws/") (<! (db/multiaddrs relay))))
;; the Node peer: reachable through the relay
n (<! (db/open {:secret secret :app-salt "test/v1" :name "chat" :relay relay-addr}))
_ (is (not (tu/error? n)) (some-> n ex-message))
circuit (first (filter #(str/includes? % "/p2p-circuit/") (<! (db/multiaddrs n))))]
(testing "the hop and the Node peer"
(is (some? relay-addr))
(is (= #{"identify" "pubsub" "relay"} (set (:services (:node relay)))) "the relay runs the hop service, and nothing else new")
(is (= #{"identify" "pubsub"} (set (:services (:node n)))) "the Node peer runs no hop")
(is (= ["libp2p-router"] (:routers (:node n))))
(is (= (str relay-addr "/p2p-circuit/p2p/" (db/peer-id n)) circuit) "the Node peer reserved a slot on the relay")
(is (some #{(db/peer-id relay)} ((:peers (:node n)))) "…and is connected to it")
(is (not= (db/address relay) (db/address n)) "the relay holds another database"))
(let [r1 (<! (db/append n {:text "unu" :n 1}))
r2 (<! (db/append n {:text "doi" :n 2}))
_ (is (and (:hash r1) (:hash r2)))
cn (tu/collector (db/entries-stream n))
pn (tu/collector (db/peers-stream n))
wire (tu/collector ((:sniff! (:node n))))
relay-wire (tu/collector ((:sniff! (:node relay))))
server (<! (serve!))
browser (<! (a/promise->chan (.launch puppeteer #js {:headless true})))
page (<! (a/promise->chan (.newPage browser)))
url (str "http://127.0.0.1:" (:port server) "/?"
(query {:relay relay-addr :target circuit :address (db/address n)
:secret secret :app-salt "test/v1" :name "chat"}))]
(.on page "console" (fn [m] (forward-console! m)))
(.on page "pageerror" (fn [e] (println "[browser pageerror]" (.-message e))))
(try
(let [nav (<! (a/promise->chan (.goto page url)))]
(is (not (tu/error? nav)) (some-> nav ex-message)))
(let [{:keys [info] :as joined} (<! (wait-phase page "joined" 90000))]
(is (some? joined) "the browser peer joined through the relay")
(when joined
(let [bpeer (:peer-id info)]
(is (string? bpeer))
(is (= [(str relay-addr "/p2p-circuit/p2p/" bpeer)] (:multiaddrs info)) "the browser is reachable only through the relay")
(is (some? (<! (tu/wait-for pn #(and (= :peer-joined (:type %)) (= bpeer (:peer %))) 30000))) "the Node peer exchanged heads with the browser")
(is (some #{bpeer} (db/peers n)))
(is (some #(and (= bpeer (:peer %)) (str/includes? (:addr %) "/p2p-circuit")) ((:connections (:node n))))
"the browser's connection to the Node peer is relayed")
(is (= [(:hash r1) (:hash r2)] (:hashes info)) "the browser caught up with the same two entries")
;; live, Node -> browser
(let [marker "plaintext-marker-3"
r3 (<! (db/append n {:text "trei" :n 3 :marker marker}))]
(is (not (tu/error? r3)))
(<! (<eval page (str "window.__ird_live = " (js/JSON.stringify #js {:hash (:hash r3) :marker marker}))))
;; live, browser -> Node
(let [{:keys [info] :as appended} (<! (wait-phase page "appended" 60000))]
(is (some? appended) "the browser appended")
(when appended
(is (some? (<! (tu/wait-for cn #(= 4 (get-in % [:payload :n])) 30000))) "the Node peer received the browser's record through the relay")
(let [r4 (last @cn)]
(is (= (:hash info) (:hash r4)))
(is (= {:text "patru" :n 4 :marker "browser-plaintext-4"} (:payload r4)))
(is (= (:by r1) (:by r4)) "one shared writer identity")
(testing "ciphertext only: what the Node peer stored and what crossed its wire"
(let [sealed (:payload (<! ((:get (:db n)) (:hash r4))))
stored (<! ((:entry-bytes (:db n)) (:hash r4)))]
(is (bytes/valid-b64? sealed))
(is (= 2 (bytes/byte-at sealed 0)))
(is (some? stored))
(is (str/includes? (latin1 stored) sealed))
(is (not (str/includes? (latin1 stored) "browser-plaintext-4")))
(is (some? (<! (tu/wait-for wire #(str/includes? (latin1 (:data %)) sealed) 10000)))
"the browser's record arrived over gossipsub as the sealed bytes")
(is (every? #(= (db/address n) (:topic %)) @wire))
(is (not-any? #(let [s (latin1 (:data %))] (or (str/includes? s "browser-plaintext-4") (str/includes? s marker) (str/includes? s "\"text\""))) @wire)
"no plaintext ever crossed the Node peer's wire")))))))
(let [done (<! (wait-phase page "done" 90000))]
(is (some? done) "the browser suite ran to the end"))
(testing "the relay saw nothing it could read"
(is (empty? (db/peers relay)) "not a peer of the database")
(is (empty? (<! (db/entries relay))))
(is (empty? @relay-wire) "not on the database's pubsub topic"))
(testing "the browser suite is green"
(let [{:keys [test pass fail error] :as s} (<! (<summary page 30000))]
(is (some? s) "the browser reported its cljs.test summary")
(println "[browser summary]" (pr-str s))
(is (<= 1 test))
(is (<= 20 pass))
(is (= 0 fail))
(is (= 0 error))))
(is (true? (<! (tu/eventually #(not-any? #{bpeer} (db/peers n)) 20000))) "the browser peer left when it closed"))))
(finally
(<! (a/promise->chan (.close browser)))
((:close server))
(<! (db/close n))
(<! (db/close relay))))
(is (= :closed @(:state n)))
(is (= :closed @(:state relay))))))
|