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 | (ns ird.p2p.db.browser.replication-test
"The browser peer, run in headless Chrome by ird.p2p.db.browser-peer-test
(Node): the same ird.p2p.db.core, the same secret, joining the private
swarm through a circuit relay v2 hop the Node harness runs, replicating
sealed records with a Node peer it reaches only through that hop — history
caught up on join, live records both ways over gossipsub — with nothing
but ciphertext crossing the relay, and a wrong secret refused. The
relay's address, the Node peer's /p2p-circuit address and the database
come in the page's query string (ird.p2p.db.browser.harness). Plus the
browser's storage: with :path, records and the peer id survive a close
and reopen in IndexedDB (Level's browser backend)."
(:require [clojure.string :as str]
[cljs.test :refer [deftest is testing]]
[cljs.core.async :as async :refer [<!]]
[ird.p2p.db.core :as db]
[ird.p2p.db.errors :as err]
[ird.p2p.db.browser.harness :as h]
[ird.p2p.db.test-util :as tu :refer [deftest-async]]
[ird.p2p.impl.bytes :as bytes]))
(defn- latin1 [b64] (.toString (bytes/b64->buffer b64) "latin1"))
(defn- peer-of
"The peer id a multiaddr ends in."
[addr]
(last (str/split addr #"/")))
(defn- entry-count
"Channel of the number of entries HANDLE's log holds once it reaches at
least N (polling), or the count at TIMEOUT-MS."
[handle n timeout-ms]
(let [deadline (+ (js/Date.now) timeout-ms)]
(async/go-loop []
(let [c (count (<! ((:all (:db handle)))))]
(if (or (<= n c) (>= (js/Date.now) deadline))
c
(do (<! (async/timeout 200)) (recur)))))))
(deftest-async browser-peer-replicates-through-a-relay
{:timeout-ms 120000}
(let [{:keys [relay target address secret app-salt name]} @h/config
target-peer (peer-of target)
b (<! (db/open {:secret secret :app-salt app-salt :name name :relay relay :dial [target]}))]
(is (not (tu/error? b)) (some-> b ex-message))
(testing "a browser node on the same private swarm"
(is (= :browser (:platform (:node b))))
(is (= ["libp2p-router"] (:routers (:node b))) "no delegated HTTP routing")
(is (= #{"identify" "pubsub"} (set (:services (:node b)))) "no hop service, no dht, no autonat")
(is (some #{"bitswap"} (:block-brokers (:node b)))))
(let [addrs (<! (db/multiaddrs b))
conns ((:connections (:node b)))]
(testing "reachable only through the relay it reserved on, reaching the Node peer only through the hop"
(is (= [(str relay "/p2p-circuit/p2p/" (db/peer-id b))] addrs))
(is (some #(and (= target-peer (:peer %)) (str/includes? (:addr %) "/p2p-circuit/")) conns) (pr-str conns))
(is (not-any? :limited? conns) "the user-run hop applies no limits"))
(is (= address (db/address b)) "the same address as the Node peer, derived from the secret")
(let [cb (tu/collector (db/entries-stream b))
pb (tu/collector (db/peers-stream b))
wire (tu/collector ((:sniff! (:node b))))]
(is (some? (<! (tu/wait-for pb #(and (= :peer-joined (:type %)) (= target-peer (:peer %))) 30000)))
"exchanged heads with the Node peer")
(is (some? (<! (tu/wait-for cb #(= 2 (get-in % [:payload :n])) 30000))) "caught up with the Node peer's history")
(is (= [1 2] (mapv #(get-in % [:payload :n]) @cb)) "in canonical order")
(is (= [{:text "unu" :n 1} {:text "doi" :n 2}] (mapv :payload @cb)) "and decrypted")
(is (some #{target-peer} (db/peers b)))
(h/phase! "joined" {:peer-id (db/peer-id b) :multiaddrs addrs :connections conns :hashes (mapv :hash @cb)})
;; live, Node -> browser: the harness appends once it sees us joined
(is (true? (<! (tu/eventually #(some? (h/live-cmd)) 30000))) "the Node harness announced its live record")
(let [{:keys [hash marker]} (h/live-cmd)]
(is (some? (<! (tu/wait-for cb #(= hash (:hash %)) 30000))) "received the live record through the relay")
(is (= marker (get-in (last @cb) [:payload :marker])))
(is (= 3 (get-in (last @cb) [:clock :time])))
(testing "ciphertext only crossed the relay"
(let [sealed (:payload (<! ((:get (:db b)) hash)))]
(is (bytes/valid-b64? sealed) "the stored value is the sealed record")
(is (= 2 (bytes/byte-at sealed 0)))
(is (some? (<! (tu/wait-for wire #(str/includes? (latin1 (:data %)) sealed) 10000)))
"the live record arrived over gossipsub as the sealed bytes")
(is (every? #(= (db/address b) (:topic %)) @wire))
(is (not-any? #(let [s (latin1 (:data %))] (or (str/includes? s marker) (str/includes? s "\"text\""))) @wire)
"no plaintext on the wire"))))
;; live, browser -> Node
(let [r4 (<! (db/append b {:text "patru" :n 4 :marker "browser-plaintext-4"}))]
(is (not (tu/error? r4)) (some-> r4 ex-message))
(is (= 4 (get-in r4 [:clock :time])))
(is (= (:by (first @cb)) (:by r4)) "one shared writer identity")
(h/phase! "appended" {:hash (:hash r4)}))
(testing "a wrong secret joins the log through the same relay but never writes and never reads"
(let [c (<! (db/open {:secret (db/random-secret) :app-salt app-salt :name name :address address
:relay relay :dial [target]}))]
(is (not (tu/error? c)) (some-> c ex-message))
(is (= address (db/address c)))
(is (not= (:identity b) (:identity c)))
(let [r (<! (db/append c {:text "forged" :n 99}))]
(is (err/append-rejected? r) (pr-str r)))
(let [cc (tu/collector (db/entries-stream c))
pc (tu/collector (db/peers-stream c))]
(is (some? (<! (tu/wait-for pc #(= :peer-joined (:type %)) 30000))) "synced")
(is (= 4 (<! (entry-count c 4 30000))) "…and holds all four ciphertext entries")
(<! (async/timeout 500))
(is (empty? @cc) "nothing decrypts under the wrong key")
(is (empty? (<! (db/entries c)))))
(<! (db/close c))))
(<! (db/close b))
(is (= :closed @(:state b)))
(h/phase! "done" {})))))
(deftest-async browser-path-persists-in-indexeddb
{:timeout-ms 60000}
(let [{:keys [app-salt]} @h/config
secret (db/random-secret)
path (str "ird-p2p-db-test-" (subs (db/random-secret) 0 8))
a (<! (db/open {:secret secret :app-salt app-salt :name "notes" :path path}))]
(is (not (tu/error? a)) (some-> a ex-message))
(is (= [] (<! (db/multiaddrs a))) "a browser without a relay listens on nothing")
(let [r (<! (db/append a {:text "kept" :n 1}))]
(is (not (tu/error? r)) (some-> r ex-message))
(<! (db/close a))
(is (= :closed @(:state a)))
(let [b (<! (db/open {:secret secret :app-salt app-salt :name "notes" :path path}))]
(is (not (tu/error? b)) (some-> b ex-message))
(is (= (db/peer-id a) (db/peer-id b)) "the same peer id: the key survived in IndexedDB")
(is (= (db/address a) (db/address b)))
(let [es (<! (db/entries b))]
(is (= 1 (count es)) "the record survived in IndexedDB")
(is (= (:hash r) (:hash (first es))))
(is (= {:text "kept" :n 1} (:payload (first es)))))
(<! (db/close b))
(is (= :closed @(:state b)))))
(let [names (<! (async/go (let [dbs (<! (ird.p2p.async/promise->chan (.databases js/indexedDB)))]
(mapv #(.-name %) (array-seq dbs)))))]
;; Level's browser backend names its IndexedDB databases level-js-<path>
(is (= 5 (count (filter #(str/starts-with? % (str "level-js-" path)) names)))
(str "IndexedDB holds the blockstore, datastore, keystore, heads and index named after :path: " (pr-str names))))))
|