Changeset 0.28.0.7 (#179)
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user