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
3import (
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.
20func 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).
39func 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.
55func 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.
78func 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
146func 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.
158func 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