Changeset 0.16.0 (#74)

This commit is contained in:
2026-02-27 02:38:35 +00:00
parent 1370d701af
commit 8bb77710b9
28 changed files with 3050 additions and 492 deletions

365
server/handlers/groups.go Normal file
View File

@@ -0,0 +1,365 @@
package handlers
import (
"database/sql"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Request types ───────────────────────────
type createGroupRequest struct {
Name string `json:"name" binding:"required,min=1,max=200"`
Description string `json:"description,omitempty"`
Scope string `json:"scope" binding:"required,oneof=global team"`
TeamID *string `json:"team_id,omitempty"`
}
type updateGroupRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
type addGroupMemberRequest struct {
UserID string `json:"user_id" binding:"required"`
}
type setResourceGrantRequest struct {
GrantScope string `json:"grant_scope" binding:"required,oneof=team_only global groups"`
GrantedGroups []string `json:"granted_groups,omitempty"` // required when grant_scope=groups
}
// ── Handler ─────────────────────────────────
type GroupHandler struct {
stores store.Stores
}
func NewGroupHandler(s store.Stores) *GroupHandler {
return &GroupHandler{stores: s}
}
// ── Admin: List All Groups ──────────────────
func (h *GroupHandler) ListGroups(c *gin.Context) {
groups, err := h.stores.Groups.ListAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if groups == nil {
groups = []models.Group{}
}
c.JSON(http.StatusOK, gin.H{"data": groups})
}
// ── Admin: Create Group ─────────────────────
func (h *GroupHandler) CreateGroup(c *gin.Context) {
actorID := getUserID(c)
var req createGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate scope constraints
if req.Scope == models.ScopeTeam && (req.TeamID == nil || *req.TeamID == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped groups"})
return
}
if req.Scope == models.ScopeGlobal && req.TeamID != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id must be null for global groups"})
return
}
g := &models.Group{
Name: req.Name,
Description: req.Description,
Scope: req.Scope,
TeamID: req.TeamID,
CreatedBy: actorID,
}
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
c.JSON(http.StatusCreated, g)
AuditLog(c, "group.create", "group", g.ID, map[string]interface{}{
"name": g.Name, "scope": g.Scope,
})
}
// ── Admin: Get Group ────────────────────────
func (h *GroupHandler) GetGroup(c *gin.Context) {
id := c.Param("id")
g, err := h.stores.Groups.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, g)
}
// ── Admin: Update Group ─────────────────────
func (h *GroupHandler) UpdateGroup(c *gin.Context) {
id := c.Param("id")
var req updateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name == nil && req.Description == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
err := h.stores.Groups.Update(c.Request.Context(), id, req.Name, req.Description)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.update", "group", id, nil)
}
// ── Admin: Delete Group ─────────────────────
func (h *GroupHandler) DeleteGroup(c *gin.Context) {
id := c.Param("id")
err := h.stores.Groups.Delete(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.delete", "group", id, nil)
}
// ── Members: List ───────────────────────────
func (h *GroupHandler) ListMembers(c *gin.Context) {
groupID := c.Param("id")
members, err := h.stores.Groups.ListMembers(c.Request.Context(), groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if members == nil {
members = []models.GroupMember{}
}
c.JSON(http.StatusOK, gin.H{"data": members})
}
// ── Members: Add ────────────────────────────
func (h *GroupHandler) AddMember(c *gin.Context) {
groupID := c.Param("id")
actorID := getUserID(c)
var req addGroupMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify group exists
if _, err := h.stores.Groups.GetByID(c.Request.Context(), groupID); err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if err := h.stores.Groups.AddMember(c.Request.Context(), groupID, req.UserID, actorID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.member.add", "group", groupID, map[string]interface{}{
"user_id": req.UserID,
})
}
// ── Members: Remove ─────────────────────────
func (h *GroupHandler) RemoveMember(c *gin.Context) {
groupID := c.Param("id")
userID := c.Param("userId")
err := h.stores.Groups.RemoveMember(c.Request.Context(), groupID, userID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove member"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.member.remove", "group", groupID, map[string]interface{}{
"user_id": userID,
})
}
// ── User: My Groups ─────────────────────────
func (h *GroupHandler) MyGroups(c *gin.Context) {
userID := getUserID(c)
groups, err := h.stores.Groups.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if groups == nil {
groups = []models.Group{}
}
c.JSON(http.StatusOK, gin.H{"data": groups})
}
// ── Team Admin: List Team Groups ────────────
func (h *GroupHandler) ListTeamGroups(c *gin.Context) {
teamID := getTeamID(c)
groups, err := h.stores.Groups.ListForTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if groups == nil {
groups = []models.Group{}
}
c.JSON(http.StatusOK, gin.H{"data": groups})
}
// ── Resource Grants ─────────────────────────
// GetResourceGrant returns the grant for a resource.
// GET /api/v1/admin/grants/:type/:id
func (h *GroupHandler) GetResourceGrant(c *gin.Context) {
resourceType := c.Param("type")
resourceID := c.Param("id")
grant, err := h.stores.ResourceGrants.Get(c.Request.Context(), resourceType, resourceID)
if err == sql.ErrNoRows {
// No grant = default team_only behavior
c.JSON(http.StatusOK, gin.H{
"resource_type": resourceType,
"resource_id": resourceID,
"grant_scope": models.GrantScopeTeamOnly,
"granted_groups": []string{},
})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, grant)
}
// SetResourceGrant creates or updates a grant for a resource.
// PUT /api/v1/admin/grants/:type/:id
func (h *GroupHandler) SetResourceGrant(c *gin.Context) {
resourceType := c.Param("type")
resourceID := c.Param("id")
actorID := getUserID(c)
var req setResourceGrantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate: groups scope requires at least one group
if req.GrantScope == models.GrantScopeGroups && len(req.GrantedGroups) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "granted_groups required when grant_scope is 'groups'"})
return
}
// team_only = delete any existing grant (reverts to default scope behavior)
if req.GrantScope == models.GrantScopeTeamOnly {
_ = h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID)
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "grant.revoke", resourceType, resourceID, nil)
return
}
grant := &models.ResourceGrant{
ResourceType: resourceType,
ResourceID: resourceID,
GrantScope: req.GrantScope,
GrantedGroups: req.GrantedGroups,
CreatedBy: actorID,
}
if err := h.stores.ResourceGrants.Set(c.Request.Context(), grant); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set grant"})
return
}
c.JSON(http.StatusOK, grant)
AuditLog(c, "grant.set", resourceType, resourceID, map[string]interface{}{
"grant_scope": req.GrantScope,
})
}
// DeleteResourceGrant removes a grant for a resource.
// DELETE /api/v1/admin/grants/:type/:id
func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
resourceType := c.Param("type")
resourceID := c.Param("id")
err := h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "grant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "grant.delete", resourceType, resourceID, nil)
}

