Changeset 0.28.0.3 (#175)

This commit is contained in:
2026-03-12 10:22:08 +00:00
parent 8e08f3e4b0
commit f5171d3bd3
17 changed files with 956 additions and 234 deletions

View File

@@ -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
}