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

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")
}
}
}