| 1 | package 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 | |
| 9 | import ( |
| 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. |
| 23 | func 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 | |
| 36 | func 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 | |
| 164 | func 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 | |
| 245 | func 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. |
| 306 | func 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 | |