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