package main // Tests for the per-instance feature gates (ai/bots/apps): default ON, admin // flips via /api/admin/settings, and the enforcement points — agent invite // minting + member-bot storage (bots), the member-app guide + deploys (apps), // and the ai_enabled flag the sidecar reads off /api/bot-config (AI). import ( "fmt" "net/http" "testing" ) func TestFeatureGatesDefaultsAndSettings(t *testing.T) { ts, srv := newTestServer(t) // A fresh instance has every gate ON (absent keys read true). var s map[string]any decodeJSON(t, adminGet(t, ts.URL+"/api/admin/settings"), &s) for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} { if s[k] != true { t.Fatalf("fresh settings %s = %v, want true", k, s[k]) } } // Each flag flips independently (partial updates, like user_invites). for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} { if r := adminPost(t, ts.URL+"/api/admin/settings", fmt.Sprintf(`{"%s":false}`, k)); r.StatusCode != http.StatusOK { t.Fatalf("set %s=false: %d", k, r.StatusCode) } decodeJSON(t, adminGet(t, ts.URL+"/api/admin/settings"), &s) if s[k] != false { t.Fatalf("after flip, %s = %v, want false", k, s[k]) } if r := adminPost(t, ts.URL+"/api/admin/settings", fmt.Sprintf(`{"%s":true}`, k)); r.StatusCode != http.StatusOK { t.Fatalf("set %s=true: %d", k, r.StatusCode) } } // The member-visible /api/group read carries all the gates (native consumes // it) — and only with a valid token (it's also the public onboarding read). tok, _, _ := joinedMember(t, srv, "Gates") var g map[string]any decodeJSON(t, mustGet(t, ts.URL+"/api/group?token="+tok), &g) for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} { if g[k] != true { t.Fatalf("member /api/group %s = %v, want true", k, g[k]) } } var pub map[string]any decodeJSON(t, mustGet(t, ts.URL+"/api/group"), &pub) if _, ok := pub["bots_enabled"]; ok { t.Fatal("public /api/group leaked the feature gates") } } func TestBotsGate(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Maker") if r := adminPost(t, ts.URL+"/api/admin/settings", `{"bots_enabled":false}`); r.StatusCode != http.StatusOK { t.Fatalf("disable bots: %d", r.StatusCode) } // Minting an agent invite is refused — and refused before any user row is // created, so a rejected mint leaves no phantom member. before, _ := srv.db.listUsers() if r := adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Robo","id":"agent-inv","claim_hash":"`+testClaimHash+`","agent":true}`); r.StatusCode != http.StatusForbidden { t.Fatalf("agent invite with bots off: want 403, got %d", r.StatusCode) } after, _ := srv.db.listUsers() if len(after) != len(before) { t.Fatalf("rejected agent mint left a user behind: %d -> %d", len(before), len(after)) } // A normal (person) invite still works with bots off. if r := adminPost(t, ts.URL+"/api/admin/key-invites", `{"id":"person-inv","claim_hash":"`+testClaimHash+`"}`); r.StatusCode != http.StatusOK { t.Fatalf("person invite with bots off: want 200, got %d", r.StatusCode) } // Member-bot charters can't be stored (created or edited) while off. if r := doReq(t, "PUT", ts.URL+"/api/member-bots/bot-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext"}`, tok)); r.StatusCode != http.StatusForbidden { t.Fatalf("member-bot PUT with bots off: want 403, got %d", r.StatusCode) } // Back on: both paths open up again. adminPost(t, ts.URL+"/api/admin/settings", `{"bots_enabled":true}`) if r := adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Robo","id":"agent-inv2","claim_hash":"`+testClaimHash+`","agent":true}`); r.StatusCode != http.StatusOK { t.Fatalf("agent invite with bots back on: want 200, got %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-bots/bot-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext"}`, tok)); r.StatusCode != http.StatusOK { t.Fatalf("member-bot PUT with bots back on: want 200, got %d", r.StatusCode) } } func TestAppsGate(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Builder") // On by default: the guide serves and deploys land. if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusOK { t.Fatalf("guide with apps on: want 200, got %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok)); r.StatusCode != http.StatusOK { t.Fatalf("deploy with apps on: want 200, got %d", r.StatusCode) } adminPost(t, ts.URL+"/api/admin/settings", `{"apps_enabled":false}`) // The guide 404s — the sidecar reads that as "app building off" — and // deploys (new or edits) are refused. if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusNotFound { t.Fatalf("guide with apps off: want 404, got %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok)); r.StatusCode != http.StatusForbidden { t.Fatalf("deploy with apps off: want 403, got %d", r.StatusCode) } // Existing apps still LIST (threads keep working) — only new use is gated. r, _ := http.Get(ts.URL + "/api/member-apps?token=" + tok) var list struct{ Apps []MemberApp } decodeJSON(t, r, &list) if len(list.Apps) != 1 || list.Apps[0].Blob != "ciphertext-1" { t.Fatalf("existing apps should still list with apps off: %+v", list.Apps) } } // The narrower member-apps gate: the wider apps feature stays ON, only members // building/installing their OWN apps switches off — deploys and edits 403, the // authoring guide 404s (which flips the sidecar's builder off), and apps // already deployed keep listing. func TestMemberAppsGate(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Maker") // On by default, independent of the wider apps gate. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok)); r.StatusCode != http.StatusOK { t.Fatalf("deploy with member apps on: want 200, got %d", r.StatusCode) } adminPost(t, ts.URL+"/api/admin/settings", `{"member_apps_enabled":false}`) if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusNotFound { t.Fatalf("guide with member apps off: want 404, got %d", r.StatusCode) } // New deploys AND edits of an existing app are refused. if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-new456", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok)); r.StatusCode != http.StatusForbidden { t.Fatalf("new deploy with member apps off: want 403, got %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-3"}`, tok)); r.StatusCode != http.StatusForbidden { t.Fatalf("edit with member apps off: want 403, got %d", r.StatusCode) } // The wider apps feature is untouched: built-in apps stay in the menu // (apps_enabled still reads true) and deployed member apps still list. var g map[string]any decodeJSON(t, mustGet(t, ts.URL+"/api/group?token="+tok), &g) if g["apps_enabled"] != true || g["member_apps_enabled"] != false { t.Fatalf("gates: apps_enabled = %v, member_apps_enabled = %v", g["apps_enabled"], g["member_apps_enabled"]) } r, _ := http.Get(ts.URL + "/api/member-apps?token=" + tok) var list struct{ Apps []MemberApp } decodeJSON(t, r, &list) if len(list.Apps) != 1 || list.Apps[0].Blob != "ciphertext-1" { t.Fatalf("existing apps should still list with member apps off: %+v", list.Apps) } // Retiring your own app stays open — the gate stops new use, never cleanup. if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-abc123?token="+tok, ""); r.StatusCode != http.StatusOK { t.Fatalf("retire with member apps off: want 200, got %d", r.StatusCode) } // Back on: deploys land again. adminPost(t, ts.URL+"/api/admin/settings", `{"member_apps_enabled":true}`) if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusOK { t.Fatalf("guide with member apps back on: want 200, got %d", r.StatusCode) } if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-new456", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-4"}`, tok)); r.StatusCode != http.StatusOK { t.Fatalf("deploy with member apps back on: want 200, got %d", r.StatusCode) } } func TestAIGateOnBotConfig(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Bot") get := func() map[string]any { var c map[string]any decodeJSON(t, mustGet(t, ts.URL+"/api/bot-config?token="+tok), &c) return c } if c := get(); c["ai_enabled"] != true { t.Fatalf("fresh bot-config ai_enabled = %v, want true", c["ai_enabled"]) } adminPost(t, ts.URL+"/api/admin/settings", `{"ai_enabled":false}`) // The sidecar re-reads this on every (re)connect (internal/bot loadConfig) // and drops its model client when it's false. if c := get(); c["ai_enabled"] != false { t.Fatalf("bot-config after AI off: ai_enabled = %v, want false", c["ai_enabled"]) } }