| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | func 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). |
| 38 | func 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). |
| 90 | func 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. |
| 114 | func 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. |
| 125 | func 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 | |
| 163 | func 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. |
| 171 | func 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 | |