package main import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) // swapForBotTests points attachBot's seams at test doubles: invites mint // against a real httptest instance, chown is a no-op (no service user on a // dev machine), and systemctl records instead of running. func swapForBotTests(t *testing.T, instanceURL string) *[]string { t.Helper() oldData, oldMint, oldChown, oldSysctl := instanceDataDir, botMintBase, chownToService, systemctl t.Cleanup(func() { instanceDataDir, botMintBase, chownToService, systemctl = oldData, oldMint, oldChown, oldSysctl }) instanceDataDir = t.TempDir() botMintBase = func(string) string { return instanceURL } chownToService = func(string) {} var calls []string systemctl = func(args ...string) error { calls = append(calls, strings.Join(args, " ")) return nil } return &calls } // attachBot end to end against a real instance: invites minted (Eleven-only // for the provisioner path), env staged with the right session vars, the unit // started — and a second attach is a pure no-op (idempotence is what lets the // opt-in signal fire on every flip). func TestAttachBotStagesElevenOnly(t *testing.T) { ts, s := newTestServer(t) calls := swapForBotTests(t, ts.URL) host := "space.11ch.at" if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil { t.Fatal(err) } msg, err := attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@") if err != nil { t.Fatalf("attachBot: %v", err) } if !strings.Contains(msg, "started lchat-bot@"+host) { t.Fatalf("msg = %q", msg) } env, err := readEnvFile(botEnvPath(host)) if err != nil { t.Fatal(err) } if env["LCHAT_ELEVEN_INVITE"] == "" || env["LCHAT_ELEVEN_SESSION"] == "" { t.Fatalf("Eleven identity missing from env: %v", env) } if env["LCHAT_INVITE"] != "" || env["LCHAT_SESSION"] != "" { t.Fatalf("provisioner path must stage Eleven only, got %v", env) } if env["ANTHROPIC_API_KEY"] != "sk-test" || env["LCHAT_BASE"] != "https://"+host { t.Fatalf("env: %v", env) } if len(*calls) != 1 || (*calls)[0] != "enable --now lchat-bot@"+host { t.Fatalf("systemctl calls: %v", *calls) } // The minted invite is real: it resolves on the instance under its // derived id (the sidecar redeems it at first boot — #806). if n := countAgentInvites(t, s); n != 1 { t.Fatalf("agent invites on the instance = %d, want 1", n) } // Idempotent: attach again → no new invite, no unit action, "already". msg, err = attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@") if err != nil || msg != "already staged" { t.Fatalf("re-attach: %q %v", msg, err) } if len(*calls) != 1 { t.Fatalf("re-attach touched systemd: %v", *calls) } if n := countAgentInvites(t, s); n != 1 { t.Fatalf("re-attach minted again: %d invites", n) } } // A hibernating host stages the env and leaves systemd alone — the activator // co-spawns (at wake, or the sweep adopts an awake child within seconds). func TestAttachBotHibernatingSkipsUnit(t *testing.T) { ts, _ := newTestServer(t) calls := swapForBotTests(t, ts.URL) host := "sleepy.11ch.at" if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil { t.Fatal(err) } msg, err := attachBot(host, true, []botIdentitySpec{elevenIdentity}, "", "", "lchat-bot@") if err != nil { t.Fatalf("attachBot: %v", err) } if !strings.Contains(msg, "activator co-spawns") { t.Fatalf("msg = %q", msg) } if len(*calls) != 0 { t.Fatalf("hibernating attach must not touch systemd: %v", *calls) } if _, err := os.Stat(botEnvPath(host)); err != nil { t.Fatalf("bot env not staged: %v", err) } } // countAgentInvites counts the bot identities a mint created — the agent flag // lives on the pre-seeded user row, not the invite row. func countAgentInvites(t *testing.T, s *Server) int { t.Helper() var n int if err := s.db.sql.QueryRow(`SELECT COUNT(*) FROM users WHERE COALESCE(is_agent, 0) = 1`).Scan(&n); err != nil { t.Fatal(err) } return n } // The cloud's opt-in endpoint: meter-token auth resolves the space, controld // gets the attach call, a stranger is refused. func TestAIOptInHandler(t *testing.T) { db := openTestCloudDB(t) id := insertSpaceRow(t, db, "space.11ch.at", 1000) if err := db.setMeterTokenHash(id, hashToken("meter-tok")); err != nil { t.Fatal(err) } var attached []string mux := http.NewServeMux() mux.HandleFunc("POST /instances/{host}/bot", func(w http.ResponseWriter, r *http.Request) { attached = append(attached, r.PathValue("host")) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": "started"}) }) sock := fakeControl(t, mux.ServeHTTP) s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"} call := func(token string) int { req := httptest.NewRequest("POST", "/api/ai-optin", nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() s.aiOptInHandler(rec, req) return rec.Code } if code := call("meter-tok"); code != http.StatusOK { t.Fatalf("opt-in: %d", code) } if len(attached) != 1 || attached[0] != "space.11ch.at" { t.Fatalf("controld attach calls: %v", attached) } if code := call("wrong"); code != http.StatusForbidden { t.Fatal("stranger must be refused") } if len(attached) != 1 { t.Fatalf("refused call still attached: %v", attached) } } func TestMeterSibling(t *testing.T) { if got := meterSibling("https://cloud.example/api/usage", "ai-optin"); got != "https://cloud.example/api/ai-optin" { t.Fatalf("meterSibling = %q", got) } } // Flipping AI on in the admin settings asks the provisioner for the sidecar; // re-sending true (no transition) and flipping off stay silent. func TestSetSettingsAIFlipSignals(t *testing.T) { ts, s := newTestServer(t) signals := make(chan struct{}, 8) cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/ai-optin" { signals <- struct{}{} } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) })) defer cloud.Close() s.meterURL, s.meterToken = cloud.URL+"/api/usage", "meter-tok" set := func(on bool) { t.Helper() raw, _ := json.Marshal(map[string]any{"ai_enabled": on}) req, _ := http.NewRequest("POST", ts.URL+"/api/admin/settings", strings.NewReader(string(raw))) req.Header.Set("Authorization", "Bearer admintok") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("settings: %d", resp.StatusCode) } } waitSignal := func(want bool) { t.Helper() select { case <-signals: if !want { t.Fatal("unexpected opt-in signal") } case <-time.After(2 * time.Second): if want { t.Fatal("no opt-in signal arrived") } } } // THE core case: a fresh space's FIRST explicit "yes" signals, even // though the implicit default already read as on (the reviewer-caught // bug — a was/now comparison against the default swallowed it). set(true) waitSignal(true) // Re-sending true is not new consent; turning off never signals. set(true) set(false) select { case <-signals: t.Fatal("non-consent writes must not signal") case <-time.After(300 * time.Millisecond): } // Off → on is consent again (the re-enable path re-asks the provisioner). set(true) waitSignal(true) }