Changeset 0.28.0.3 (#175)
This commit is contained in:
@@ -170,18 +170,33 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||
|
||||
// Team self-service (same route group as production)
|
||||
teams := NewTeamHandler(nil)
|
||||
teams := NewTeamHandler(stores, nil)
|
||||
protected.GET("/teams/mine", teams.MyTeams)
|
||||
|
||||
teamScoped := protected.Group("/teams/:teamId")
|
||||
teamScoped.Use(middleware.RequireTeamAdmin())
|
||||
{
|
||||
// Team members (team admin self-service)
|
||||
teamScoped.GET("/members", teams.ListMembers)
|
||||
teamScoped.POST("/members", teams.AddMember)
|
||||
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||
|
||||
// Team providers
|
||||
teamScoped.GET("/providers", teams.ListTeamProviders)
|
||||
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
||||
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
|
||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||
|
||||
// Team audit
|
||||
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||
|
||||
// Team groups
|
||||
teamGroupH := NewGroupHandler(stores)
|
||||
teamScoped.GET("/groups", teamGroupH.ListTeamGroups)
|
||||
|
||||
// Team usage
|
||||
teamUsage := NewUsageHandler(stores)
|
||||
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
||||
@@ -317,8 +332,12 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.GET("/teams", teams.ListTeams)
|
||||
admin.POST("/teams", teams.CreateTeam)
|
||||
admin.GET("/teams/:id", teams.GetTeam)
|
||||
admin.PUT("/teams/:id", teams.UpdateTeam)
|
||||
admin.DELETE("/teams/:id", teams.DeleteTeam)
|
||||
admin.GET("/teams/:id/members", teams.ListMembers)
|
||||
admin.POST("/teams/:id/members", teams.AddMember)
|
||||
admin.PUT("/teams/:id/members/:memberId", teams.UpdateMember)
|
||||
admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
|
||||
admin.GET("/personas", personas.ListAdminPersonas)
|
||||
admin.POST("/personas", personas.CreateAdminPersona)
|
||||
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
@@ -340,6 +359,10 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
||||
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
||||
|
||||
// Admin permissions (v0.16.0)
|
||||
admin.GET("/permissions", groupAdm.ListPermissions)
|
||||
admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions)
|
||||
|
||||
// User groups (v0.16.0)
|
||||
protected.GET("/groups/mine", groupAdm.MyGroups)
|
||||
|
||||
@@ -1906,10 +1929,14 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var roleList map[string]interface{}
|
||||
decode(w, &roleList)
|
||||
if len(roleList) != 0 {
|
||||
t.Fatalf("team roles should be empty initially, got %d", len(roleList))
|
||||
var roleResp map[string]interface{}
|
||||
decode(w, &roleResp)
|
||||
roleData, _ := roleResp["data"].(map[string]interface{})
|
||||
if roleData == nil {
|
||||
roleData = map[string]interface{}{}
|
||||
}
|
||||
if len(roleData) != 0 {
|
||||
t.Fatalf("team roles should be empty initially, got %d", len(roleData))
|
||||
}
|
||||
|
||||
// Set team role override
|
||||
@@ -1926,8 +1953,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
|
||||
// List should now have override
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
decode(w, &roleList)
|
||||
if _, ok := roleList["utility"]; !ok {
|
||||
roleResp = map[string]interface{}{}
|
||||
decode(w, &roleResp)
|
||||
roleData, _ = roleResp["data"].(map[string]interface{})
|
||||
if roleData == nil || len(roleData) == 0 {
|
||||
t.Fatal("team roles should have utility after update")
|
||||
}
|
||||
if _, ok := roleData["utility"]; !ok {
|
||||
t.Fatal("team roles should have utility after update")
|
||||
}
|
||||
|
||||
@@ -1939,10 +1971,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
|
||||
// List should be empty again
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
roleList = map[string]interface{}{} // reset — json.Unmarshal merges into existing maps
|
||||
decode(w, &roleList)
|
||||
if _, ok := roleList["utility"]; ok {
|
||||
t.Fatal("team roles should not have utility after delete")
|
||||
roleResp = map[string]interface{}{}
|
||||
decode(w, &roleResp)
|
||||
roleData, _ = roleResp["data"].(map[string]interface{})
|
||||
if roleData != nil {
|
||||
if _, ok := roleData["utility"]; ok {
|
||||
t.Fatal("team roles should not have utility after delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3383,3 +3418,564 @@ func TestIntegration_ChannelListWithTypeFilter(t *testing.T) {
|
||||
// Verify the request succeeded (200) — that's the regression test.
|
||||
// Channel content verification is covered by other tests.
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// TEAMS ICD AUDIT — Phase 2 Tests
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// ── Admin Team CRUD Lifecycle ──────────────
|
||||
|
||||
func TestAudit_AdminTeamCRUD(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// ── Create ──
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "Audit Team", "description": "For audit testing",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created map[string]interface{}
|
||||
decode(w, &created)
|
||||
teamID := created["id"].(string)
|
||||
if created["name"] != "Audit Team" {
|
||||
t.Fatalf("create response should have name, got %v", created["name"])
|
||||
}
|
||||
|
||||
// ── List (data envelope) ──
|
||||
w = h.request("GET", "/api/v1/admin/teams", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list teams: want 200, got %d", w.Code)
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
decode(w, &listResp)
|
||||
if _, ok := listResp["data"]; !ok {
|
||||
t.Fatal("GET /admin/teams must return 'data' key (envelope convention)")
|
||||
}
|
||||
if _, ok := listResp["total"]; !ok {
|
||||
t.Fatal("GET /admin/teams must return 'total' key (paginated)")
|
||||
}
|
||||
|
||||
// ── Get ──
|
||||
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got map[string]interface{}
|
||||
decode(w, &got)
|
||||
if got["name"] != "Audit Team" {
|
||||
t.Fatalf("get team name mismatch: %v", got["name"])
|
||||
}
|
||||
|
||||
// ── Get nonexistent ──
|
||||
w = h.request("GET", "/api/v1/admin/teams/00000000-0000-0000-0000-000000000099", adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("get nonexistent team: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Update ──
|
||||
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
|
||||
"name": "Renamed Team",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify update
|
||||
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
decode(w, &got)
|
||||
if got["name"] != "Renamed Team" {
|
||||
t.Fatalf("after update, name should be 'Renamed Team', got %v", got["name"])
|
||||
}
|
||||
|
||||
// ── Update with no fields → 400 ──
|
||||
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("update with no fields: want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── Duplicate name → 409 ──
|
||||
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "Second Team",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create second team: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
|
||||
"name": "Second Team",
|
||||
})
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate name update: want 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── Delete ──
|
||||
w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("deleted team: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Delete nonexistent → 404 ──
|
||||
w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete nonexistent: want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Member Update + Remove ───────────
|
||||
|
||||
func TestAudit_AdminMemberUpdateRemove(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
userID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "MemberTest",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
// Add member
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": userID, "role": "member"})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("add member: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Get member ID from listing
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil)
|
||||
var membersResp map[string]interface{}
|
||||
decode(w, &membersResp)
|
||||
members := membersResp["data"].([]interface{})
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(members))
|
||||
}
|
||||
memberID := members[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
// Update role → admin
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken,
|
||||
map[string]string{"role": "admin"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Update nonexistent member → 404
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, "00000000-0000-0000-0000-000000000099"), adminToken,
|
||||
map[string]string{"role": "member"})
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("update nonexistent member: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Remove member
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Remove nonexistent → 404
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("remove nonexistent: want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── C1: Cross-Team Member Mutation ─────────
|
||||
// BUG: UpdateMember/RemoveMember WHERE clause has no team_id constraint.
|
||||
// A team admin for Team A can mutate membership in Team B by knowing
|
||||
// the team_members.id primary key.
|
||||
|
||||
func TestAudit_CrossTeamMemberMutation(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create two users
|
||||
aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
|
||||
bobID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
|
||||
|
||||
// Create Team A and Team B
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team A"})
|
||||
var tA map[string]interface{}
|
||||
decode(w, &tA)
|
||||
teamAID := tA["id"].(string)
|
||||
|
||||
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team B"})
|
||||
var tB map[string]interface{}
|
||||
decode(w, &tB)
|
||||
teamBID := tB["id"].(string)
|
||||
|
||||
// Alice is admin of Team A
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamAID), adminToken,
|
||||
map[string]string{"user_id": aliceID, "role": "admin"})
|
||||
|
||||
// Bob is member of Team B
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken,
|
||||
map[string]string{"user_id": bobID, "role": "member"})
|
||||
|
||||
// Get Bob's team_members.id from Team B listing
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken, nil)
|
||||
var bMembers map[string]interface{}
|
||||
decode(w, &bMembers)
|
||||
bobMemberID := bMembers["data"].([]interface{})[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
// Alice (Team A admin) tries to update Bob's role in Team B
|
||||
// via the team-scoped route for Team A — should fail with 404 because
|
||||
// bobMemberID does not belong to Team A.
|
||||
aliceToken := makeToken(aliceID, "alice@test.com", "user")
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken,
|
||||
map[string]string{"role": "admin"})
|
||||
if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
|
||||
t.Fatalf("C1 BUG: cross-team member update should be 404/403, got %d: %s\n"+
|
||||
"UpdateMember WHERE clause has no team_id constraint — authorization bypass",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Alice (Team A admin) tries to remove Bob from Team B
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken, nil)
|
||||
if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
|
||||
t.Fatalf("C1 BUG: cross-team member delete should be 404/403, got %d: %s\n"+
|
||||
"RemoveMember WHERE clause has no team_id constraint — authorization bypass",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team Audit Log ─────────────────────────
|
||||
|
||||
func TestAudit_TeamAuditLog(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create team + add admin as team member so audit actions are scoped
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "AuditTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
// ── Query audit log (team admin) ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("team audit log: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var auditResp map[string]interface{}
|
||||
decode(w, &auditResp)
|
||||
// Must use "data" envelope
|
||||
if _, ok := auditResp["data"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/audit must return 'data' key (envelope convention)")
|
||||
}
|
||||
if _, ok := auditResp["total"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/audit must return 'total' key (paginated)")
|
||||
}
|
||||
|
||||
// ── Query audit actions ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit/actions", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("team audit actions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var actionsResp map[string]interface{}
|
||||
decode(w, &actionsResp)
|
||||
if _, ok := actionsResp["actions"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/audit/actions must return 'actions' key")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Permissions Listing ──────────────
|
||||
|
||||
func TestAudit_AdminPermissions(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// ── List all permissions ──
|
||||
w := h.request("GET", "/api/v1/admin/permissions", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list permissions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var permResp map[string]interface{}
|
||||
decode(w, &permResp)
|
||||
perms, ok := permResp["permissions"]
|
||||
if !ok {
|
||||
t.Fatal("GET /admin/permissions must return 'permissions' key")
|
||||
}
|
||||
permList := perms.([]interface{})
|
||||
if len(permList) == 0 {
|
||||
t.Fatal("permissions list should not be empty")
|
||||
}
|
||||
|
||||
// Verify known permissions are present
|
||||
found := false
|
||||
for _, p := range permList {
|
||||
if p.(string) == "model.use" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("permissions list should contain 'model.use'")
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Effective Permissions ─────────────
|
||||
|
||||
func TestAudit_UserEffectivePermissions(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
userID := database.SeedTestUser(t, "charlie", "charlie@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// ── Get permissions for user ──
|
||||
w := h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", userID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get user permissions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
if _, ok := resp["permissions"]; !ok {
|
||||
t.Fatal("GET /admin/users/:id/permissions must return 'permissions' key")
|
||||
}
|
||||
if _, ok := resp["user_id"]; !ok {
|
||||
t.Fatal("GET /admin/users/:id/permissions must return 'user_id' key")
|
||||
}
|
||||
// H10: must include contributing groups
|
||||
groups, ok := resp["groups"]
|
||||
if !ok {
|
||||
t.Fatal("GET /admin/users/:id/permissions must return 'groups' key (contributing groups)")
|
||||
}
|
||||
groupList := groups.([]interface{})
|
||||
// Should always contain the Everyone group
|
||||
found := false
|
||||
for _, g := range groupList {
|
||||
if g.(string) == "00000000-0000-0000-0000-000000000001" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("groups should always include the Everyone group, got %v", groupList)
|
||||
}
|
||||
|
||||
// ── Get permissions for admin (should include their own set) ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", adminID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get admin permissions: want 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── System Group Deletion Rejection ────────
|
||||
|
||||
func TestAudit_SystemGroupCannotBeDeleted(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
everyoneID := "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// Seed the Everyone group (TruncateAll wipes migration-seeded data)
|
||||
_, err := database.TestDB.Exec(dialectSQL(`
|
||||
INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
|
||||
VALUES ($1, $2, $3, $4, NULL, $5, $6)
|
||||
`), everyoneID, "Everyone",
|
||||
"Implicit group — all authenticated users receive these permissions.",
|
||||
"global", "system", `["model.use","kb.read","channel.create"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed Everyone group: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to delete the Everyone system group → should fail with 400
|
||||
w := h.request("DELETE", "/api/v1/admin/groups/"+everyoneID, adminToken, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("delete system group: want 400, got %d: %s\n"+
|
||||
"System groups (source=system) must be protected from deletion",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resource Grant DELETE Round-Trip ────────
|
||||
|
||||
func TestAudit_ResourceGrantDelete(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create a persona to grant
|
||||
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
|
||||
"name": "Grant Test Persona",
|
||||
"base_model_id": "test-model",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var persona map[string]interface{}
|
||||
decode(w, &persona)
|
||||
personaID := persona["id"].(string)
|
||||
|
||||
// Set a grant
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken,
|
||||
map[string]interface{}{
|
||||
"grant_scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify the grant exists
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get grant: want 200, got %d", w.Code)
|
||||
}
|
||||
var grantResp map[string]interface{}
|
||||
decode(w, &grantResp)
|
||||
if grantResp["grant_scope"] != "global" {
|
||||
t.Fatalf("grant_scope should be global, got %v", grantResp["grant_scope"])
|
||||
}
|
||||
|
||||
// DELETE the grant
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// After delete, GET should return default (team_only)
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get after delete: want 200, got %d", w.Code)
|
||||
}
|
||||
decode(w, &grantResp)
|
||||
if grantResp["grant_scope"] != "team_only" {
|
||||
t.Fatalf("after delete, grant_scope should fall back to team_only, got %v", grantResp["grant_scope"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── H8: Team Providers Envelope ────────────
|
||||
// BUG: ListTeamProviders returns {"providers": [...]} instead of {"data": [...]}.
|
||||
|
||||
func TestAudit_TeamProvidersEnvelope(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "ProvEnvTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team providers: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Must use "data" not "providers"
|
||||
if _, ok := resp["data"]; !ok {
|
||||
t.Fatalf("H8 BUG: GET /teams/:teamId/providers should return 'data' key (envelope convention), "+
|
||||
"got keys: %v", mapKeys(resp))
|
||||
}
|
||||
if _, ok := resp["allow_team_providers"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/providers must include 'allow_team_providers'")
|
||||
}
|
||||
}
|
||||
|
||||
// ── H9: Team Roles Envelope ────────────────
|
||||
// BUG: ListTeamRoles returns bare map, not wrapped in {"data": ...}.
|
||||
|
||||
func TestAudit_TeamRolesEnvelope(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "RolesEnvTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team roles: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Should be wrapped in {"data": {...}} per composite convention
|
||||
if _, ok := resp["data"]; !ok {
|
||||
t.Fatalf("H9 BUG: GET /teams/:teamId/roles should return 'data' key (composite convention), "+
|
||||
"got keys: %v — bare map returned without wrapper", mapKeys(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team Groups Listing ────────────────────
|
||||
|
||||
func TestAudit_TeamGroupsListing(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "GroupsTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/groups", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team groups: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Must use "data" envelope
|
||||
if _, ok := resp["data"]; !ok {
|
||||
t.Fatalf("GET /teams/:teamId/groups must return 'data' key, got keys: %v", mapKeys(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: map keys for diagnostic output ──
|
||||
|
||||
func mapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user