| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "lchat/internal/bot" |
| 14 | "lchat/internal/e2ee" |
| 15 | ) |
| 16 | |
| 17 | // postTyped posts an arbitrary (already-shaped) payload encrypted under the |
| 18 | // thread key — used to inject connect.op / connect.oauth messages the way the |
| 19 | // browser (shell.js) does. |
| 20 | func postTyped(t *testing.T, tsURL string, sess *bot.Session, threadID string, key []byte, payload map[string]any) { |
| 21 | t.Helper() |
| 22 | blob, err := e2ee.EncryptJSON(key, payload) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) |
| 27 | resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body)) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | resp.Body.Close() |
| 32 | if resp.StatusCode != http.StatusOK { |
| 33 | t.Fatalf("post typed: %d", resp.StatusCode) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // typedReply returns the first message in the thread whose decrypted payload has |
| 38 | // the given type (connect.result / connect.token). |
| 39 | func typedReply(t *testing.T, s *Server, threadID string, key []byte, typ string) map[string]any { |
| 40 | t.Helper() |
| 41 | msgs, err := s.db.messagesAfter(threadID, 0, 200) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | for _, m := range msgs { |
| 46 | var p map[string]any |
| 47 | if e2ee.DecryptJSON(key, m.Blob, &p) == nil && p["type"] == typ { |
| 48 | return p |
| 49 | } |
| 50 | } |
| 51 | return nil |
| 52 | } |
| 53 | |
| 54 | // fakeDNSimple serves the minimal endpoints listDomains touches. |
| 55 | func fakeDNSimple(t *testing.T) *httptest.Server { |
| 56 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 57 | switch { |
| 58 | case r.URL.Path == "/v2/whoami": |
| 59 | json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"account": map[string]any{"id": 42}}}) |
| 60 | case strings.HasSuffix(r.URL.Path, "/domains"): |
| 61 | json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{ |
| 62 | {"name": "example.com", "state": "registered", "auto_renew": true, "expires_on": "2027-01-01", "account_id": 42}, |
| 63 | }}) |
| 64 | case strings.HasSuffix(r.URL.Path, "/prices"): |
| 65 | json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"renewal_price": 14.5}}) |
| 66 | default: |
| 67 | http.Error(w, "not found", 404) |
| 68 | } |
| 69 | })) |
| 70 | t.Cleanup(srv.Close) |
| 71 | return srv |
| 72 | } |
| 73 | |
| 74 | // TestBotConnectListDomains drives a connect.op (list) end-to-end: the member |
| 75 | // seals a DNSimple token to the bot in a grant, the bot opens it, lists domains |
| 76 | // against a fake DNSimple, and replies with a non-rendering connect.result the |
| 77 | // member matches by rid. |
| 78 | func TestBotConnectListDomains(t *testing.T) { |
| 79 | dns := fakeDNSimple(t) |
| 80 | t.Setenv("LCHAT_DNSIMPLE_BASE", dns.URL) |
| 81 | |
| 82 | ts, s := newTestServer(t) |
| 83 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 84 | eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| 85 | dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| 86 | |
| 87 | ready := make(chan struct{}) |
| 88 | ctx, cancel := context.WithCancel(context.Background()) |
| 89 | defer cancel() |
| 90 | rt := &bot.Runtime{Base: ts.URL, Session: eleven, |
| 91 | Handler: bot.CommandFloor("Eleven", false), Ready: func() { close(ready) }, Logf: t.Logf} |
| 92 | go rt.Run(ctx) |
| 93 | awaitReady(t, ready) |
| 94 | |
| 95 | // The member seals { service, token, exp } to the bot's public key — only |
| 96 | // the bot can open it — exactly like shell.js's requestData grant. |
| 97 | grant, err := e2ee.SealForPub(eleven.UserPub, map[string]any{ |
| 98 | "service": "dnsimple", "token": "scoped-tok", "exp": time.Now().Add(2 * time.Minute).UnixMilli(), |
| 99 | }) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 104 | postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| 105 | "type": "connect.op", "service": "dnsimple", "op": "list", "grant": grant, |
| 106 | "rid": "rid-1", "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| 107 | }) |
| 108 | |
| 109 | waitFor(t, "connect.result", func() bool { return typedReply(t, s, dmID, dmKey, "connect.result") != nil }) |
| 110 | res := typedReply(t, s, dmID, dmKey, "connect.result") |
| 111 | if res["rid"] != "rid-1" || res["op"] != "list" { |
| 112 | t.Fatalf("bad result envelope: %+v", res) |
| 113 | } |
| 114 | domains, _ := res["domains"].([]any) |
| 115 | if len(domains) != 1 { |
| 116 | t.Fatalf("want 1 domain, got %+v", res["domains"]) |
| 117 | } |
| 118 | d0 := domains[0].(map[string]any) |
| 119 | if d0["name"] != "example.com" || d0["price"].(float64) != 14.5 { |
| 120 | t.Fatalf("bad domain row: %+v", d0) |
| 121 | } |
| 122 | |
| 123 | // A grant sealed to someone else must NOT open — the bot stays silent (no |
| 124 | // second result), proving the capability is bound to the bot's key. |
| 125 | other, _ := e2ee.GenerateUserKeypair() |
| 126 | foreign, _ := e2ee.SealForPub(other.PublicKey, map[string]any{"service": "dnsimple", "token": "x", "exp": time.Now().Add(time.Minute).UnixMilli()}) |
| 127 | postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| 128 | "type": "connect.op", "service": "dnsimple", "op": "list", "grant": foreign, |
| 129 | "rid": "rid-2", "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| 130 | }) |
| 131 | // Post a second, addressed command AFTER the foreign grant. When the bot |
| 132 | // answers that (proving it processed past the foreign op), the foreign op |
| 133 | // must have produced no connect.result — a clean ordering check, no sleep. |
| 134 | postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{"name": "Owner", "text": "/help", "ts": time.Now().UnixMilli()}) |
| 135 | waitFor(t, "command after foreign grant", func() bool { |
| 136 | return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 |
| 137 | }) |
| 138 | for _, m := range mustMsgs(t, s, dmID) { |
| 139 | var p map[string]any |
| 140 | if e2ee.DecryptJSON(dmKey, m.Blob, &p) == nil && p["type"] == "connect.result" && p["rid"] == "rid-2" { |
| 141 | t.Fatal("bot answered a grant not sealed to it") |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func mustMsgs(t *testing.T, s *Server, threadID string) []Message { |
| 147 | t.Helper() |
| 148 | msgs, err := s.db.messagesAfter(threadID, 0, 200) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | return msgs |
| 153 | } |
| 154 | |
| 155 | // TestBotConnectOAuth drives the confidential OAuth exchange: the member sends |
| 156 | // {code, state}, the bot exchanges via a fake broker, seals the token back to |
| 157 | // the member, and the member opens it — the server only ever saw sealed bytes. |
| 158 | func TestBotConnectOAuth(t *testing.T) { |
| 159 | var gotAuth string |
| 160 | broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 161 | gotAuth = r.Header.Get("Authorization") |
| 162 | var body map[string]string |
| 163 | json.NewDecoder(r.Body).Decode(&body) |
| 164 | if body["code"] != "the-code" { |
| 165 | http.Error(w, `{"message":"bad code"}`, 400) |
| 166 | return |
| 167 | } |
| 168 | json.NewEncoder(w).Encode(map[string]string{"access_token": "member-token"}) |
| 169 | })) |
| 170 | t.Cleanup(broker.Close) |
| 171 | t.Setenv("LCHAT_CONNECT_BROKER_URL", broker.URL) |
| 172 | t.Setenv("LCHAT_CONNECT_BROKER_TOKEN", "instance-bearer") |
| 173 | |
| 174 | ts, s := newTestServer(t) |
| 175 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 176 | eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| 177 | dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| 178 | |
| 179 | ready := make(chan struct{}) |
| 180 | ctx, cancel := context.WithCancel(context.Background()) |
| 181 | defer cancel() |
| 182 | rt := &bot.Runtime{Base: ts.URL, Session: eleven, |
| 183 | Handler: bot.CommandFloor("Eleven", false), Ready: func() { close(ready) }, Logf: t.Logf} |
| 184 | go rt.Run(ctx) |
| 185 | awaitReady(t, ready) |
| 186 | |
| 187 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 188 | postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| 189 | "type": "connect.oauth", "service": "dnsimple", "code": "the-code", "state": "st", |
| 190 | "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| 191 | }) |
| 192 | |
| 193 | waitFor(t, "connect.token", func() bool { return typedReply(t, s, dmID, dmKey, "connect.token") != nil }) |
| 194 | tok := typedReply(t, s, dmID, dmKey, "connect.token") |
| 195 | sealed, _ := tok["sealed"].(string) |
| 196 | if sealed == "" { |
| 197 | t.Fatalf("no sealed token: %+v", tok) |
| 198 | } |
| 199 | if gotAuth != "Bearer instance-bearer" { |
| 200 | t.Fatalf("broker not authed with the instance token: %q", gotAuth) |
| 201 | } |
| 202 | // Only the member can open it. |
| 203 | var opened struct { |
| 204 | Secret string `json:"secret"` |
| 205 | } |
| 206 | if err := e2ee.OpenSealed(sealed, owner.UserPriv, &opened); err != nil { |
| 207 | t.Fatalf("member couldn't open the sealed token: %v", err) |
| 208 | } |
| 209 | if opened.Secret != "member-token" { |
| 210 | t.Fatalf("wrong token handed back: %q", opened.Secret) |
| 211 | } |
| 212 | } |
| 213 | |