eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// These tests exercise bot.Announcer — since the default "Announcer" identity's
4// retirement, the engine behind a custom recipe's `changelog` rule
5// (docs/bot-recipes.md). The identity name in here is just a label.
6
7import (
8 "bytes"
9 "encoding/json"
10 "net/http"
11 "os"
12 "testing"
13
14 "lchat/internal/bot"
15 "lchat/internal/e2ee"
16)
17
18// provisionTestBot mints an agent invite via the real admin API and claims it
19// with the real provisioning client.
20func provisionTestBot(t *testing.T, tsURL, name string) *bot.Session {
21 t.Helper()
22 secret := mintAgentInviteForTest(t, tsURL)
23 sess, err := bot.Provision(tsURL, secret, name)
24 if err != nil {
25 t.Fatalf("provision %s: %v", name, err)
26 }
27 return sess
28}
29
30// createGroupWith makes owner create a group thread with the announcer, both
31// keys wrapped client-side — exactly what a member's browser does when adding
32// a bot to a group. Returns the thread id and the raw thread key.
33func createGroupWith(t *testing.T, tsURL string, s *Server, owner, announcer *bot.Session) (string, []byte) {
34 t.Helper()
35 threadKey, err := e2ee.GenerateKey()
36 if err != nil {
37 t.Fatal(err)
38 }
39 ownerID, _ := s.db.deviceTokenUser(owner.Token)
40 annID, _ := s.db.deviceTokenUser(announcer.Token)
41 members := []map[string]string{}
42 for id, sess := range map[string]*bot.Session{ownerID: owner, annID: announcer} {
43 wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(threadKey), sess.UserPub)
44 if err != nil {
45 t.Fatal(err)
46 }
47 members = append(members, map[string]string{"user_id": id, "wrapped": wrapped})
48 }
49 body, _ := json.Marshal(map[string]any{"token": owner.Token, "kind": "group", "members": members})
50 resp, err := http.Post(tsURL+"/api/threads", "application/json", bytes.NewReader(body))
51 if err != nil {
52 t.Fatal(err)
53 }
54 defer resp.Body.Close()
55 var out struct {
56 ThreadID string `json:"thread_id"`
57 }
58 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ThreadID == "" {
59 t.Fatalf("create group: status %d err %v id %q", resp.StatusCode, err, out.ThreadID)
60 }
61 return out.ThreadID, threadKey
62}
63
64func TestAnnouncerAgainstRealServer(t *testing.T) {
65 ts, s := newTestServer(t)
66 if len(s.changelog) < 1 {
67 t.Fatal("embedded changelog is empty — the announcer has nothing to test with")
68 }
69
70 owner := provisionTestBot(t, ts.URL, "Owner")
71 annSess := provisionTestBot(t, ts.URL, "Announcer")
72 threadID, threadKey := createGroupWith(t, ts.URL, s, owner, annSess)
73
74 statePath := t.TempDir() + "/announcer-session.json.state.json"
75 a := &bot.Announcer{Base: ts.URL, Session: annSess, StatePath: statePath, Logf: t.Logf}
76
77 // First tick baselines silently: state written, nothing posted.
78 if err := a.Tick(); err != nil {
79 t.Fatalf("baseline tick: %v", err)
80 }
81 msgs, err := s.db.messagesAfter(threadID, 0, 100)
82 if err != nil {
83 t.Fatal(err)
84 }
85 if len(msgs) != 0 {
86 t.Fatalf("baseline tick posted %d messages, want 0", len(msgs))
87 }
88
89 // Forget the two newest entries (Node state shape) → next tick announces
90 // them, batched into one message.
91 raw, err := os.ReadFile(statePath)
92 if err != nil {
93 t.Fatal(err)
94 }
95 var state struct {
96 Announced []string `json:"announced"`
97 }
98 if err := json.Unmarshal(raw, &state); err != nil {
99 t.Fatal(err)
100 }
101 if len(state.Announced) != len(s.changelog) {
102 t.Fatalf("baselined %d ids, want %d", len(state.Announced), len(s.changelog))
103 }
104 forget := 1
105 if len(state.Announced) >= 2 {
106 forget = 2
107 }
108 state.Announced = state.Announced[:len(state.Announced)-forget]
109 raw, _ = json.Marshal(state)
110 if err := os.WriteFile(statePath, raw, 0o600); err != nil {
111 t.Fatal(err)
112 }
113
114 if err := a.Tick(); err != nil {
115 t.Fatalf("announce tick: %v", err)
116 }
117 msgs, err = s.db.messagesAfter(threadID, 0, 100)
118 if err != nil {
119 t.Fatal(err)
120 }
121 if len(msgs) != 1 {
122 t.Fatalf("announce tick posted %d messages, want 1 (batched)", len(msgs))
123 }
124
125 // The blob must decrypt under the thread key to the announcer's payload,
126 // carrying the forgotten entries' verbatim text.
127 var payload struct {
128 Name string `json:"name"`
129 Text string `json:"text"`
130 TS int64 `json:"ts"`
131 }
132 if err := e2ee.DecryptJSON(threadKey, msgs[0].Blob, &payload); err != nil {
133 t.Fatalf("decrypt announcement: %v", err)
134 }
135 if payload.Name != "Announcer" || payload.TS == 0 {
136 t.Errorf("bad payload envelope: %+v", payload)
137 }
138 if !bytes.Contains([]byte(payload.Text), []byte("🚀 Just shipped:")) {
139 t.Errorf("announcement missing header: %q", payload.Text)
140 }
141 for _, e := range s.changelog[len(s.changelog)-forget:] {
142 if !bytes.Contains([]byte(payload.Text), []byte(e.Text)) {
143 t.Errorf("announcement missing entry %s", e.ID)
144 }
145 }
146
147 // Kind self-declared as deterministic bot.
148 uid, _ := s.db.deviceTokenUser(annSess.Token)
149 var kind string
150 if err := s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind); err != nil || kind != "bot" {
151 t.Errorf("announcer kind: got %q (err %v), want \"bot\"", kind, err)
152 }
153
154 // A third tick has nothing new: no further messages.
155 if err := a.Tick(); err != nil {
156 t.Fatalf("idle tick: %v", err)
157 }
158 msgs, _ = s.db.messagesAfter(threadID, 0, 100)
159 if len(msgs) != 1 {
160 t.Errorf("idle tick posted more messages: %d total", len(msgs))
161 }
162}
163