Changeset 0.28.0.7 (#179)

This commit is contained in:
2026-03-12 19:34:48 +00:00
parent 1a5eb707ba
commit f3f5afd14c
11 changed files with 1189 additions and 89 deletions

View File

@@ -214,6 +214,19 @@ func setupHarness(t *testing.T) *testHarness {
teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete)
teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow)
teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun)
// Team personas (v0.28.0-audit)
teamPersonas := NewPersonaHandler(stores)
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs)
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs)
teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants)
teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants)
teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar)
teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar)
}
// Team task viewing for all members (v0.28.0-audit)
@@ -240,8 +253,24 @@ func setupHarness(t *testing.T) *testHarness {
personas := NewPersonaHandler(stores)
protected.GET("/personas", personas.ListUserPersonas)
protected.POST("/personas", personas.CreateUserPersona)
protected.PUT("/personas/:id", personas.UpdateUserPersona)
protected.DELETE("/personas/:id", personas.DeleteUserPersona)
protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
// Persona Groups
pgH := NewPersonaGroupHandler()
protected.GET("/persona-groups", pgH.List)
protected.POST("/persona-groups", pgH.Create)
protected.GET("/persona-groups/:id", pgH.Get)
protected.PUT("/persona-groups/:id", pgH.Update)
protected.DELETE("/persona-groups/:id", pgH.Delete)
protected.POST("/persona-groups/:id/members", pgH.AddMember)
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
// Notes
notes := NewNoteHandler(stores)
@@ -340,8 +369,14 @@ func setupHarness(t *testing.T) *testHarness {
admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
admin.GET("/personas", personas.ListAdminPersonas)
admin.POST("/personas", personas.CreateAdminPersona)
admin.PUT("/personas/:id", personas.UpdateAdminPersona)
admin.DELETE("/personas/:id", personas.DeleteAdminPersona)
admin.POST("/personas/:id/avatar", UploadPersonaAvatar)
admin.DELETE("/personas/:id/avatar", DeletePersonaAvatar)
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
admin.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
// Admin groups (v0.16.0)
groupAdm := NewGroupHandler(stores)
@@ -2921,7 +2956,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
w = h.request("GET", "/api/v1/personas", userToken, nil)
var personasResp map[string]interface{}
decode(w, &personasResp)
personas, _ := personasResp["personas"].([]interface{})
personas, _ := personasResp["data"].([]interface{})
for _, p := range personas {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
@@ -2955,7 +2990,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
}
decode(w, &personasResp)
personas, _ = personasResp["personas"].([]interface{})
personas, _ = personasResp["data"].([]interface{})
found := false
for _, p := range personas {
pm := p.(map[string]interface{})
@@ -2973,7 +3008,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
w = h.request("GET", "/api/v1/personas", userToken, nil)
decode(w, &personasResp)
personas, _ = personasResp["personas"].([]interface{})
personas, _ = personasResp["data"].([]interface{})
for _, p := range personas {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
@@ -3979,3 +4014,828 @@ func mapKeys(m map[string]interface{}) []string {
}
return keys
}
// ═══════════════════════════════════════════════════════════════
// PERSONA AUDIT TESTS (v0.28.0-audit CS2)
// ═══════════════════════════════════════════════════════════════
// ── Persona CRUD ────────────────────────────
func TestPersona_CRUD_Personal(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("padmin", "padmin@test.com")
_ = adminID
// Enable persona creation policy
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Create
w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
"name": "My Helper", "base_model_id": "test-model",
"system_prompt": "You are helpful.", "description": "Test persona",
})
if w.Code != http.StatusCreated {
t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
if created["handle"] == nil || created["handle"] == "" {
t.Error("handle should be auto-generated from name")
}
// Update
w = h.request("PUT", "/api/v1/personas/"+personaID, adminToken, map[string]interface{}{
"name": "My Updated Helper", "description": "Updated desc",
})
if w.Code != http.StatusOK {
t.Fatalf("update persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// List — verify updated name appears
w = h.request("GET", "/api/v1/personas", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
data := listResp["data"].([]interface{})
found := false
for _, p := range data {
pm := p.(map[string]interface{})
if pm["id"] == personaID && pm["name"] == "My Updated Helper" {
found = true
}
}
if !found {
t.Error("updated persona not found in list with new name")
}
// Delete
w = h.request("DELETE", "/api/v1/personas/"+personaID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete persona: want 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestPersona_Update_OwnershipCheck(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("own1", "own1@test.com")
otherID := database.SeedTestUser(t, "own2", "own2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
otherToken := makeToken(otherID, "own2@test.com", "user")
// Enable persona creation
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Admin creates a personal persona
w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
"name": "Admin's Bot", "base_model_id": "test-model",
})
if w.Code != http.StatusCreated {
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Other user tries to update — should be forbidden
w = h.request("PUT", "/api/v1/personas/"+personaID, otherToken, map[string]interface{}{
"name": "Stolen",
})
if w.Code != http.StatusForbidden {
t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
}
// Other user tries to delete — should be forbidden
w = h.request("DELETE", "/api/v1/personas/"+personaID, otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("delete by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestPersona_AdminCRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("apadmin", "apadmin@test.com")
// Create global persona via admin
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Global Bot", "base_model_id": "test-model",
"system_prompt": "You are global.",
})
if w.Code != http.StatusCreated {
t.Fatalf("admin create: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
if created["scope"] != "global" {
t.Fatalf("admin persona scope: want 'global', got %v", created["scope"])
}
// Update
w = h.request("PUT", "/api/v1/admin/personas/"+personaID, adminToken, map[string]interface{}{
"name": "Global Bot v2",
})
if w.Code != http.StatusOK {
t.Fatalf("admin update: want 200, got %d: %s", w.Code, w.Body.String())
}
// List
w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
if listResp["data"] == nil {
t.Fatal("admin list should return 'data' key")
}
// Delete
w = h.request("DELETE", "/api/v1/admin/personas/"+personaID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String())
}
}
// ── Envelope + Nil Slice ────────────────────
func TestPersona_EnvelopeKey(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("envadm", "envadm@test.com")
// User list
w := h.request("GET", "/api/v1/personas", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
if _, ok := resp["personas"]; ok {
t.Error("response should use 'data' key, not 'personas'")
}
if resp["data"] == nil {
t.Error("response should have 'data' key")
}
// Admin list
w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
decode(w, &resp)
if _, ok := resp["personas"]; ok {
t.Error("admin response should use 'data' key, not 'personas'")
}
if resp["data"] == nil {
t.Error("admin response should have 'data' key")
}
}
func TestPersona_NilSlice_ReturnsEmptyArray(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("niladm", "niladm@test.com")
// Fresh user, no personas — should get [] not null
w := h.request("GET", "/api/v1/personas", adminToken, nil)
// Check raw JSON contains [] not null
body := w.Body.String()
if strings.Contains(body, `"data":null`) {
t.Fatal("data should be [] not null for empty persona list")
}
if !strings.Contains(body, `"data":[]`) {
t.Fatalf("data should be empty array, got: %s", body)
}
// Admin list
w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
body = w.Body.String()
if strings.Contains(body, `"data":null`) {
t.Fatal("admin data should be [] not null for empty persona list")
}
}
// ── Tool Grants ─────────────────────────────
func TestPersona_ToolGrants_RoundTrip(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("tgadm", "tgadm@test.com")
// Create persona
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Grant Bot", "base_model_id": "test-model",
})
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Initially empty
w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
var grantResp map[string]interface{}
decode(w, &grantResp)
grants := grantResp["data"].([]interface{})
if len(grants) != 0 {
t.Fatalf("initial grants: want 0, got %d", len(grants))
}
// Set grants
w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
"tool_names": []string{"web_search", "calculator", "kb_search"},
})
if w.Code != http.StatusOK {
t.Fatalf("set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back
w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
decode(w, &grantResp)
grants = grantResp["data"].([]interface{})
if len(grants) != 3 {
t.Fatalf("grants after set: want 3, got %d", len(grants))
}
// Clear grants
w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
"tool_names": []string{},
})
if w.Code != http.StatusOK {
t.Fatalf("clear tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back — should be empty
w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
decode(w, &grantResp)
grants = grantResp["data"].([]interface{})
if len(grants) != 0 {
t.Fatalf("grants after clear: want 0, got %d", len(grants))
}
// Nil-slice check: verify "data":[] not "data":null
body := w.Body.String()
if strings.Contains(body, `"data":null`) {
t.Fatal("tool grants should return [] not null when empty")
}
}
func TestPersona_ToolGrants_UserScope(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("tguadm", "tguadm@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Create personal persona
w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
"name": "User Grant Bot", "base_model_id": "test-model",
})
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Set via user endpoint
w = h.request("PUT", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
"tool_names": []string{"web_search"},
})
if w.Code != http.StatusOK {
t.Fatalf("user set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back via user endpoint
w = h.request("GET", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("user get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
var grantResp map[string]interface{}
decode(w, &grantResp)
grants := grantResp["data"].([]interface{})
if len(grants) != 1 {
t.Fatalf("user grants: want 1, got %d", len(grants))
}
}
// ── Team Persona CRUD + Scope Checks ────────
func TestTeamPersona_CRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("tpadm", "tpadm@test.com")
// Create a team
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Persona Team",
})
var teamResp map[string]interface{}
decode(w, &teamResp)
teamID := teamResp["id"].(string)
// Create team persona
w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
"name": "Team Bot", "base_model_id": "test-model",
"system_prompt": "You are a team bot.",
})
if w.Code != http.StatusCreated {
t.Fatalf("create team persona: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
if created["scope"] != "team" {
t.Fatalf("team persona scope: want 'team', got %v", created["scope"])
}
// List team personas
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list team personas: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
if listResp["data"] == nil {
t.Fatal("team persona list should use 'data' key")
}
items := listResp["data"].([]interface{})
if len(items) != 1 {
t.Fatalf("team personas: want 1, got %d", len(items))
}
// Update team persona
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, map[string]interface{}{
"name": "Team Bot v2",
})
if w.Code != http.StatusOK {
t.Fatalf("update team persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// Delete team persona
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete team persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify deleted
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
decode(w, &listResp)
items = listResp["data"].([]interface{})
if len(items) != 0 {
t.Fatalf("team personas after delete: want 0, got %d", len(items))
}
}
func TestTeamPersona_Delete_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("tpscadm", "tpscadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Team Alpha",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Team Beta",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team Alpha
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('Alpha Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to delete it via Team Beta route — should fail
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team delete: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to update it via Team Beta route — should fail
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, map[string]interface{}{
"name": "Stolen",
})
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team update: want 403, got %d: %s", w.Code, w.Body.String())
}
// Delete via correct team — should succeed
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("same-team delete: want 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestTeamPersona_KB_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("tpkbadm", "tpkbadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "KB Team A",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "KB Team B",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team A
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('KB Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to read KBs via Team B — should fail
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team get KBs: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to set KBs via Team B — should fail
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, map[string]interface{}{
"kb_ids": []string{},
})
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team set KBs: want 403, got %d: %s", w.Code, w.Body.String())
}
// Read via correct team — should succeed
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("same-team get KBs: want 200, got %d: %s", w.Code, w.Body.String())
}
}
// ── Persona Groups ──────────────────────────
func TestPersonaGroup_CRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgadm", "pgadm@test.com")
// Create group
w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
"name": "Support Team", "description": "Front-line support personas",
})
if w.Code != http.StatusCreated {
t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
groupID := created["id"].(string)
if created["scope"] != "personal" {
t.Fatalf("group scope: want 'personal', got %v", created["scope"])
}
// Get
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get group: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetched map[string]interface{}
decode(w, &fetched)
if fetched["name"] != "Support Team" {
t.Fatalf("group name: want 'Support Team', got %v", fetched["name"])
}
// Update
w = h.request("PUT", "/api/v1/persona-groups/"+groupID, adminToken, map[string]interface{}{
"name": "Support Team v2",
})
if w.Code != http.StatusOK {
t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
}
// List
w = h.request("GET", "/api/v1/persona-groups", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list groups: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
if listResp["data"] == nil {
t.Fatal("group list should use 'data' key")
}
if _, ok := listResp["groups"]; ok {
t.Error("group list should NOT use 'groups' key")
}
// Delete
w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete group: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify deleted
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("get deleted group: want 404, got %d", w.Code)
}
}
func TestPersonaGroup_Members(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgmadm", "pgmadm@test.com")
// Create a persona for the group
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Group Persona A", "base_model_id": "test-model",
})
var pA map[string]interface{}
decode(w, &pA)
personaAID := pA["id"].(string)
w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Group Persona B", "base_model_id": "test-model",
})
var pB map[string]interface{}
decode(w, &pB)
personaBID := pB["id"].(string)
// Create group
w = h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
"name": "Two-Bot Team",
})
var group map[string]interface{}
decode(w, &group)
groupID := group["id"].(string)
// Add member A as leader
w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
"persona_id": personaAID, "is_leader": true,
})
if w.Code != http.StatusCreated {
t.Fatalf("add member A: want 201, got %d: %s", w.Code, w.Body.String())
}
// Add member B
w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
"persona_id": personaBID,
})
if w.Code != http.StatusCreated {
t.Fatalf("add member B: want 201, got %d: %s", w.Code, w.Body.String())
}
// Get group — should have 2 members
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get group for members: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetched map[string]interface{}
decode(w, &fetched)
members := fetched["members"].([]interface{})
if len(members) != 2 {
t.Fatalf("members: want 2, got %d", len(members))
}
// Verify leader is first (sorted by is_leader DESC)
first := members[0].(map[string]interface{})
if first["is_leader"] != true {
t.Error("first member should be the leader")
}
// Swap leader to B — should clear A's leader flag
w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
"persona_id": personaBID, "is_leader": true,
})
if w.Code != http.StatusCreated {
t.Fatalf("swap leader: want 201, got %d: %s", w.Code, w.Body.String())
}
// Get group — verify only one leader
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
decode(w, &fetched)
members = fetched["members"].([]interface{})
leaderCount := 0
for _, m := range members {
mm := m.(map[string]interface{})
if mm["is_leader"] == true {
leaderCount++
}
}
if leaderCount != 1 {
t.Fatalf("leaders after swap: want 1, got %d", leaderCount)
}
// Remove member A
memberAID := ""
for _, m := range members {
mm := m.(map[string]interface{})
if mm["persona_id"] == personaAID {
memberAID = mm["id"].(string)
}
}
if memberAID == "" {
t.Fatal("could not find member A in group")
}
w = h.request("DELETE", fmt.Sprintf("/api/v1/persona-groups/%s/members/%s", groupID, memberAID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
}
// Get group — should have 1 member
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
decode(w, &fetched)
members = fetched["members"].([]interface{})
if len(members) != 1 {
t.Fatalf("members after remove: want 1, got %d", len(members))
}
}
func TestPersonaGroup_OwnershipCheck(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgown1", "pgown1@test.com")
otherID := database.SeedTestUser(t, "pgown2", "pgown2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
otherToken := makeToken(otherID, "pgown2@test.com", "user")
// Admin creates a group
w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
"name": "Admin's Group",
})
var group map[string]interface{}
decode(w, &group)
groupID := group["id"].(string)
// Other user tries to update — should fail
w = h.request("PUT", "/api/v1/persona-groups/"+groupID, otherToken, map[string]interface{}{
"name": "Stolen Group",
})
if w.Code != http.StatusForbidden {
t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
}
// Other user tries to delete — should fail
w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, otherToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("delete by non-owner: want 404, got %d: %s", w.Code, w.Body.String())
}
// Other user list — should not see admin's group
w = h.request("GET", "/api/v1/persona-groups", otherToken, nil)
var listResp map[string]interface{}
decode(w, &listResp)
items := listResp["data"].([]interface{})
if len(items) != 0 {
t.Fatalf("non-owner should see 0 groups, got %d", len(items))
}
}
func TestPersonaGroup_EnvelopeKey(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgeadm", "pgeadm@test.com")
w := h.request("GET", "/api/v1/persona-groups", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
if _, ok := resp["groups"]; ok {
t.Error("persona-groups list should use 'data' key, not 'groups'")
}
if resp["data"] == nil {
t.Error("persona-groups list should have 'data' key")
}
}
// ── Team Persona Tool Grants ────────────────
func TestTeamPersona_ToolGrants_RoundTrip(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("ttgadm", "ttgadm@test.com")
// Create team
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "ToolGrant Team",
})
var team map[string]interface{}
decode(w, &team)
teamID := team["id"].(string)
// Create team persona
w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
"name": "TG Bot", "base_model_id": "test-model",
})
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Initially empty
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
grants := resp["data"].([]interface{})
if len(grants) != 0 {
t.Fatalf("initial team grants: want 0, got %d", len(grants))
}
// Set
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, map[string]interface{}{
"tool_names": []string{"web_search", "kb_search"},
})
if w.Code != http.StatusOK {
t.Fatalf("set team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
decode(w, &resp)
grants = resp["data"].([]interface{})
if len(grants) != 2 {
t.Fatalf("team grants after set: want 2, got %d", len(grants))
}
}
func TestTeamPersona_ToolGrants_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("ttgsadm", "ttgsadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "TG Scope A",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "TG Scope B",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team A
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('TG Scope Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to read via Team B — 403
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team get tool grants: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to set via Team B — 403
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, map[string]interface{}{
"tool_names": []string{"web_search"},
})
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team set tool grants: want 403, got %d: %s", w.Code, w.Body.String())
}
// Via correct team — 200
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("same-team get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
}
// ── Team Persona Avatar ─────────────────────
func TestTeamPersona_Avatar_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("taadm", "taadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Avatar Team A",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Avatar Team B",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team A
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('Avatar Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to delete avatar via Team B — 403
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team delete avatar: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to delete via correct team — should succeed (persona has no avatar, so 404 from UPDATE rows=0 is fine too)
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK && w.Code != http.StatusNotFound {
t.Fatalf("same-team delete avatar: want 200 or 404, got %d: %s", w.Code, w.Body.String())
}
}