eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// End-to-end tests for internal/client — the E2EE member client layer under
4// the eleven CLI — against the real router (newTestServer), the same way the
5// bot runtime is tested. They cover all three invite-link shapes Join claims
6// (fresh person, member-minted DM invite, add-device) and the live loop:
7// thread names decrypt, messages arrive over the WebSocket, sends round-trip.
8
9import (
10 "bytes"
11 "context"
12 "encoding/json"
13 "net/http"
14 "net/http/httptest"
15 "testing"
16 "time"
17
18 lclient "lchat/internal/client"
19 "lchat/internal/e2ee"
20)
21
22// joinForTest mints a plain member invite and joins as name.
23func joinForTest(t *testing.T, tsURL, name string) *lclient.Client {
24 t.Helper()
25 secret := mintInviteForTest(t, tsURL, "", false)
26 sess, res, err := lclient.Join(tsURL, secret, name)
27 if err != nil {
28 t.Fatalf("Join(%s): %v", name, err)
29 }
30 if res.AddDevice || res.ThreadID != "" {
31 t.Fatalf("plain invite reported %+v", res)
32 }
33 return &lclient.Client{Base: tsURL, Session: sess}
34}
35
36func TestElevenClientGroupChat(t *testing.T) {
37 ts, _ := newTestServer(t)
38 term := joinForTest(t, ts.URL, "Terminal")
39 friend := joinForTest(t, ts.URL, "Friend")
40
41 termUID, err := term.Me()
42 if err != nil {
43 t.Fatalf("Me: %v", err)
44 }
45 friendUID, err := friend.Me()
46 if err != nil {
47 t.Fatalf("Me: %v", err)
48 }
49
50 // Friend creates a named group with both members — what the web composer
51 // does: thread key wrapped to each member's roster pubkey, name encrypted
52 // under the thread key.
53 tk, err := e2ee.GenerateKey()
54 if err != nil {
55 t.Fatal(err)
56 }
57 tkB64 := e2ee.B64uEncode(tk)
58 wrapTo := func(pub string) string {
59 w, err := e2ee.WrapThreadKey(tkB64, pub)
60 if err != nil {
61 t.Fatal(err)
62 }
63 return w
64 }
65 nameBlob, err := e2ee.EncryptJSON(tk, map[string]string{"name": "CLI Test"})
66 if err != nil {
67 t.Fatal(err)
68 }
69 body, _ := json.Marshal(map[string]any{
70 "token": friend.Session.Token, "kind": "group", "name_blob": nameBlob,
71 "members": []map[string]string{
72 {"user_id": friendUID, "wrapped": wrapTo(friend.Session.UserPub)},
73 {"user_id": termUID, "wrapped": wrapTo(term.Session.UserPub)},
74 },
75 })
76 resp, err := http.Post(ts.URL+"/api/threads", "application/json", bytes.NewReader(body))
77 if err != nil {
78 t.Fatal(err)
79 }
80 var created struct {
81 ThreadID string `json:"thread_id"`
82 }
83 if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" {
84 t.Fatalf("create thread: %v (id %q)", err, created.ThreadID)
85 }
86 resp.Body.Close()
87
88 // The CLI's sidebar view: the group's name must decrypt.
89 threads, err := term.Threads()
90 if err != nil {
91 t.Fatalf("Threads: %v", err)
92 }
93 var found bool
94 for _, th := range threads {
95 if th.ID == created.ThreadID {
96 found = true
97 if th.Name != "CLI Test" {
98 t.Errorf("thread name %q, want CLI Test", th.Name)
99 }
100 if th.Kind != "group" {
101 t.Errorf("thread kind %q", th.Kind)
102 }
103 }
104 }
105 if !found {
106 t.Fatal("created thread not listed")
107 }
108
109 // Live loop: a message sent after the terminal connects must arrive
110 // decrypted, attributed, and not Mine.
111 got := make(chan lclient.Message, 8)
112 ready := make(chan struct{})
113 term.OnMessage = func(m lclient.Message) { got <- m }
114 term.OnReady = func() { ready <- struct{}{} }
115 ctx, cancel := context.WithCancel(context.Background())
116 defer cancel()
117 go term.Run(ctx)
118 select {
119 case <-ready:
120 case <-time.After(5 * time.Second):
121 t.Fatal("client never connected")
122 }
123
124 if err := friend.Send(created.ThreadID, "hello from the web"); err != nil {
125 t.Fatalf("friend Send: %v", err)
126 }
127 select {
128 case m := <-got:
129 if m.Text != "hello from the web" || m.Name != "Friend" || m.Mine || m.ThreadID != created.ThreadID {
130 t.Errorf("got %+v", m)
131 }
132 case <-time.After(5 * time.Second):
133 t.Fatal("message never arrived over the socket")
134 }
135
136 // And the other way: a terminal send must decrypt for the friend, and the
137 // terminal's own echo comes back flagged Mine.
138 if err := term.Send(created.ThreadID, "hello from the terminal"); err != nil {
139 t.Fatalf("term Send: %v", err)
140 }
141 select {
142 case m := <-got:
143 if m.Text != "hello from the terminal" || !m.Mine || !m.Echo {
144 t.Errorf("own echo: %+v", m)
145 }
146 case <-time.After(5 * time.Second):
147 t.Fatal("own echo never arrived")
148 }
149 hist, err := friend.History(created.ThreadID, 10)
150 if err != nil {
151 t.Fatalf("History: %v", err)
152 }
153 var sawTerminal bool
154 for _, m := range hist {
155 if m.Name == "Terminal" && m.Text == "hello from the terminal" {
156 sawTerminal = true
157 }
158 }
159 if !sawTerminal {
160 t.Errorf("friend's history is missing the terminal's message: %+v", hist)
161 }
162}
163
164func TestElevenClientDMInviteJoin(t *testing.T) {
165 ts, s := newTestServer(t)
166 if err := s.db.setUsersCanInvite(true); err != nil {
167 t.Fatal(err)
168 }
169 inviter := joinForTest(t, ts.URL, "Inviter")
170 inviterUID, err := inviter.Me()
171 if err != nil {
172 t.Fatal(err)
173 }
174 _ = inviterUID
175
176 // Mint a user invite the way the web does (POST /api/invites): the pending
177 // DM's key rides in the link, wrapped under the invite secret; the
178 // inviter's own copy is wrapped to their pubkey.
179 secret, err := e2ee.RandomSecret()
180 if err != nil {
181 t.Fatal(err)
182 }
183 inv, err := e2ee.DeriveInvite(secret)
184 if err != nil {
185 t.Fatal(err)
186 }
187 tk, err := e2ee.GenerateKey()
188 if err != nil {
189 t.Fatal(err)
190 }
191 tkB64 := e2ee.B64uEncode(tk)
192 linkWrapped, err := e2ee.EncryptJSON(inv.WrapKey, map[string]string{"threadKey": tkB64})
193 if err != nil {
194 t.Fatal(err)
195 }
196 selfWrapped, err := e2ee.WrapThreadKey(tkB64, inviter.Session.UserPub)
197 if err != nil {
198 t.Fatal(err)
199 }
200 // The invitee's suggested name, encrypted to the instance key by a real
201 // inviter — opaque to the server, so any blob satisfies the endpoint here.
202 labelBlob, err := e2ee.EncryptJSON(tk, "Joiner")
203 if err != nil {
204 t.Fatal(err)
205 }
206 body, _ := json.Marshal(map[string]string{
207 "token": inviter.Session.Token, "id": inv.ID, "claim_hash": inv.ClaimHash,
208 "wrapped": linkWrapped, "self_wrapped": selfWrapped, "label_blob": labelBlob,
209 })
210 resp, err := http.Post(ts.URL+"/api/invites", "application/json", bytes.NewReader(body))
211 if err != nil {
212 t.Fatal(err)
213 }
214 if resp.StatusCode != http.StatusOK {
215 t.Fatalf("mint user invite: HTTP %d", resp.StatusCode)
216 }
217 resp.Body.Close()
218
219 sess, res, err := lclient.Join(ts.URL, secret, "Joiner")
220 if err != nil {
221 t.Fatalf("Join: %v", err)
222 }
223 if res.AddDevice {
224 t.Error("DM invite reported add-device")
225 }
226 if res.ThreadID == "" {
227 t.Fatal("DM invite returned no pending thread")
228 }
229
230 // The joiner re-wrapped the DM key to itself during the claim, so both
231 // sides can talk straight away.
232 joiner := &lclient.Client{Base: ts.URL, Session: sess}
233 if err := joiner.Send(res.ThreadID, "hi, it's me"); err != nil {
234 t.Fatalf("joiner Send: %v", err)
235 }
236 hist, err := inviter.History(res.ThreadID, 5)
237 if err != nil {
238 t.Fatalf("inviter History: %v", err)
239 }
240 if len(hist) != 1 || hist[0].Text != "hi, it's me" || hist[0].Name != "Joiner" {
241 t.Errorf("inviter sees %+v", hist)
242 }
243}
244
245func TestElevenClientAddDeviceJoin(t *testing.T) {
246 ts, s := newTestServer(t)
247 phone := joinForTest(t, ts.URL, "Paul")
248 uid, err := phone.Me()
249 if err != nil {
250 t.Fatal(err)
251 }
252
253 // Mint an add-device link for the same person: the link carries their
254 // keypair, encrypted under the invite secret (join.js's payload.priv path).
255 secret, err := e2ee.RandomSecret()
256 if err != nil {
257 t.Fatal(err)
258 }
259 inv, err := e2ee.DeriveInvite(secret)
260 if err != nil {
261 t.Fatal(err)
262 }
263 wrapped, err := e2ee.EncryptJSON(inv.WrapKey, map[string]string{
264 "priv": phone.Session.UserPriv, "pub": phone.Session.UserPub,
265 })
266 if err != nil {
267 t.Fatal(err)
268 }
269 body, _ := json.Marshal(map[string]any{
270 "id": inv.ID, "claim_hash": inv.ClaimHash, "user_id": uid, "wrapped": wrapped,
271 })
272 req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", bytes.NewReader(body))
273 req.Header.Set("Authorization", "Bearer admintok")
274 req.Header.Set("Content-Type", "application/json")
275 resp, err := http.DefaultClient.Do(req)
276 if err != nil {
277 t.Fatal(err)
278 }
279 if resp.StatusCode != http.StatusOK {
280 t.Fatalf("mint add-device invite: HTTP %d", resp.StatusCode)
281 }
282 resp.Body.Close()
283
284 sess, res, err := lclient.Join(ts.URL, secret, "Paul")
285 if err != nil {
286 t.Fatalf("Join: %v", err)
287 }
288 if !res.AddDevice {
289 t.Error("add-device link not detected")
290 }
291 if sess.UserPub != phone.Session.UserPub || sess.UserPriv != phone.Session.UserPriv {
292 t.Error("add-device session doesn't carry the member's keypair")
293 }
294 if sess.Token == phone.Session.Token {
295 t.Error("add-device minted no fresh device token")
296 }
297 if got, ok := s.db.deviceTokenUser(sess.Token); !ok || got != uid {
298 t.Errorf("new device token maps to %q, want %q", got, uid)
299 }
300}
301
302// The signin round trip's last leg: credentials restored from a home-vault
303// blob must be a working member session on the instance they point at. This
304// is the real contract behind `eleven signin` — the vault entry IS the
305// session.
306func TestVaultRestoredSessionTalksToInstance(t *testing.T) {
307 ts, _ := newTestServer(t)
308 phone := joinForTest(t, ts.URL, "Paul")
309 friend := joinForTest(t, ts.URL, "Friend")
310
311 // A DM between them, so the restored terminal has something to read.
312 phoneUID, err := phone.Me()
313 if err != nil {
314 t.Fatal(err)
315 }
316 friendUID, err := friend.Me()
317 if err != nil {
318 t.Fatal(err)
319 }
320 tk, err := e2ee.GenerateKey()
321 if err != nil {
322 t.Fatal(err)
323 }
324 tkB64 := e2ee.B64uEncode(tk)
325 wrapP, err := e2ee.WrapThreadKey(tkB64, phone.Session.UserPub)
326 if err != nil {
327 t.Fatal(err)
328 }
329 wrapF, err := e2ee.WrapThreadKey(tkB64, friend.Session.UserPub)
330 if err != nil {
331 t.Fatal(err)
332 }
333 body, _ := json.Marshal(map[string]any{
334 "token": phone.Session.Token, "kind": "dm",
335 "members": []map[string]string{
336 {"user_id": phoneUID, "wrapped": wrapP},
337 {"user_id": friendUID, "wrapped": wrapF},
338 },
339 })
340 resp, err := http.Post(ts.URL+"/api/threads", "application/json", bytes.NewReader(body))
341 if err != nil {
342 t.Fatal(err)
343 }
344 var created struct {
345 ThreadID string `json:"thread_id"`
346 }
347 if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" {
348 t.Fatalf("create dm: %v (%q)", err, created.ThreadID)
349 }
350 resp.Body.Close()
351 if err := friend.Send(created.ThreadID, "seen from the phone"); err != nil {
352 t.Fatal(err)
353 }
354
355 // Seal the phone's credentials into a vault blob (what the member's other
356 // devices keep in home), then restore it the way FetchVault does.
357 vault := &lclient.Vault{V: 1, Servers: []lclient.VaultServer{{
358 URL: ts.URL, Name: "Test Space",
359 Credentials: &lclient.VaultCredentials{
360 Token: phone.Session.Token, UserPub: phone.Session.UserPub,
361 UserPriv: phone.Session.UserPriv, Name: phone.Session.Name,
362 },
363 }}}
364 dek, err := e2ee.GenerateKey()
365 if err != nil {
366 t.Fatal(err)
367 }
368 envelope, err := lclient.SealHomeBlob(vault, dek, map[string]string{"cred": "opaque"})
369 if err != nil {
370 t.Fatal(err)
371 }
372 homeMux := http.NewServeMux()
373 homeMux.HandleFunc("GET /api/blob", func(w http.ResponseWriter, r *http.Request) {
374 if r.Header.Get("Authorization") != "Bearer hometok" {
375 http.Error(w, "unauthorized", http.StatusUnauthorized)
376 return
377 }
378 w.Write(envelope)
379 })
380 home := httptest.NewServer(homeMux)
381 defer home.Close()
382
383 got, err := lclient.FetchVault(&lclient.HomeSession{V: 1, Base: home.URL, Token: "hometok", DEK: e2ee.B64uEncode(dek)})
384 if err != nil {
385 t.Fatalf("FetchVault: %v", err)
386 }
387 if len(got.Servers) != 1 {
388 t.Fatalf("vault: %+v", got)
389 }
390 sess := got.Servers[0].Session()
391 if sess == nil || sess.SpaceName != "Test Space" {
392 t.Fatalf("restored session: %+v", sess)
393 }
394 terminal := &lclient.Client{Base: sess.Base, Session: sess}
395 hist, err := terminal.History(created.ThreadID, 5)
396 if err != nil {
397 t.Fatalf("restored History: %v", err)
398 }
399 if len(hist) != 1 || hist[0].Text != "seen from the phone" {
400 t.Fatalf("restored terminal reads: %+v", hist)
401 }
402 if err := terminal.Send(created.ThreadID, "and the terminal answers"); err != nil {
403 t.Fatalf("restored Send: %v", err)
404 }
405 fhist, err := friend.History(created.ThreadID, 5)
406 if err != nil {
407 t.Fatal(err)
408 }
409 if len(fhist) != 2 || fhist[1].Text != "and the terminal answers" || fhist[1].Name != "Paul" {
410 t.Fatalf("friend sees: %+v", fhist)
411 }
412
413 // A revoked home token surfaces as ErrHomeAuth (the rerun-ceremony signal).
414 if _, err := lclient.FetchVault(&lclient.HomeSession{V: 1, Base: home.URL, Token: "wrong", DEK: e2ee.B64uEncode(dek)}); err != lclient.ErrHomeAuth {
415 t.Fatalf("bad token: %v", err)
416 }
417}
418