package main import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) // postTyped posts an arbitrary (already-shaped) payload encrypted under the // thread key — used to inject connect.op / connect.oauth messages the way the // browser (shell.js) does. func postTyped(t *testing.T, tsURL string, sess *bot.Session, threadID string, key []byte, payload map[string]any) { t.Helper() blob, err := e2ee.EncryptJSON(key, payload) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("post typed: %d", resp.StatusCode) } } // typedReply returns the first message in the thread whose decrypted payload has // the given type (connect.result / connect.token). func typedReply(t *testing.T, s *Server, threadID string, key []byte, typ string) map[string]any { t.Helper() msgs, err := s.db.messagesAfter(threadID, 0, 200) if err != nil { t.Fatal(err) } for _, m := range msgs { var p map[string]any if e2ee.DecryptJSON(key, m.Blob, &p) == nil && p["type"] == typ { return p } } return nil } // fakeDNSimple serves the minimal endpoints listDomains touches. func fakeDNSimple(t *testing.T) *httptest.Server { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/v2/whoami": json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"account": map[string]any{"id": 42}}}) case strings.HasSuffix(r.URL.Path, "/domains"): json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{ {"name": "example.com", "state": "registered", "auto_renew": true, "expires_on": "2027-01-01", "account_id": 42}, }}) case strings.HasSuffix(r.URL.Path, "/prices"): json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"renewal_price": 14.5}}) default: http.Error(w, "not found", 404) } })) t.Cleanup(srv.Close) return srv } // TestBotConnectListDomains drives a connect.op (list) end-to-end: the member // seals a DNSimple token to the bot in a grant, the bot opens it, lists domains // against a fake DNSimple, and replies with a non-rendering connect.result the // member matches by rid. func TestBotConnectListDomains(t *testing.T) { dns := fakeDNSimple(t) t.Setenv("LCHAT_DNSIMPLE_BASE", dns.URL) ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) eleven := provisionNamed(t, ts.URL, "Eleven", true) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) ready := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Ready: func() { close(ready) }, Logf: t.Logf} go rt.Run(ctx) awaitReady(t, ready) // The member seals { service, token, exp } to the bot's public key — only // the bot can open it — exactly like shell.js's requestData grant. grant, err := e2ee.SealForPub(eleven.UserPub, map[string]any{ "service": "dnsimple", "token": "scoped-tok", "exp": time.Now().Add(2 * time.Minute).UnixMilli(), }) if err != nil { t.Fatal(err) } ownerUID, _ := s.db.deviceTokenUser(owner.Token) postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ "type": "connect.op", "service": "dnsimple", "op": "list", "grant": grant, "rid": "rid-1", "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), }) waitFor(t, "connect.result", func() bool { return typedReply(t, s, dmID, dmKey, "connect.result") != nil }) res := typedReply(t, s, dmID, dmKey, "connect.result") if res["rid"] != "rid-1" || res["op"] != "list" { t.Fatalf("bad result envelope: %+v", res) } domains, _ := res["domains"].([]any) if len(domains) != 1 { t.Fatalf("want 1 domain, got %+v", res["domains"]) } d0 := domains[0].(map[string]any) if d0["name"] != "example.com" || d0["price"].(float64) != 14.5 { t.Fatalf("bad domain row: %+v", d0) } // A grant sealed to someone else must NOT open — the bot stays silent (no // second result), proving the capability is bound to the bot's key. other, _ := e2ee.GenerateUserKeypair() foreign, _ := e2ee.SealForPub(other.PublicKey, map[string]any{"service": "dnsimple", "token": "x", "exp": time.Now().Add(time.Minute).UnixMilli()}) postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ "type": "connect.op", "service": "dnsimple", "op": "list", "grant": foreign, "rid": "rid-2", "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), }) // Post a second, addressed command AFTER the foreign grant. When the bot // answers that (proving it processed past the foreign op), the foreign op // must have produced no connect.result — a clean ordering check, no sleep. postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{"name": "Owner", "text": "/help", "ts": time.Now().UnixMilli()}) waitFor(t, "command after foreign grant", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 }) for _, m := range mustMsgs(t, s, dmID) { var p map[string]any if e2ee.DecryptJSON(dmKey, m.Blob, &p) == nil && p["type"] == "connect.result" && p["rid"] == "rid-2" { t.Fatal("bot answered a grant not sealed to it") } } } func mustMsgs(t *testing.T, s *Server, threadID string) []Message { t.Helper() msgs, err := s.db.messagesAfter(threadID, 0, 200) if err != nil { t.Fatal(err) } return msgs } // TestBotConnectOAuth drives the confidential OAuth exchange: the member sends // {code, state}, the bot exchanges via a fake broker, seals the token back to // the member, and the member opens it — the server only ever saw sealed bytes. func TestBotConnectOAuth(t *testing.T) { var gotAuth string broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") var body map[string]string json.NewDecoder(r.Body).Decode(&body) if body["code"] != "the-code" { http.Error(w, `{"message":"bad code"}`, 400) return } json.NewEncoder(w).Encode(map[string]string{"access_token": "member-token"}) })) t.Cleanup(broker.Close) t.Setenv("LCHAT_CONNECT_BROKER_URL", broker.URL) t.Setenv("LCHAT_CONNECT_BROKER_TOKEN", "instance-bearer") ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) eleven := provisionNamed(t, ts.URL, "Eleven", true) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) ready := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Ready: func() { close(ready) }, Logf: t.Logf} go rt.Run(ctx) awaitReady(t, ready) ownerUID, _ := s.db.deviceTokenUser(owner.Token) postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ "type": "connect.oauth", "service": "dnsimple", "code": "the-code", "state": "st", "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), }) waitFor(t, "connect.token", func() bool { return typedReply(t, s, dmID, dmKey, "connect.token") != nil }) tok := typedReply(t, s, dmID, dmKey, "connect.token") sealed, _ := tok["sealed"].(string) if sealed == "" { t.Fatalf("no sealed token: %+v", tok) } if gotAuth != "Bearer instance-bearer" { t.Fatalf("broker not authed with the instance token: %q", gotAuth) } // Only the member can open it. var opened struct { Secret string `json:"secret"` } if err := e2ee.OpenSealed(sealed, owner.UserPriv, &opened); err != nil { t.Fatalf("member couldn't open the sealed token: %v", err) } if opened.Secret != "member-token" { t.Fatalf("wrong token handed back: %q", opened.Secret) } }