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// Tests for the per-instance feature gates (ai/bots/apps): default ON, admin
4// flips via /api/admin/settings, and the enforcement points — agent invite
5// minting + member-bot storage (bots), the member-app guide + deploys (apps),
6// and the ai_enabled flag the sidecar reads off /api/bot-config (AI).
7
8import (
9 "fmt"
10 "net/http"
11 "testing"
12)
13
14func TestFeatureGatesDefaultsAndSettings(t *testing.T) {
15 ts, srv := newTestServer(t)
16
17 // A fresh instance has every gate ON (absent keys read true).
18 var s map[string]any
19 decodeJSON(t, adminGet(t, ts.URL+"/api/admin/settings"), &s)
20 for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} {
21 if s[k] != true {
22 t.Fatalf("fresh settings %s = %v, want true", k, s[k])
23 }
24 }
25
26 // Each flag flips independently (partial updates, like user_invites).
27 for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} {
28 if r := adminPost(t, ts.URL+"/api/admin/settings", fmt.Sprintf(`{"%s":false}`, k)); r.StatusCode != http.StatusOK {
29 t.Fatalf("set %s=false: %d", k, r.StatusCode)
30 }
31 decodeJSON(t, adminGet(t, ts.URL+"/api/admin/settings"), &s)
32 if s[k] != false {
33 t.Fatalf("after flip, %s = %v, want false", k, s[k])
34 }
35 if r := adminPost(t, ts.URL+"/api/admin/settings", fmt.Sprintf(`{"%s":true}`, k)); r.StatusCode != http.StatusOK {
36 t.Fatalf("set %s=true: %d", k, r.StatusCode)
37 }
38 }
39
40 // The member-visible /api/group read carries all the gates (native consumes
41 // it) — and only with a valid token (it's also the public onboarding read).
42 tok, _, _ := joinedMember(t, srv, "Gates")
43 var g map[string]any
44 decodeJSON(t, mustGet(t, ts.URL+"/api/group?token="+tok), &g)
45 for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} {
46 if g[k] != true {
47 t.Fatalf("member /api/group %s = %v, want true", k, g[k])
48 }
49 }
50 var pub map[string]any
51 decodeJSON(t, mustGet(t, ts.URL+"/api/group"), &pub)
52 if _, ok := pub["bots_enabled"]; ok {
53 t.Fatal("public /api/group leaked the feature gates")
54 }
55}
56
57func TestBotsGate(t *testing.T) {
58 ts, srv := newTestServer(t)
59 tok, _, _ := joinedMember(t, srv, "Maker")
60
61 if r := adminPost(t, ts.URL+"/api/admin/settings", `{"bots_enabled":false}`); r.StatusCode != http.StatusOK {
62 t.Fatalf("disable bots: %d", r.StatusCode)
63 }
64
65 // Minting an agent invite is refused — and refused before any user row is
66 // created, so a rejected mint leaves no phantom member.
67 before, _ := srv.db.listUsers()
68 if r := adminPost(t, ts.URL+"/api/admin/key-invites",
69 `{"label":"Robo","id":"agent-inv","claim_hash":"`+testClaimHash+`","agent":true}`); r.StatusCode != http.StatusForbidden {
70 t.Fatalf("agent invite with bots off: want 403, got %d", r.StatusCode)
71 }
72 after, _ := srv.db.listUsers()
73 if len(after) != len(before) {
74 t.Fatalf("rejected agent mint left a user behind: %d -> %d", len(before), len(after))
75 }
76
77 // A normal (person) invite still works with bots off.
78 if r := adminPost(t, ts.URL+"/api/admin/key-invites",
79 `{"id":"person-inv","claim_hash":"`+testClaimHash+`"}`); r.StatusCode != http.StatusOK {
80 t.Fatalf("person invite with bots off: want 200, got %d", r.StatusCode)
81 }
82
83 // Member-bot charters can't be stored (created or edited) while off.
84 if r := doReq(t, "PUT", ts.URL+"/api/member-bots/bot-abc123",
85 fmt.Sprintf(`{"token":"%s","blob":"ciphertext"}`, tok)); r.StatusCode != http.StatusForbidden {
86 t.Fatalf("member-bot PUT with bots off: want 403, got %d", r.StatusCode)
87 }
88
89 // Back on: both paths open up again.
90 adminPost(t, ts.URL+"/api/admin/settings", `{"bots_enabled":true}`)
91 if r := adminPost(t, ts.URL+"/api/admin/key-invites",
92 `{"label":"Robo","id":"agent-inv2","claim_hash":"`+testClaimHash+`","agent":true}`); r.StatusCode != http.StatusOK {
93 t.Fatalf("agent invite with bots back on: want 200, got %d", r.StatusCode)
94 }
95 if r := doReq(t, "PUT", ts.URL+"/api/member-bots/bot-abc123",
96 fmt.Sprintf(`{"token":"%s","blob":"ciphertext"}`, tok)); r.StatusCode != http.StatusOK {
97 t.Fatalf("member-bot PUT with bots back on: want 200, got %d", r.StatusCode)
98 }
99}
100
101func TestAppsGate(t *testing.T) {
102 ts, srv := newTestServer(t)
103 tok, _, _ := joinedMember(t, srv, "Builder")
104
105 // On by default: the guide serves and deploys land.
106 if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusOK {
107 t.Fatalf("guide with apps on: want 200, got %d", r.StatusCode)
108 }
109 if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123",
110 fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok)); r.StatusCode != http.StatusOK {
111 t.Fatalf("deploy with apps on: want 200, got %d", r.StatusCode)
112 }
113
114 adminPost(t, ts.URL+"/api/admin/settings", `{"apps_enabled":false}`)
115
116 // The guide 404s — the sidecar reads that as "app building off" — and
117 // deploys (new or edits) are refused.
118 if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusNotFound {
119 t.Fatalf("guide with apps off: want 404, got %d", r.StatusCode)
120 }
121 if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123",
122 fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok)); r.StatusCode != http.StatusForbidden {
123 t.Fatalf("deploy with apps off: want 403, got %d", r.StatusCode)
124 }
125
126 // Existing apps still LIST (threads keep working) — only new use is gated.
127 r, _ := http.Get(ts.URL + "/api/member-apps?token=" + tok)
128 var list struct{ Apps []MemberApp }
129 decodeJSON(t, r, &list)
130 if len(list.Apps) != 1 || list.Apps[0].Blob != "ciphertext-1" {
131 t.Fatalf("existing apps should still list with apps off: %+v", list.Apps)
132 }
133}
134
135// The narrower member-apps gate: the wider apps feature stays ON, only members
136// building/installing their OWN apps switches off — deploys and edits 403, the
137// authoring guide 404s (which flips the sidecar's builder off), and apps
138// already deployed keep listing.
139func TestMemberAppsGate(t *testing.T) {
140 ts, srv := newTestServer(t)
141 tok, _, _ := joinedMember(t, srv, "Maker")
142
143 // On by default, independent of the wider apps gate.
144 if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123",
145 fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok)); r.StatusCode != http.StatusOK {
146 t.Fatalf("deploy with member apps on: want 200, got %d", r.StatusCode)
147 }
148
149 adminPost(t, ts.URL+"/api/admin/settings", `{"member_apps_enabled":false}`)
150
151 if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusNotFound {
152 t.Fatalf("guide with member apps off: want 404, got %d", r.StatusCode)
153 }
154 // New deploys AND edits of an existing app are refused.
155 if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-new456",
156 fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok)); r.StatusCode != http.StatusForbidden {
157 t.Fatalf("new deploy with member apps off: want 403, got %d", r.StatusCode)
158 }
159 if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123",
160 fmt.Sprintf(`{"token":"%s","blob":"ciphertext-3"}`, tok)); r.StatusCode != http.StatusForbidden {
161 t.Fatalf("edit with member apps off: want 403, got %d", r.StatusCode)
162 }
163
164 // The wider apps feature is untouched: built-in apps stay in the menu
165 // (apps_enabled still reads true) and deployed member apps still list.
166 var g map[string]any
167 decodeJSON(t, mustGet(t, ts.URL+"/api/group?token="+tok), &g)
168 if g["apps_enabled"] != true || g["member_apps_enabled"] != false {
169 t.Fatalf("gates: apps_enabled = %v, member_apps_enabled = %v", g["apps_enabled"], g["member_apps_enabled"])
170 }
171 r, _ := http.Get(ts.URL + "/api/member-apps?token=" + tok)
172 var list struct{ Apps []MemberApp }
173 decodeJSON(t, r, &list)
174 if len(list.Apps) != 1 || list.Apps[0].Blob != "ciphertext-1" {
175 t.Fatalf("existing apps should still list with member apps off: %+v", list.Apps)
176 }
177 // Retiring your own app stays open — the gate stops new use, never cleanup.
178 if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-abc123?token="+tok, ""); r.StatusCode != http.StatusOK {
179 t.Fatalf("retire with member apps off: want 200, got %d", r.StatusCode)
180 }
181
182 // Back on: deploys land again.
183 adminPost(t, ts.URL+"/api/admin/settings", `{"member_apps_enabled":true}`)
184 if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusOK {
185 t.Fatalf("guide with member apps back on: want 200, got %d", r.StatusCode)
186 }
187 if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-new456",
188 fmt.Sprintf(`{"token":"%s","blob":"ciphertext-4"}`, tok)); r.StatusCode != http.StatusOK {
189 t.Fatalf("deploy with member apps back on: want 200, got %d", r.StatusCode)
190 }
191}
192
193func TestAIGateOnBotConfig(t *testing.T) {
194 ts, srv := newTestServer(t)
195 tok, _, _ := joinedMember(t, srv, "Bot")
196
197 get := func() map[string]any {
198 var c map[string]any
199 decodeJSON(t, mustGet(t, ts.URL+"/api/bot-config?token="+tok), &c)
200 return c
201 }
202 if c := get(); c["ai_enabled"] != true {
203 t.Fatalf("fresh bot-config ai_enabled = %v, want true", c["ai_enabled"])
204 }
205 adminPost(t, ts.URL+"/api/admin/settings", `{"ai_enabled":false}`)
206 // The sidecar re-reads this on every (re)connect (internal/bot loadConfig)
207 // and drops its model client when it's false.
208 if c := get(); c["ai_enabled"] != false {
209 t.Fatalf("bot-config after AI off: ai_enabled = %v, want false", c["ai_enabled"])
210 }
211}
212