package main // End-to-end tests for internal/client — the E2EE member client layer under // the eleven CLI — against the real router (newTestServer), the same way the // bot runtime is tested. They cover all three invite-link shapes Join claims // (fresh person, member-minted DM invite, add-device) and the live loop: // thread names decrypt, messages arrive over the WebSocket, sends round-trip. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" lclient "lchat/internal/client" "lchat/internal/e2ee" ) // joinForTest mints a plain member invite and joins as name. func joinForTest(t *testing.T, tsURL, name string) *lclient.Client { t.Helper() secret := mintInviteForTest(t, tsURL, "", false) sess, res, err := lclient.Join(tsURL, secret, name) if err != nil { t.Fatalf("Join(%s): %v", name, err) } if res.AddDevice || res.ThreadID != "" { t.Fatalf("plain invite reported %+v", res) } return &lclient.Client{Base: tsURL, Session: sess} } func TestElevenClientGroupChat(t *testing.T) { ts, _ := newTestServer(t) term := joinForTest(t, ts.URL, "Terminal") friend := joinForTest(t, ts.URL, "Friend") termUID, err := term.Me() if err != nil { t.Fatalf("Me: %v", err) } friendUID, err := friend.Me() if err != nil { t.Fatalf("Me: %v", err) } // Friend creates a named group with both members — what the web composer // does: thread key wrapped to each member's roster pubkey, name encrypted // under the thread key. tk, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } tkB64 := e2ee.B64uEncode(tk) wrapTo := func(pub string) string { w, err := e2ee.WrapThreadKey(tkB64, pub) if err != nil { t.Fatal(err) } return w } nameBlob, err := e2ee.EncryptJSON(tk, map[string]string{"name": "CLI Test"}) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{ "token": friend.Session.Token, "kind": "group", "name_blob": nameBlob, "members": []map[string]string{ {"user_id": friendUID, "wrapped": wrapTo(friend.Session.UserPub)}, {"user_id": termUID, "wrapped": wrapTo(term.Session.UserPub)}, }, }) resp, err := http.Post(ts.URL+"/api/threads", "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } var created struct { ThreadID string `json:"thread_id"` } if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" { t.Fatalf("create thread: %v (id %q)", err, created.ThreadID) } resp.Body.Close() // The CLI's sidebar view: the group's name must decrypt. threads, err := term.Threads() if err != nil { t.Fatalf("Threads: %v", err) } var found bool for _, th := range threads { if th.ID == created.ThreadID { found = true if th.Name != "CLI Test" { t.Errorf("thread name %q, want CLI Test", th.Name) } if th.Kind != "group" { t.Errorf("thread kind %q", th.Kind) } } } if !found { t.Fatal("created thread not listed") } // Live loop: a message sent after the terminal connects must arrive // decrypted, attributed, and not Mine. got := make(chan lclient.Message, 8) ready := make(chan struct{}) term.OnMessage = func(m lclient.Message) { got <- m } term.OnReady = func() { ready <- struct{}{} } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go term.Run(ctx) select { case <-ready: case <-time.After(5 * time.Second): t.Fatal("client never connected") } if err := friend.Send(created.ThreadID, "hello from the web"); err != nil { t.Fatalf("friend Send: %v", err) } select { case m := <-got: if m.Text != "hello from the web" || m.Name != "Friend" || m.Mine || m.ThreadID != created.ThreadID { t.Errorf("got %+v", m) } case <-time.After(5 * time.Second): t.Fatal("message never arrived over the socket") } // And the other way: a terminal send must decrypt for the friend, and the // terminal's own echo comes back flagged Mine. if err := term.Send(created.ThreadID, "hello from the terminal"); err != nil { t.Fatalf("term Send: %v", err) } select { case m := <-got: if m.Text != "hello from the terminal" || !m.Mine || !m.Echo { t.Errorf("own echo: %+v", m) } case <-time.After(5 * time.Second): t.Fatal("own echo never arrived") } hist, err := friend.History(created.ThreadID, 10) if err != nil { t.Fatalf("History: %v", err) } var sawTerminal bool for _, m := range hist { if m.Name == "Terminal" && m.Text == "hello from the terminal" { sawTerminal = true } } if !sawTerminal { t.Errorf("friend's history is missing the terminal's message: %+v", hist) } } func TestElevenClientDMInviteJoin(t *testing.T) { ts, s := newTestServer(t) if err := s.db.setUsersCanInvite(true); err != nil { t.Fatal(err) } inviter := joinForTest(t, ts.URL, "Inviter") inviterUID, err := inviter.Me() if err != nil { t.Fatal(err) } _ = inviterUID // Mint a user invite the way the web does (POST /api/invites): the pending // DM's key rides in the link, wrapped under the invite secret; the // inviter's own copy is wrapped to their pubkey. secret, err := e2ee.RandomSecret() if err != nil { t.Fatal(err) } inv, err := e2ee.DeriveInvite(secret) if err != nil { t.Fatal(err) } tk, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } tkB64 := e2ee.B64uEncode(tk) linkWrapped, err := e2ee.EncryptJSON(inv.WrapKey, map[string]string{"threadKey": tkB64}) if err != nil { t.Fatal(err) } selfWrapped, err := e2ee.WrapThreadKey(tkB64, inviter.Session.UserPub) if err != nil { t.Fatal(err) } // The invitee's suggested name, encrypted to the instance key by a real // inviter — opaque to the server, so any blob satisfies the endpoint here. labelBlob, err := e2ee.EncryptJSON(tk, "Joiner") if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]string{ "token": inviter.Session.Token, "id": inv.ID, "claim_hash": inv.ClaimHash, "wrapped": linkWrapped, "self_wrapped": selfWrapped, "label_blob": labelBlob, }) resp, err := http.Post(ts.URL+"/api/invites", "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("mint user invite: HTTP %d", resp.StatusCode) } resp.Body.Close() sess, res, err := lclient.Join(ts.URL, secret, "Joiner") if err != nil { t.Fatalf("Join: %v", err) } if res.AddDevice { t.Error("DM invite reported add-device") } if res.ThreadID == "" { t.Fatal("DM invite returned no pending thread") } // The joiner re-wrapped the DM key to itself during the claim, so both // sides can talk straight away. joiner := &lclient.Client{Base: ts.URL, Session: sess} if err := joiner.Send(res.ThreadID, "hi, it's me"); err != nil { t.Fatalf("joiner Send: %v", err) } hist, err := inviter.History(res.ThreadID, 5) if err != nil { t.Fatalf("inviter History: %v", err) } if len(hist) != 1 || hist[0].Text != "hi, it's me" || hist[0].Name != "Joiner" { t.Errorf("inviter sees %+v", hist) } } func TestElevenClientAddDeviceJoin(t *testing.T) { ts, s := newTestServer(t) phone := joinForTest(t, ts.URL, "Paul") uid, err := phone.Me() if err != nil { t.Fatal(err) } // Mint an add-device link for the same person: the link carries their // keypair, encrypted under the invite secret (join.js's payload.priv path). secret, err := e2ee.RandomSecret() if err != nil { t.Fatal(err) } inv, err := e2ee.DeriveInvite(secret) if err != nil { t.Fatal(err) } wrapped, err := e2ee.EncryptJSON(inv.WrapKey, map[string]string{ "priv": phone.Session.UserPriv, "pub": phone.Session.UserPub, }) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{ "id": inv.ID, "claim_hash": inv.ClaimHash, "user_id": uid, "wrapped": wrapped, }) req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer admintok") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("mint add-device invite: HTTP %d", resp.StatusCode) } resp.Body.Close() sess, res, err := lclient.Join(ts.URL, secret, "Paul") if err != nil { t.Fatalf("Join: %v", err) } if !res.AddDevice { t.Error("add-device link not detected") } if sess.UserPub != phone.Session.UserPub || sess.UserPriv != phone.Session.UserPriv { t.Error("add-device session doesn't carry the member's keypair") } if sess.Token == phone.Session.Token { t.Error("add-device minted no fresh device token") } if got, ok := s.db.deviceTokenUser(sess.Token); !ok || got != uid { t.Errorf("new device token maps to %q, want %q", got, uid) } } // The signin round trip's last leg: credentials restored from a home-vault // blob must be a working member session on the instance they point at. This // is the real contract behind `eleven signin` — the vault entry IS the // session. func TestVaultRestoredSessionTalksToInstance(t *testing.T) { ts, _ := newTestServer(t) phone := joinForTest(t, ts.URL, "Paul") friend := joinForTest(t, ts.URL, "Friend") // A DM between them, so the restored terminal has something to read. phoneUID, err := phone.Me() if err != nil { t.Fatal(err) } friendUID, err := friend.Me() if err != nil { t.Fatal(err) } tk, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } tkB64 := e2ee.B64uEncode(tk) wrapP, err := e2ee.WrapThreadKey(tkB64, phone.Session.UserPub) if err != nil { t.Fatal(err) } wrapF, err := e2ee.WrapThreadKey(tkB64, friend.Session.UserPub) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{ "token": phone.Session.Token, "kind": "dm", "members": []map[string]string{ {"user_id": phoneUID, "wrapped": wrapP}, {"user_id": friendUID, "wrapped": wrapF}, }, }) resp, err := http.Post(ts.URL+"/api/threads", "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } var created struct { ThreadID string `json:"thread_id"` } if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" { t.Fatalf("create dm: %v (%q)", err, created.ThreadID) } resp.Body.Close() if err := friend.Send(created.ThreadID, "seen from the phone"); err != nil { t.Fatal(err) } // Seal the phone's credentials into a vault blob (what the member's other // devices keep in home), then restore it the way FetchVault does. vault := &lclient.Vault{V: 1, Servers: []lclient.VaultServer{{ URL: ts.URL, Name: "Test Space", Credentials: &lclient.VaultCredentials{ Token: phone.Session.Token, UserPub: phone.Session.UserPub, UserPriv: phone.Session.UserPriv, Name: phone.Session.Name, }, }}} dek, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } envelope, err := lclient.SealHomeBlob(vault, dek, map[string]string{"cred": "opaque"}) if err != nil { t.Fatal(err) } homeMux := http.NewServeMux() homeMux.HandleFunc("GET /api/blob", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer hometok" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } w.Write(envelope) }) home := httptest.NewServer(homeMux) defer home.Close() got, err := lclient.FetchVault(&lclient.HomeSession{V: 1, Base: home.URL, Token: "hometok", DEK: e2ee.B64uEncode(dek)}) if err != nil { t.Fatalf("FetchVault: %v", err) } if len(got.Servers) != 1 { t.Fatalf("vault: %+v", got) } sess := got.Servers[0].Session() if sess == nil || sess.SpaceName != "Test Space" { t.Fatalf("restored session: %+v", sess) } terminal := &lclient.Client{Base: sess.Base, Session: sess} hist, err := terminal.History(created.ThreadID, 5) if err != nil { t.Fatalf("restored History: %v", err) } if len(hist) != 1 || hist[0].Text != "seen from the phone" { t.Fatalf("restored terminal reads: %+v", hist) } if err := terminal.Send(created.ThreadID, "and the terminal answers"); err != nil { t.Fatalf("restored Send: %v", err) } fhist, err := friend.History(created.ThreadID, 5) if err != nil { t.Fatal(err) } if len(fhist) != 2 || fhist[1].Text != "and the terminal answers" || fhist[1].Name != "Paul" { t.Fatalf("friend sees: %+v", fhist) } // A revoked home token surfaces as ErrHomeAuth (the rerun-ceremony signal). if _, err := lclient.FetchVault(&lclient.HomeSession{V: 1, Base: home.URL, Token: "wrong", DEK: e2ee.B64uEncode(dek)}); err != lclient.ErrHomeAuth { t.Fatalf("bad token: %v", err) } }