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
3import (
4 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12)
13
14// swapForBotTests points attachBot's seams at test doubles: invites mint
15// against a real httptest instance, chown is a no-op (no service user on a
16// dev machine), and systemctl records instead of running.
17func swapForBotTests(t *testing.T, instanceURL string) *[]string {
18 t.Helper()
19 oldData, oldMint, oldChown, oldSysctl := instanceDataDir, botMintBase, chownToService, systemctl
20 t.Cleanup(func() {
21 instanceDataDir, botMintBase, chownToService, systemctl = oldData, oldMint, oldChown, oldSysctl
22 })
23 instanceDataDir = t.TempDir()
24 botMintBase = func(string) string { return instanceURL }
25 chownToService = func(string) {}
26 var calls []string
27 systemctl = func(args ...string) error {
28 calls = append(calls, strings.Join(args, " "))
29 return nil
30 }
31 return &calls
32}
33
34// attachBot end to end against a real instance: invites minted (Eleven-only
35// for the provisioner path), env staged with the right session vars, the unit
36// started — and a second attach is a pure no-op (idempotence is what lets the
37// opt-in signal fire on every flip).
38func TestAttachBotStagesElevenOnly(t *testing.T) {
39 ts, s := newTestServer(t)
40 calls := swapForBotTests(t, ts.URL)
41 host := "space.11ch.at"
42 if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil {
43 t.Fatal(err)
44 }
45
46 msg, err := attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@")
47 if err != nil {
48 t.Fatalf("attachBot: %v", err)
49 }
50 if !strings.Contains(msg, "started lchat-bot@"+host) {
51 t.Fatalf("msg = %q", msg)
52 }
53 env, err := readEnvFile(botEnvPath(host))
54 if err != nil {
55 t.Fatal(err)
56 }
57 if env["LCHAT_ELEVEN_INVITE"] == "" || env["LCHAT_ELEVEN_SESSION"] == "" {
58 t.Fatalf("Eleven identity missing from env: %v", env)
59 }
60 if env["LCHAT_INVITE"] != "" || env["LCHAT_SESSION"] != "" {
61 t.Fatalf("provisioner path must stage Eleven only, got %v", env)
62 }
63 if env["ANTHROPIC_API_KEY"] != "sk-test" || env["LCHAT_BASE"] != "https://"+host {
64 t.Fatalf("env: %v", env)
65 }
66 if len(*calls) != 1 || (*calls)[0] != "enable --now lchat-bot@"+host {
67 t.Fatalf("systemctl calls: %v", *calls)
68 }
69 // The minted invite is real: it resolves on the instance under its
70 // derived id (the sidecar redeems it at first boot — #806).
71 if n := countAgentInvites(t, s); n != 1 {
72 t.Fatalf("agent invites on the instance = %d, want 1", n)
73 }
74
75 // Idempotent: attach again → no new invite, no unit action, "already".
76 msg, err = attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@")
77 if err != nil || msg != "already staged" {
78 t.Fatalf("re-attach: %q %v", msg, err)
79 }
80 if len(*calls) != 1 {
81 t.Fatalf("re-attach touched systemd: %v", *calls)
82 }
83 if n := countAgentInvites(t, s); n != 1 {
84 t.Fatalf("re-attach minted again: %d invites", n)
85 }
86}
87
88// A hibernating host stages the env and leaves systemd alone — the activator
89// co-spawns (at wake, or the sweep adopts an awake child within seconds).
90func TestAttachBotHibernatingSkipsUnit(t *testing.T) {
91 ts, _ := newTestServer(t)
92 calls := swapForBotTests(t, ts.URL)
93 host := "sleepy.11ch.at"
94 if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil {
95 t.Fatal(err)
96 }
97 msg, err := attachBot(host, true, []botIdentitySpec{elevenIdentity}, "", "", "lchat-bot@")
98 if err != nil {
99 t.Fatalf("attachBot: %v", err)
100 }
101 if !strings.Contains(msg, "activator co-spawns") {
102 t.Fatalf("msg = %q", msg)
103 }
104 if len(*calls) != 0 {
105 t.Fatalf("hibernating attach must not touch systemd: %v", *calls)
106 }
107 if _, err := os.Stat(botEnvPath(host)); err != nil {
108 t.Fatalf("bot env not staged: %v", err)
109 }
110}
111
112// countAgentInvites counts the bot identities a mint created — the agent flag
113// lives on the pre-seeded user row, not the invite row.
114func countAgentInvites(t *testing.T, s *Server) int {
115 t.Helper()
116 var n int
117 if err := s.db.sql.QueryRow(`SELECT COUNT(*) FROM users WHERE COALESCE(is_agent, 0) = 1`).Scan(&n); err != nil {
118 t.Fatal(err)
119 }
120 return n
121}
122
123// The cloud's opt-in endpoint: meter-token auth resolves the space, controld
124// gets the attach call, a stranger is refused.
125func TestAIOptInHandler(t *testing.T) {
126 db := openTestCloudDB(t)
127 id := insertSpaceRow(t, db, "space.11ch.at", 1000)
128 if err := db.setMeterTokenHash(id, hashToken("meter-tok")); err != nil {
129 t.Fatal(err)
130 }
131 var attached []string
132 mux := http.NewServeMux()
133 mux.HandleFunc("POST /instances/{host}/bot", func(w http.ResponseWriter, r *http.Request) {
134 attached = append(attached, r.PathValue("host"))
135 writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": "started"})
136 })
137 sock := fakeControl(t, mux.ServeHTTP)
138 s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"}
139
140 call := func(token string) int {
141 req := httptest.NewRequest("POST", "/api/ai-optin", nil)
142 if token != "" {
143 req.Header.Set("Authorization", "Bearer "+token)
144 }
145 rec := httptest.NewRecorder()
146 s.aiOptInHandler(rec, req)
147 return rec.Code
148 }
149 if code := call("meter-tok"); code != http.StatusOK {
150 t.Fatalf("opt-in: %d", code)
151 }
152 if len(attached) != 1 || attached[0] != "space.11ch.at" {
153 t.Fatalf("controld attach calls: %v", attached)
154 }
155 if code := call("wrong"); code != http.StatusForbidden {
156 t.Fatal("stranger must be refused")
157 }
158 if len(attached) != 1 {
159 t.Fatalf("refused call still attached: %v", attached)
160 }
161}
162
163func TestMeterSibling(t *testing.T) {
164 if got := meterSibling("https://cloud.example/api/usage", "ai-optin"); got != "https://cloud.example/api/ai-optin" {
165 t.Fatalf("meterSibling = %q", got)
166 }
167}
168
169// Flipping AI on in the admin settings asks the provisioner for the sidecar;
170// re-sending true (no transition) and flipping off stay silent.
171func TestSetSettingsAIFlipSignals(t *testing.T) {
172 ts, s := newTestServer(t)
173 signals := make(chan struct{}, 8)
174 cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175 if r.URL.Path == "/api/ai-optin" {
176 signals <- struct{}{}
177 }
178 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
179 }))
180 defer cloud.Close()
181 s.meterURL, s.meterToken = cloud.URL+"/api/usage", "meter-tok"
182
183 set := func(on bool) {
184 t.Helper()
185 raw, _ := json.Marshal(map[string]any{"ai_enabled": on})
186 req, _ := http.NewRequest("POST", ts.URL+"/api/admin/settings", strings.NewReader(string(raw)))
187 req.Header.Set("Authorization", "Bearer admintok")
188 req.Header.Set("Content-Type", "application/json")
189 resp, err := http.DefaultClient.Do(req)
190 if err != nil {
191 t.Fatal(err)
192 }
193 resp.Body.Close()
194 if resp.StatusCode != http.StatusOK {
195 t.Fatalf("settings: %d", resp.StatusCode)
196 }
197 }
198 waitSignal := func(want bool) {
199 t.Helper()
200 select {
201 case <-signals:
202 if !want {
203 t.Fatal("unexpected opt-in signal")
204 }
205 case <-time.After(2 * time.Second):
206 if want {
207 t.Fatal("no opt-in signal arrived")
208 }
209 }
210 }
211 // THE core case: a fresh space's FIRST explicit "yes" signals, even
212 // though the implicit default already read as on (the reviewer-caught
213 // bug — a was/now comparison against the default swallowed it).
214 set(true)
215 waitSignal(true)
216 // Re-sending true is not new consent; turning off never signals.
217 set(true)
218 set(false)
219 select {
220 case <-signals:
221 t.Fatal("non-consent writes must not signal")
222 case <-time.After(300 * time.Millisecond):
223 }
224 // Off → on is consent again (the re-enable path re-asks the provisioner).
225 set(true)
226 waitSignal(true)
227}
228