View File

@@ -16,6 +16,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/roles"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
)
@@ -185,6 +186,25 @@ func setupHarness(t *testing.T) *testHarness {
admin.GET("/presets", presets.ListAdminPersonas)
admin.POST("/presets", presets.CreateAdminPersona)
// Admin groups (v0.16.0)
groupAdm := NewGroupHandler(stores)
admin.GET("/groups", groupAdm.ListGroups)
admin.POST("/groups", groupAdm.CreateGroup)
admin.GET("/groups/:id", groupAdm.GetGroup)
admin.PUT("/groups/:id", groupAdm.UpdateGroup)
admin.DELETE("/groups/:id", groupAdm.DeleteGroup)
admin.GET("/groups/:id/members", groupAdm.ListMembers)
admin.POST("/groups/:id/members", groupAdm.AddMember)
admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember)
// Admin resource grants (v0.16.0)
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
// User groups (v0.16.0)
protected.GET("/groups/mine", groupAdm.MyGroups)
// Admin roles
rolesH := NewRolesHandler(stores, roleResolver)
admin.GET("/roles", rolesH.ListRoles)
@@ -2443,3 +2463,397 @@ func TestIntegration_ChannelKBLinking(t *testing.T) {
t.Fatalf("channel should have 0 linked KBs after unlink, got %d", len(emptyData))
}
}
// ══════════════════════════════════════════════
// GROUPS + RESOURCE GRANTS (v0.16.0)
// ══════════════════════════════════════════════
func TestGroupCRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("gadmin", "gadmin@test.com")
// ── Create global group ──
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "Engineering",
"description": "All engineers",
"scope": "global",
})
if w.Code != http.StatusCreated {
t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
}
var created models.Group
decode(w, &created)
if created.ID == "" || created.Name != "Engineering" || created.Scope != "global" {
t.Fatalf("unexpected group: %+v", created)
}
// ── List groups ──
w = h.request("GET", "/api/v1/admin/groups", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list groups: want 200, got %d", w.Code)
}
var listResp map[string]interface{}
decode(w, &listResp)
data := listResp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 group, got %d", len(data))
}
// ── Get group ──
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get group: want 200, got %d", w.Code)
}
// ── Update group ──
w = h.request("PUT", "/api/v1/admin/groups/"+created.ID, adminToken, map[string]interface{}{
"name": "Platform Engineering",
})
if w.Code != http.StatusOK {
t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify update
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
var updated models.Group
decode(w, &updated)
if updated.Name != "Platform Engineering" {
t.Fatalf("name should be updated, got %q", updated.Name)
}
// ── Duplicate name conflict ──
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "Platform Engineering",
"scope": "global",
})
if w.Code != http.StatusConflict {
t.Fatalf("duplicate name: want 409, got %d", w.Code)
}
// ── Delete group ──
w = h.request("DELETE", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete group: want 200, got %d", w.Code)
}
// Verify deleted
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("deleted group: want 404, got %d", w.Code)
}
}
func TestGroupMembers(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com")
userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
userToken := makeToken(userID, "gmuser@test.com", "user")
// Create group
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "Testers",
"scope": "global",
})
var group models.Group
decode(w, &group)
// ── Add member ──
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
"user_id": userID,
})
if w.Code != http.StatusOK {
t.Fatalf("add member: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── List members ──
w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list members: want 200, got %d", w.Code)
}
var memberResp map[string]interface{}
decode(w, &memberResp)
members := memberResp["data"].([]interface{})
if len(members) != 1 {
t.Fatalf("expected 1 member, got %d", len(members))
}
m := members[0].(map[string]interface{})
if m["username"] != "gmuser" {
t.Fatalf("expected username gmuser, got %v", m["username"])
}
// ── My groups (user endpoint) ──
w = h.request("GET", "/api/v1/groups/mine", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("my groups: want 200, got %d", w.Code)
}
var myResp map[string]interface{}
decode(w, &myResp)
myGroups := myResp["data"].([]interface{})
if len(myGroups) != 1 {
t.Fatalf("user should be in 1 group, got %d", len(myGroups))
}
// ── Idempotent add (no error) ──
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
"user_id": userID,
})
if w.Code != http.StatusOK {
t.Fatalf("idempotent add: want 200, got %d", w.Code)
}
// ── Remove member ──
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify removed
w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
decode(w, &memberResp)
members = memberResp["data"].([]interface{})
if len(members) != 0 {
t.Fatalf("expected 0 members after removal, got %d", len(members))
}
}
func TestGroupScopeValidation(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("gsadmin", "gsadmin@test.com")
// Team-scoped without team_id → 400
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "Bad Group",
"scope": "team",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("team scope without team_id: want 400, got %d", w.Code)
}
// Global with team_id → 400
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "Bad Group",
"scope": "global",
"team_id": "00000000-0000-0000-0000-000000000001",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("global scope with team_id: want 400, got %d", w.Code)
}
}
func TestResourceGrants(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("rgadmin", "rgadmin@test.com")
// Create a group
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "Grantees",
"scope": "global",
})
var group models.Group
decode(w, &group)
// Create a persona (admin)
w = h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
"name": "Test Bot",
"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)
// ── Get grant (no grant yet → 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 grant: want 200, got %d", w.Code)
}
var grantResp map[string]interface{}
decode(w, &grantResp)
if grantResp["grant_scope"] != "team_only" {
t.Fatalf("default grant_scope should be team_only, got %v", grantResp["grant_scope"])
}
// ── Set grant: groups scope ──
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
"grant_scope": "groups",
"granted_groups": []string{group.ID},
})
if w.Code != http.StatusOK {
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── Get grant (now groups) ──
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
decode(w, &grantResp)
if grantResp["grant_scope"] != "groups" {
t.Fatalf("grant_scope should be groups, got %v", grantResp["grant_scope"])
}
// ── Set grant: global scope ──
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 global grant: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── Revoke: set back to team_only (deletes grant row) ──
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
"grant_scope": "team_only",
})
if w.Code != http.StatusOK {
t.Fatalf("revoke grant: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── Validation: groups scope without groups → 400 ──
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
"grant_scope": "groups",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("groups without list: want 400, got %d", w.Code)
}
}
func TestGroupBasedPersonaAccess(t *testing.T) {
h := setupHarness(t)
// Setup: admin + regular user (not on any team)
adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com")
userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
userToken := makeToken(userID, "gpuser@test.com", "user")
// Create a team
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Secret Team",
})
var teamResp map[string]interface{}
decode(w, &teamResp)
teamID := teamResp["id"].(string)
// Create persona scoped to that team (user shouldn't see it without group access)
var personaID string
err := database.DB.QueryRow(`
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamID, adminID).Scan(&personaID)
if err != nil {
t.Fatalf("insert persona: %v", err)
}
if personaID == "" {
t.Fatal("personaID is empty after insert")
}
// ── User should NOT see team persona ──
w = h.request("GET", "/api/v1/presets", userToken, nil)
var presetsResp map[string]interface{}
decode(w, &presetsResp)
presets, _ := presetsResp["presets"].([]interface{})
for _, p := range presets {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
t.Fatalf("user should NOT see team persona without group access")
}
}
// ── Create group + add user + grant persona ──
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "Secret Readers",
"scope": "global",
})
var group models.Group
decode(w, &group)
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
"user_id": userID,
})
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
"grant_scope": "groups",
"granted_groups": []string{group.ID},
})
if w.Code != http.StatusOK {
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
}
// Diagnostic: verify grant row exists
var grantCount int
database.DB.QueryRow(`SELECT COUNT(*) FROM resource_grants WHERE resource_type = 'persona' AND resource_id = $1`, personaID).Scan(&grantCount)
if grantCount == 0 {
t.Fatal("DIAG: resource_grant row missing after SET")
}
// Diagnostic: verify group membership
var memberCount int
database.DB.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, group.ID, userID).Scan(&memberCount)
if memberCount == 0 {
t.Fatal("DIAG: group_members row missing")
}
// Diagnostic: verify persona exists
var personaExists bool
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM personas WHERE id = $1 AND is_active = true)`, personaID).Scan(&personaExists)
if !personaExists {
t.Fatal("DIAG: persona not found or not active")
}
// Diagnostic: run the subquery directly
var subqCount int
database.DB.QueryRow(`
SELECT COUNT(*) FROM resource_grants rg
WHERE rg.resource_type = 'persona'
AND (
rg.grant_scope = 'global'
OR (rg.grant_scope = 'groups'
AND EXISTS (
SELECT 1 FROM group_members gm
WHERE gm.user_id = $1
AND gm.group_id = ANY(rg.granted_groups)
))
)
`, userID).Scan(&subqCount)
t.Logf("DIAG: grant_count=%d member_count=%d persona_exists=%v subquery_matches=%d personaID=%s groupID=%s userID=%s",
grantCount, memberCount, personaExists, subqCount, personaID, group.ID, userID)
// ── User should NOW see team persona via group grant ──
w = h.request("GET", "/api/v1/presets", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
}
decode(w, &presetsResp)
presets, _ = presetsResp["presets"].([]interface{})
found := false
for _, p := range presets {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
found = true
break
}
}
if !found {
t.Fatalf("user should see team persona after group grant, but didn't find it in %d presets (response: %s)", len(presets), w.Body.String())
}
// ── Remove user from group → should lose access ──
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
w = h.request("GET", "/api/v1/presets", userToken, nil)
decode(w, &presetsResp)
presets, _ = presetsResp["presets"].([]interface{})
for _, p := range presets {
pm := p.(map[string]interface{})
if pm["id"] == personaID {
t.Fatalf("user should NOT see persona after group removal")
}
}
}