package handlers import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "chat-switchboard/config" authpkg "chat-switchboard/auth" "chat-switchboard/database" "chat-switchboard/middleware" "chat-switchboard/models" "chat-switchboard/roles" "chat-switchboard/store" postgres "chat-switchboard/store/postgres" sqlite "chat-switchboard/store/sqlite" "chat-switchboard/treepath" ) // ── Test Harness ──────────────────────────── // dialectSQL converts Postgres-style $N placeholders to ? for SQLite, // strips ::jsonb casts, and converts boolean literals to integers. // Allows raw SQL in tests to work on both backends. func dialectSQL(q string) string { if !database.IsSQLite() { return q } result := q // Replace high-to-low to avoid $1 matching inside $10 for i := 20; i >= 1; i-- { result = strings.ReplaceAll(result, fmt.Sprintf("$%d", i), "?") } result = strings.ReplaceAll(result, "::jsonb", "") result = strings.ReplaceAll(result, "::text", "") // Boolean literals: true/false → 1/0 (only bare keywords, not string 'true') result = strings.ReplaceAll(result, "= true", "= 1") result = strings.ReplaceAll(result, "= false", "= 0") result = strings.ReplaceAll(result, ", true)", ", 1)") result = strings.ReplaceAll(result, "COALESCE(is_private, false)", "COALESCE(is_private, 0)") // Time functions result = strings.ReplaceAll(result, "NOW()", "datetime('now')") // NULL sort result = strings.ReplaceAll(result, "NULLS LAST", "") return result } // seedID returns a new UUID for use in test seed data. func seedID() string { return uuid.New().String() } // seedInsertReturningID executes an INSERT with RETURNING id on Postgres, // or injects a generated UUID id on SQLite and returns it. func seedInsertReturningID(t *testing.T, query string, args ...interface{}) string { t.Helper() if !database.IsSQLite() { var id string err := database.TestDB.QueryRow(query, args...).Scan(&id) if err != nil { t.Fatalf("seedInsertReturningID: %v", err) } return id } id := seedID() q := dialectSQL(query) // Remove RETURNING clause if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 { q = strings.TrimSpace(q[:idx]) } // Inject id column q = database.InjectIDForTest(q) newArgs := append([]interface{}{id}, args...) _, err := database.TestDB.Exec(q, newArgs...) if err != nil { t.Fatalf("seedInsertReturningID: %v", err) } return id } // seedExec executes an INSERT via dialectSQL; on SQLite it also injects // a generated id column and value (first arg) so that TEXT PRIMARY KEY // tables that lack a DEFAULT get a proper UUID. func seedExec(t *testing.T, query string, args ...interface{}) { t.Helper() q := dialectSQL(query) if database.IsSQLite() { q = database.InjectIDForTest(q) args = append([]interface{}{seedID()}, args...) } _, err := database.TestDB.Exec(q, args...) if err != nil { t.Fatalf("seedExec: %v\n query: %s", err, q) } } const testJWTSecret = "test-secret-key-for-integration-tests" type testHarness struct { router *gin.Engine t *testing.T } // setupHarness creates a full API router backed by the test database. // Call database.RequireTestDB(t) + database.TruncateAll(t) before using. func setupHarness(t *testing.T) *testHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } treepath.Stores = &stores userCache := middleware.NewUserStatusCache() // Roles resolver (nil vault — test-fire won't work, but CRUD will) roleResolver := roles.NewResolver(stores, nil) r := gin.New() api := r.Group("/api/v1") // Auth (unprotected) auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) authGroup := api.Group("/auth") authGroup.POST("/register", auth.Register) authGroup.POST("/login", auth.Login) // Public settings adm := NewAdminHandler(stores, nil, nil, nil) api.GET("/settings/public", adm.PublicSettings) // Protected routes protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Models models := NewModelHandler(stores) protected.GET("/models/enabled", models.ListEnabledModels) // Model prefs modelPrefs := NewModelPrefsHandler(stores) protected.GET("/models/preferences", modelPrefs.GetPreferences) protected.PUT("/models/preferences", modelPrefs.SetPreference) protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) // User providers provCfg := NewProviderConfigHandler(stores, nil) protected.GET("/api-configs", provCfg.ListConfigs) protected.POST("/api-configs", provCfg.CreateConfig) protected.GET("/api-configs/:id", provCfg.GetConfig) protected.PUT("/api-configs/:id", provCfg.UpdateConfig) protected.DELETE("/api-configs/:id", provCfg.DeleteConfig) protected.GET("/api-configs/:id/models", provCfg.ListModels) protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) // Team self-service (same route group as production) teams := NewTeamHandler(stores, nil) protected.GET("/teams/mine", teams.MyTeams) teamScoped := protected.Group("/teams/:teamId") teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams)) { // 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) // Team roles teamRoles := NewRolesHandler(stores, roleResolver) teamScoped.GET("/roles", teamRoles.ListTeamRoles) teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole) teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) // Team tasks — admin CRUD (v0.28.0-audit) teamTaskH := NewTaskHandler(stores) teamScoped.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.CreateTeamTask) teamScoped.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Update) teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete) teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow) teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun) // Team personas (v0.28.0-audit) teamPersonas := NewPersonaHandler(stores) teamScoped.GET("/personas", teamPersonas.ListTeamPersonas) teamScoped.POST("/personas", teamPersonas.CreateTeamPersona) teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona) teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona) teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) } // Team task viewing for all members (v0.28.0-audit) // NOTE: Uses RequireTeamMember which has raw $1 placeholders — admin // users bypass the DB query so these tests work on both dialects when // the caller is an admin. Non-admin member tests require Postgres. teamMemberRoutes := protected.Group("/teams/:teamId") teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams)) { teamMemberTaskH := NewTaskHandler(stores) teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks) teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns) } // User usage usage := NewUsageHandler(stores) protected.GET("/usage", usage.PersonalUsage) // Profile permissions (v0.37.1) permH := NewProfilePermissionsHandler(stores) protected.GET("/profile/permissions", permH.GetMyPermissions) // Boot payload (v0.37.15) bootH := NewProfileBootstrapHandler(stores) protected.GET("/profile/bootstrap", bootH.GetBootstrap) // Profile / Settings settings := NewSettingsHandler(stores, nil) protected.GET("/profile", settings.GetProfile) protected.PUT("/profile", settings.UpdateProfile) protected.POST("/profile/password", settings.ChangePassword) protected.POST("/profile/avatar", settings.UploadAvatar) protected.DELETE("/profile/avatar", settings.DeleteAvatar) protected.GET("/settings", settings.GetSettings) protected.PUT("/settings", settings.UpdateSettings) // Personas personas := NewPersonaHandler(stores) protected.GET("/personas", personas.ListUserPersonas) protected.POST("/personas", middleware.RequirePermission(authpkg.PermPersonaCreate, stores), personas.CreateUserPersona) protected.PUT("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), personas.UpdateUserPersona) protected.DELETE("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), personas.DeleteUserPersona) protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar) protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar) protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0 // Persona Groups pgH := NewPersonaGroupHandler(stores) protected.GET("/persona-groups", pgH.List) protected.POST("/persona-groups", pgH.Create) protected.GET("/persona-groups/:id", pgH.Get) protected.PUT("/persona-groups/:id", pgH.Update) protected.DELETE("/persona-groups/:id", pgH.Delete) protected.POST("/persona-groups/:id/members", pgH.AddMember) protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember) // Notes notes := NewNoteHandler(stores) protected.GET("/notes", notes.List) protected.POST("/notes", notes.Create) protected.GET("/notes/:id", notes.Get) protected.PUT("/notes/:id", notes.Update) protected.DELETE("/notes/:id", notes.Delete) // Channels channels := NewChannelHandler(stores) protected.GET("/channels", channels.ListChannels) protected.POST("/channels", middleware.RequirePermission(authpkg.PermChannelCreate, stores), channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) protected.DELETE("/channels/:id", channels.DeleteChannel) // Channel participants partH := NewParticipantHandler(stores) protected.GET("/channels/:id/participants", partH.List) protected.POST("/channels/:id/participants", middleware.RequirePermission(authpkg.PermChannelInvite, stores), partH.Add) // Knowledge Bases (nil storage/ingester/embedder — CRUD works, upload/rebuild return 503) kbH := NewKnowledgeBaseHandler(stores, nil, nil, nil) protected.POST("/knowledge-bases", middleware.RequirePermission(authpkg.PermKBCreate, stores), kbH.CreateKB) protected.GET("/knowledge-bases", kbH.ListKBs) protected.GET("/knowledge-bases/:id", kbH.GetKB) protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UpdateKB) protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteKB) protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UploadDocument) protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments) protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus) protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteDocument) protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(authpkg.PermKBRead, stores), kbH.SearchKB) protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.RebuildKB) protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs) protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs) protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0 protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0 // Files (nil storage = upload returns 503, but metadata works) fileH := NewFileHandler(stores, nil, nil) protected.POST("/channels/:id/files", fileH.Upload) protected.GET("/channels/:id/files", fileH.ListByChannel) protected.GET("/files/:id", fileH.GetMetadata) protected.GET("/files/:id/download", fileH.Download) protected.DELETE("/files/:id", fileH.Delete) // Completions completions := NewCompletionHandler(nil, stores, nil, nil, nil) protected.POST("/chat/completions", middleware.RequirePermission(authpkg.PermModelUse, stores), completions.Complete) // Messages msgs := NewMessageHandler(nil, stores, nil, nil) protected.GET("/channels/:id/messages", msgs.ListMessages) protected.POST("/channels/:id/messages", msgs.CreateMessage) protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) protected.GET("/channels/:id/path", msgs.GetActivePath) // Workflows (v0.37.1 — perm enforcement) wfH := NewWorkflowHandler(stores) protected.POST("/workflows", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Create) protected.GET("/workflows", wfH.List) protected.PATCH("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Update) protected.DELETE("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Delete) // Tasks (v0.28.0) taskH := NewTaskHandler(stores) protected.GET("/tasks", taskH.ListMine) // Memory (v0.18.0, audit v0.28.0) memH := NewMemoryHandler(stores) protected.GET("/memories", memH.ListMyMemories) protected.PUT("/memories/:id", memH.UpdateMemory) protected.DELETE("/memories/:id", memH.DeleteMemory) protected.POST("/memories/:id/approve", memH.ApproveMemory) protected.POST("/memories/:id/reject", memH.RejectMemory) protected.GET("/memories/count", memH.MemoryCount) protected.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Create) protected.GET("/tasks/:id", taskH.Get) protected.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Update) protected.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Delete) protected.GET("/tasks/:id/runs", taskH.ListRuns) protected.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.RunNow) protected.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.KillRun) // Webhook trigger (unauthenticated, token-based) triggerH := NewTriggerHandler(stores) api.POST("/hooks/t/:token", triggerH.Handle) // Admin routes admin := api.Group("/admin") admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) admin.GET("/users", adm.ListUsers) admin.POST("/users", adm.CreateUser) admin.PUT("/users/:id/active", adm.ToggleUserActive) admin.GET("/settings", adm.ListGlobalSettings) admin.PUT("/settings/:key", adm.UpdateGlobalSetting) admin.GET("/configs", adm.ListGlobalConfigs) admin.POST("/configs", adm.CreateGlobalConfig) admin.PUT("/configs/:id", adm.UpdateGlobalConfig) admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) admin.GET("/models", adm.ListModelConfigs) admin.PUT("/models/:id", adm.UpdateModelConfig) admin.PUT("/models/bulk", adm.BulkUpdateModels) admin.POST("/models/fetch", adm.FetchModels) 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.PUT("/personas/:id", personas.UpdateAdminPersona) admin.DELETE("/personas/:id", personas.DeleteAdminPersona) admin.POST("/personas/:id/avatar", func(c *gin.Context) { UploadPersonaAvatar(stores.Personas, c) }) admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { DeletePersonaAvatar(stores.Personas, c) }) admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 admin.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0 // 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) // 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) // Admin roles rolesH := NewRolesHandler(stores, roleResolver) admin.GET("/roles", rolesH.ListRoles) admin.GET("/roles/:role", rolesH.GetRole) admin.PUT("/roles/:role", rolesH.UpdateRole) // Admin usage + pricing usageH := NewUsageHandler(stores) admin.GET("/usage", usageH.AdminUsage) admin.GET("/usage/users/:id", usageH.AdminUserUsage) admin.GET("/usage/teams/:id", usageH.AdminTeamUsage) admin.GET("/pricing", usageH.ListPricing) admin.PUT("/pricing", usageH.UpsertPricing) admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing) // Admin tasks taskAdm := NewTaskHandler(stores) admin.GET("/tasks", taskAdm.ListAll) admin.POST("/tasks/:id/run", taskAdm.RunNow) admin.POST("/tasks/:id/kill", taskAdm.KillRun) admin.DELETE("/tasks/:id", taskAdm.Delete) // Admin memory review adminMemH := NewMemoryHandler(stores) admin.GET("/memories/pending", adminMemH.ListPendingReview) admin.POST("/memories/bulk-approve", adminMemH.BulkApprove) return &testHarness{router: r, t: t} } // makeToken generates a valid JWT for a test user. func makeToken(userID, email, role string) string { claims := Claims{ UserID: userID, Email: email, Role: role, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), Issuer: "chat-switchboard", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) s, _ := token.SignedString([]byte(testJWTSecret)) return s } // request makes an HTTP request and returns the response recorder. func (h *testHarness) request(method, path, token string, body interface{}) *httptest.ResponseRecorder { h.t.Helper() var reader io.Reader if body != nil { b, _ := json.Marshal(body) reader = bytes.NewReader(b) } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() h.router.ServeHTTP(w, req) return w } // decode JSON response into target. func decode(w *httptest.ResponseRecorder, target interface{}) error { return json.Unmarshal(w.Body.Bytes(), target) } // ── Seed Helpers ──────────────────────────── // registerUser registers a user via the API and returns the user_id + token. func (h *testHarness) registerUser(username, email, password string) (userID, token string) { h.t.Helper() w := h.request("POST", "/api/v1/auth/register", "", map[string]string{ "username": username, "email": email, "password": password, }) if w.Code != http.StatusCreated && w.Code != http.StatusOK { h.t.Fatalf("register %s: want 200/201, got %d: %s", username, w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) token, _ = resp["access_token"].(string) if token == "" { h.t.Fatalf("register %s: no access_token in response (user inactive? default_user_active policy may not have committed): %s", username, w.Body.String()) } // Extract user_id from profile w2 := h.request("GET", "/api/v1/profile", token, nil) var profile map[string]interface{} decode(w2, &profile) userID, _ = profile["id"].(string) return userID, token } // createAdminUser seeds an admin directly in DB, returns user_id + token. func (h *testHarness) createAdminUser(username, email string) (userID, token string) { h.t.Helper() userID = database.SeedTestUser(h.t, username, email) // Make admin database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID) token = makeToken(userID, email, "admin") return } // ═══════════════════════════════════════════ // TESTS // ═══════════════════════════════════════════ // ── 1. Auth ───────────────────────────────── func TestIntegration_Auth_Register(t *testing.T) { h := setupHarness(t) // Enable registration policy database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") w := h.request("POST", "/api/v1/auth/register", "", map[string]string{ "username": "alice", "email": "alice@test.com", "password": "password123", }) if w.Code != http.StatusCreated { t.Fatalf("register: want 201, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) if resp["access_token"] == nil || resp["access_token"] == "" { t.Fatal("register should return access_token") } } func TestIntegration_Auth_Login(t *testing.T) { h := setupHarness(t) database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") h.registerUser("bob", "bob@test.com", "password123") w := h.request("POST", "/api/v1/auth/login", "", map[string]string{ "login": "bob@test.com", "password": "password123", }) if w.Code != http.StatusOK { t.Fatalf("login: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) if resp["access_token"] == nil { t.Fatal("login should return access_token") } } func TestIntegration_Auth_ProfileRequiresToken(t *testing.T) { h := setupHarness(t) w := h.request("GET", "/api/v1/profile", "", nil) if w.Code != http.StatusUnauthorized { t.Fatalf("profile without token: want 401, got %d", w.Code) } } // ── 2. Admin Users ────────────────────────── func TestIntegration_AdminListUsers(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // page/per_page support w := h.request("GET", "/api/v1/admin/users?page=1&per_page=50", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin list users: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) users, ok := resp["data"].([]interface{}) if !ok { t.Fatalf("response must have 'data' array, got %T", resp["data"]) } if len(users) < 1 { t.Fatal("should have at least 1 user (admin)") } total, ok := resp["total"].(float64) if !ok || total < 1 { t.Fatalf("response must have 'total' >= 1, got %v", resp["total"]) } } func TestIntegration_AdminListUsers_NonAdmin403(t *testing.T) { h := setupHarness(t) database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("user1", "user1@test.com", "password123") w := h.request("GET", "/api/v1/admin/users?page=1&per_page=50", userToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("non-admin list users: want 403, got %d", w.Code) } } // ── 3. Admin Settings / Policies ──────────── func TestIntegration_AdminPolicyRoundtrip(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") policies := []struct { key string value string }{ {"allow_registration", "false"}, {"allow_user_byok", "true"}, {"allow_user_personas", "true"}, {"default_user_active", "true"}, } for _, p := range policies { w := h.request("PUT", "/api/v1/admin/settings/"+p.key, adminToken, map[string]interface{}{"value": p.value}) if w.Code != http.StatusOK { t.Fatalf("set policy %s: want 200, got %d: %s", p.key, w.Code, w.Body.String()) } } // Verify via ListGlobalSettings w := h.request("GET", "/api/v1/admin/settings", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get settings: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) respPolicies, ok := resp["policies"].(map[string]interface{}) if !ok { t.Fatalf("settings response must have 'policies' map, got %v", resp) } if respPolicies["allow_user_byok"] != "true" { t.Errorf("allow_user_byok: want 'true', got %v", respPolicies["allow_user_byok"]) } if respPolicies["allow_user_personas"] != "true" { t.Errorf("allow_user_personas: want 'true', got %v", respPolicies["allow_user_personas"]) } } func TestIntegration_PublicSettingsExposePolicies(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Set a policy h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, map[string]interface{}{"value": "true"}) // Public settings (no auth required) w := h.request("GET", "/api/v1/settings/public", "", nil) if w.Code != http.StatusOK { t.Fatalf("public settings: want 200, got %d", w.Code) } var resp map[string]interface{} decode(w, &resp) policies, ok := resp["policies"].(map[string]interface{}) if !ok { t.Fatalf("public settings must have 'policies', got %v", resp) } if policies["allow_user_byok"] != "true" { t.Errorf("public allow_user_byok: want 'true', got %v", policies["allow_user_byok"]) } if policies["allow_user_personas"] == nil { t.Error("public settings should expose allow_user_personas") } } // ── 4. Provider Configs (Admin) ────────────── func TestIntegration_AdminProviderConfigCRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "Test OpenAI", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test123", }) if w.Code != http.StatusCreated { t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) configID := created["id"].(string) // ── Verify API key is actually stored in DB ── var storedKey string err := database.TestDB.QueryRow( dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID, ).Scan(&storedKey) if err != nil { t.Fatalf("query stored key: %v", err) } if storedKey != "sk-test123" { t.Fatalf("API key not stored: want 'sk-test123', got %q", storedKey) } // List w = h.request("GET", "/api/v1/admin/configs", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list configs: want 200, got %d", w.Code) } // ── Update API key via PUT ── w = h.request("PUT", fmt.Sprintf("/api/v1/admin/configs/%s", configID), adminToken, map[string]interface{}{"api_key": "sk-updated456"}) if w.Code != http.StatusOK { t.Fatalf("update config: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify the key was actually updated err = database.TestDB.QueryRow( dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID, ).Scan(&storedKey) if err != nil { t.Fatalf("query updated key: %v", err) } if storedKey != "sk-updated456" { t.Fatalf("API key not updated: want 'sk-updated456', got %q", storedKey) } // Delete w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/configs/%s", configID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String()) } } // TestIntegration_AdminProviderAPIKeyUsedByFetch verifies that the stored API // key is actually passed to the provider when fetching models. This catches // the json:"-" bug where CreateGlobalConfig silently dropped the API key. func TestIntegration_AdminProviderAPIKeyUsedByFetch(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create provider with a deliberate bad key — we expect the fetch to // return an auth error FROM the provider, proving the key was sent. w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "KeyTest", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-badkey-for-test", }) if w.Code != http.StatusCreated { t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String()) } var cfg map[string]interface{} decode(w, &cfg) configID := cfg["id"].(string) // Verify key stored in DB var storedKey string database.TestDB.QueryRow( dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID, ).Scan(&storedKey) if storedKey != "sk-badkey-for-test" { t.Fatalf("key not stored: want 'sk-badkey-for-test', got %q — json:\"-\" bug is back", storedKey) } // Fetch models — this will fail (bad key) but the error message should // contain an auth/HTTP error from OpenAI, NOT a DNS or empty-key error. // We're not testing OpenAI connectivity here, just that the key reaches // the provider layer. w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{"provider_config_id": configID}) // Accept either 502 (single provider fetch fails) or 200 with errors array. // The key point: the handler TRIED to call the provider with our key. var fetchResp map[string]interface{} decode(w, &fetchResp) if w.Code == http.StatusOK { // Multi-provider path returns 200 with errors if errs, ok := fetchResp["errors"]; ok { errList := errs.([]interface{}) if len(errList) > 0 { errMsg := errList[0].(string) t.Logf(" Provider returned error (expected): %s", errMsg) } } } else if w.Code == http.StatusBadGateway { // Single-provider path returns 502 errMsg, _ := fetchResp["error"].(string) t.Logf(" Provider returned error (expected): %s", errMsg) } else { t.Fatalf("fetch models: unexpected status %d: %s", w.Code, w.Body.String()) } } // ── 5. Model Visibility / Resolution ───────── func TestIntegration_ModelVisibilityResolution(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("admin", "admin@test.com") _ = adminID // Create global provider config w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "TestProvider", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test", }) if w.Code != http.StatusCreated { t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String()) } var cfg map[string]interface{} decode(w, &cfg) configID := cfg["id"].(string) // Insert a model into catalog directly (simulating fetch) seedExec(t, ` INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility) VALUES ($1, 'gpt-4o', 'GPT-4o', 'disabled') `, configID) // As admin, models/enabled should return empty (model disabled) w = h.request("GET", "/api/v1/models/enabled", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String()) } var modelsResp map[string]interface{} decode(w, &modelsResp) modelsList := modelsResp["data"].([]interface{}) if len(modelsList) != 0 { t.Errorf("disabled model should not appear, got %d models", len(modelsList)) } // Enable the model var catalogID string database.TestDB.QueryRow(dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID).Scan(&catalogID) w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken, map[string]interface{}{"visibility": "enabled"}) if w.Code != http.StatusOK { t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String()) } // Now models/enabled should return 1 model w = h.request("GET", "/api/v1/models/enabled", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String()) } decode(w, &modelsResp) modelsList = modelsResp["data"].([]interface{}) if len(modelsList) != 1 { t.Errorf("enabled model should appear, want 1 got %d", len(modelsList)) } } // ── 6. Teams ───────────────────────────────── func TestIntegration_TeamMemberManagement(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create regular user userID := database.SeedTestUser(t, "alice", "alice@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": "Engineering", "description": "Eng team", }) if w.Code != http.StatusCreated { t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String()) } var team map[string]interface{} decode(w, &team) teamID := team["id"].(string) // List users (verify response has 'users' field, not 'data') w = h.request("GET", "/api/v1/admin/users?page=1&per_page=200", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list users: want 200, got %d", w.Code) } var usersResp map[string]interface{} decode(w, &usersResp) if _, ok := usersResp["data"]; !ok { t.Fatal("admin/users response MUST have 'data' key") } users := usersResp["data"].([]interface{}) if len(users) < 2 { t.Fatalf("expected at least 2 users (admin + alice), got %d", len(users)) } // 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.StatusOK && w.Code != http.StatusCreated { t.Fatalf("add member: want 200/201, got %d: %s", w.Code, w.Body.String()) } // List members w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list members: want 200, got %d: %s", w.Code, w.Body.String()) } 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)) } // Verify the user shows up in teams/mine userToken := makeToken(userID, "alice@test.com", "user") w = h.request("GET", "/api/v1/teams/mine", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("teams/mine: want 200, got %d: %s", w.Code, w.Body.String()) } } // ── 7. Personas (Policy Gated) ─────────────── func TestIntegration_PersonaCreation_PolicyGated(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("admin", "admin@test.com") _ = adminID // Create a provider config (personas need a valid model reference) w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "TestProvider", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test", }) var cfg map[string]interface{} decode(w, &cfg) configID := cfg["id"].(string) // Ensure allow_user_personas = false database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'") // Try to create a persona as admin (role=admin but policy says no) w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ "name": "My Persona", "base_model_id": "gpt-4o", "provider_config_id": configID, "system_prompt": "You are helpful", }) if w.Code != http.StatusForbidden { t.Fatalf("persona create with policy=false: want 403, got %d: %s", w.Code, w.Body.String()) } // Enable the policy h.request("PUT", "/api/v1/admin/settings/allow_user_personas", adminToken, map[string]interface{}{"value": "true"}) // Now creation should succeed w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ "name": "My Persona", "base_model_id": "gpt-4o", "provider_config_id": configID, "system_prompt": "You are helpful", }) if w.Code != http.StatusCreated { t.Fatalf("persona create with policy=true: want 201, got %d: %s", w.Code, w.Body.String()) } // List personas w = h.request("GET", "/api/v1/personas", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String()) } } // ── 8. User Provider BYOK (Policy Gated) ──── func TestIntegration_UserBYOK_PolicyGated(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Ensure allow_user_byok = false database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("bob", "bob@test.com", "password123") // Try to create a personal provider — should fail w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ "name": "MyKey", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-user123", }) if w.Code == http.StatusCreated { t.Fatal("BYOK create should be blocked when allow_user_byok=false") } // Enable BYOK h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, map[string]interface{}{"value": "true"}) // Now should succeed w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ "name": "MyKey", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-user123", }) if w.Code != http.StatusCreated { t.Fatalf("BYOK create with policy=true: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) cfgID := created["id"].(string) // List should only show personal configs, NOT global ones w = h.request("GET", "/api/v1/api-configs", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("list configs: want 200, got %d", w.Code) } // Update w = h.request("PUT", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, map[string]interface{}{"name": "MyKey-Updated"}) if w.Code != http.StatusOK { t.Fatalf("update config: want 200, got %d: %s", w.Code, w.Body.String()) } // Delete w = h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String()) } } // ── 9. Notes CRUD ──────────────────────────── func TestIntegration_NotesCRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create w := h.request("POST", "/api/v1/notes", adminToken, map[string]interface{}{ "title": "Test Note", "content": "Hello world", "folder": "general", }) if w.Code != http.StatusCreated { t.Fatalf("create note: want 201, got %d: %s", w.Code, w.Body.String()) } var note map[string]interface{} decode(w, ¬e) noteID := note["id"].(string) // Get w = h.request("GET", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get note: want 200, got %d", w.Code) } // Update w = h.request("PUT", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken, map[string]interface{}{"title": "Updated", "content": "Updated content"}) if w.Code != http.StatusOK { t.Fatalf("update note: want 200, got %d: %s", w.Code, w.Body.String()) } // List w = h.request("GET", "/api/v1/notes", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list notes: want 200, got %d", w.Code) } // Delete w = h.request("DELETE", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete note: want 200, got %d", w.Code) } } // ── 10. Cross-User Isolation ───────────────── func TestIntegration_CrossUserIsolation(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userAToken := h.registerUser("alice", "alice@test.com", "password123") _, userBToken := h.registerUser("charlie", "charlie@test.com", "password123") // Alice creates a provider w := h.request("POST", "/api/v1/api-configs", userAToken, map[string]interface{}{ "name": "AliceKey", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-alice", }) if w.Code != http.StatusCreated { t.Fatalf("alice create config: want 201, got %d: %s", w.Code, w.Body.String()) } var aliceCfg map[string]interface{} decode(w, &aliceCfg) aliceCfgID := aliceCfg["id"].(string) // Bob should NOT see Alice's provider w = h.request("GET", "/api/v1/api-configs", userBToken, nil) if w.Code != http.StatusOK { t.Fatalf("bob list configs: want 200, got %d", w.Code) } // Bob should NOT be able to delete Alice's provider w = h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", aliceCfgID), userBToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("bob delete alice's config: want 403, got %d", w.Code) } // Admin should NOT see personal providers in admin list (they're in /admin/configs for global only) _ = adminToken } // ── 9. Admin Model Fetch → Enable → User Visibility ───── func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("admin", "admin@test.com") _ = adminID // Create global provider config w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "TestProvider", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test", }) if w.Code != http.StatusCreated { t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String()) } var cfg map[string]interface{} decode(w, &cfg) configID := cfg["id"].(string) // Insert models directly (simulating successful provider fetch) for _, mid := range []string{"gpt-4o", "gpt-4o-mini", "o1-preview"} { seedExec(t, ` INSERT INTO model_catalog (provider_config_id, model_id, display_name, capabilities, visibility) VALUES ($1, $2, $3, '{"streaming":true,"tool_calling":true}'::jsonb, 'disabled') `, configID, mid, mid) } // ── Admin list should show ALL models (including disabled) ── w = h.request("GET", "/api/v1/admin/models", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin list models: want 200, got %d: %s", w.Code, w.Body.String()) } var adminResp map[string]interface{} decode(w, &adminResp) adminModels := adminResp["data"].([]interface{}) if len(adminModels) != 3 { t.Fatalf("admin should see 3 disabled models, got %d", len(adminModels)) } // Verify admin response is non-null array (not {"data": null}) if adminResp["data"] == nil { t.Fatal("admin models must be [] not null — causes frontend fallback chain to break") } // ── User should see 0 models (all disabled) ── userID := database.SeedTestUser(t, "testuser", "user@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) userToken := makeToken(userID, "user@test.com", "user") w = h.request("GET", "/api/v1/models/enabled", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("user models/enabled: want 200, got %d: %s", w.Code, w.Body.String()) } var userResp map[string]interface{} decode(w, &userResp) userModels := userResp["data"].([]interface{}) if len(userModels) != 0 { t.Errorf("disabled models must not appear for user, got %d", len(userModels)) } // Verify user response is non-null array if userResp["data"] == nil { t.Fatal("user models must be [] not null — causes '📋 Loaded 0 models' to crash") } // ── Admin enables one model ── var catalogID string database.TestDB.QueryRow( dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID, ).Scan(&catalogID) w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken, map[string]interface{}{"visibility": "enabled"}) if w.Code != http.StatusOK { t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String()) } // ── User should now see 1 model ── w = h.request("GET", "/api/v1/models/enabled", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("user models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String()) } decode(w, &userResp) userModels = userResp["data"].([]interface{}) if len(userModels) != 1 { t.Fatalf("user should see 1 enabled model, got %d", len(userModels)) } // ── Validate response shape matches frontend contract ── m := userModels[0].(map[string]interface{}) // Backend MUST send provider_config_id (Go struct canonical) if m["provider_config_id"] == nil || m["provider_config_id"] == "" { t.Error("MISSING: provider_config_id — Go struct canonical field") } // Backend MUST send config_id alias (frontend reads this) if m["config_id"] == nil || m["config_id"] == "" { t.Error("MISSING: config_id — frontend alias, composite IDs will break") } // Alias must match canonical if m["config_id"] != m["provider_config_id"] { t.Errorf("config_id (%v) must equal provider_config_id (%v)", m["config_id"], m["provider_config_id"]) } // model_id required for composite ID construction if m["model_id"] == nil || m["model_id"] == "" { t.Error("MISSING: model_id — required for composite ID") } // source required for persona detection if m["source"] == nil || m["source"] == "" { t.Error("MISSING: source — frontend uses this to distinguish catalog vs persona") } // provider_name required for display if m["provider_name"] == nil || m["provider_name"] == "" { t.Error("MISSING: provider_name — frontend model selector display") } // capabilities must be object not null if m["capabilities"] == nil { t.Error("capabilities must not be null") } } // ═══════════════════════════════════════════════════ // USER JOURNEY TESTS — API calls only // ═══════════════════════════════════════════════════ // These tests exercise the ACTUAL user experience. // No insertModel(). No insertProvider(). Only API calls. // // The ONLY raw SQL allowed is labeled [SIMULATED FETCH] // to represent what an external provider API would return, // since integration tests can't hit real OpenAI/Venice APIs. // ═══════════════════════════════════════════════════ // ── Test helpers ── // getModels calls GET /models/enabled and returns the model list. func (h *testHarness) getModels(token string) []interface{} { h.t.Helper() w := h.request("GET", "/api/v1/models/enabled", token, nil) if w.Code != http.StatusOK { h.t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) if resp["data"] == nil { h.t.Fatal("models response must never be null") } return resp["data"].([]interface{}) } func getModelIDs(models []interface{}) []string { ids := make([]string, 0, len(models)) for _, raw := range models { m := raw.(map[string]interface{}) if mid, ok := m["model_id"].(string); ok { ids = append(ids, mid) } } return ids } func hasModel(models []interface{}, modelID string) bool { for _, raw := range models { m := raw.(map[string]interface{}) if m["model_id"] == modelID { return true } } return false } func hasModelWithScope(models []interface{}, modelID, scope string) bool { for _, raw := range models { m := raw.(map[string]interface{}) if m["model_id"] == modelID && m["scope"] == scope { return true } } return false } // simulateFetch inserts models into model_catalog as if a provider API returned them. // This is the ONLY raw SQL in journey tests — clearly labeled because integration // tests cannot hit real external APIs (OpenAI, Venice, etc). func simulateFetch(t *testing.T, providerConfigID string, models []string, visibility string) { t.Helper() for _, modelID := range models { seedExec(t, ` INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility) VALUES ($1, $2, $3, $4) `, providerConfigID, modelID, modelID, visibility) } } // ── Journey 1: Admin creates provider → user sees models ── func TestUserJourney_AdminProvider_UserSeesModels(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Regular user — no special role, no team userID := database.SeedTestUser(t, "alice", "alice@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) userToken := makeToken(userID, "alice@test.com", "user") // Step 1: Admin creates provider via API w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "TestOpenAI", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test", }) if w.Code != http.StatusCreated { t.Fatalf("admin create config: want 201, got %d: %s", w.Code, w.Body.String()) } var cfg map[string]interface{} decode(w, &cfg) configID := cfg["id"].(string) // Step 2: [SIMULATED FETCH] — represents POST /admin/models/fetch hitting OpenAI API // In production: admin clicks "Fetch Models" → backend calls OpenAI → inserts into catalog // In test: we insert directly because we can't call a real API simulateFetch(t, configID, []string{"gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"}, "disabled") // Step 3: User should see 0 models (all disabled) userModels := h.getModels(userToken) if len(userModels) != 0 { t.Fatalf("before admin enables: user should see 0 models, got %d", len(userModels)) } // Step 4: Admin enables one model via API var catalogID string database.TestDB.QueryRow( dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID, ).Scan(&catalogID) w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken, map[string]interface{}{"visibility": "enabled"}) if w.Code != http.StatusOK { t.Fatalf("admin enable model: want 200, got %d: %s", w.Code, w.Body.String()) } // Step 5: User should now see exactly 1 model userModels = h.getModels(userToken) if len(userModels) != 1 { t.Fatalf("after admin enables gpt-4o: user should see 1 model, got %d: %v", len(userModels), getModelIDs(userModels)) } // Step 6: Verify the model has all required frontend fields m := userModels[0].(map[string]interface{}) if m["model_id"] != "gpt-4o" { t.Errorf("expected model_id=gpt-4o, got %v", m["model_id"]) } if m["config_id"] == nil || m["config_id"] == "" { t.Error("MISSING config_id — frontend needs this for composite model ID") } if m["provider_name"] == nil || m["provider_name"] == "" { t.Error("MISSING provider_name — frontend model selector display") } if m["scope"] != "global" { t.Errorf("expected scope=global, got %v", m["scope"]) } } // ── Journey 2: User creates BYOK provider → auto-fetch triggers ── // // After the fix in apiconfigs.go, CreateConfig now auto-fetches models // from the provider API and auto-enables them. In integration tests, // the real API isn't reachable so we get a warning — but the provider // is still created (201). The Live Venice test validates the real flow. func TestUserJourney_BYOK_AutoFetchTriggered(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Enable BYOK policy h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, map[string]interface{}{"value": "true"}) // Regular user database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("bob", "bob@test.com", "password123") // Step 1: User creates BYOK provider via API w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ "name": "My OpenAI Key", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-fake-key", }) if w.Code != http.StatusCreated { t.Fatalf("create BYOK provider: want 201, got %d: %s", w.Code, w.Body.String()) } // Step 2: Response should include the provider ID and a fetch result. // In integration tests, the real API isn't reachable, so we expect: // - id: present (provider was created) // - warning: present (fetch failed — no real API in test) // - models_fetched: 0 (or absent) var created map[string]interface{} decode(w, &created) cfgID := created["id"].(string) if cfgID == "" { t.Fatal("provider creation should return an id") } // Warning is expected in integration tests (can't reach real OpenAI API) if created["warning"] != nil { t.Logf("expected warning in test env: %v", created["warning"]) } // Step 3: models/enabled → 0 personal models (fetch failed, catalog empty) // This is CORRECT behavior in test env. Live Venice test validates real flow. userModels := h.getModels(userToken) personalCount := 0 for _, raw := range userModels { m := raw.(map[string]interface{}) if m["scope"] == "personal" { personalCount++ } } t.Logf("personal models after auto-fetch (test env, no real API): %d", personalCount) } // ── Journey 3: BYOK models exist in catalog but NULL scan kills them ── // // Even if we manually populate the catalog for a BYOK provider // (simulating what auto-fetch WOULD do), the provider scan fails // because model_default is NULL and scanProviders uses bare string. // // This test FAILS before the provider.go fix, PASSES after. func TestUserJourney_BYOK_NullScanKillsModels(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Enable BYOK h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, map[string]interface{}{"value": "true"}) database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("alice", "alice@test.com", "password123") // User creates BYOK provider via API // Use unreachable endpoint so auto-fetch fails — simulateFetch controls the catalog. w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ "name": "My Venice", "provider": "venice", "endpoint": "http://localhost:1/v1", "api_key": "sk-alice-key", }) if w.Code != http.StatusCreated { t.Fatalf("create BYOK: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) cfgID := created["id"].(string) // [SIMULATED FETCH] — what auto-fetch SHOULD do after provider creation // Inserts models with visibility='enabled' (BYOK models should be auto-enabled) simulateFetch(t, cfgID, []string{"llama-3.3-70b", "deepseek-r1"}, "enabled") // User calls models/enabled — should see their 2 personal models userModels := h.getModels(userToken) personalModels := 0 for _, raw := range userModels { m := raw.(map[string]interface{}) if m["scope"] == "personal" { personalModels++ } } // Before provider.go NULL scan fix: personalModels = 0 (scan crashes, models silently lost) // After fix: personalModels = 2 if personalModels != 2 { t.Fatalf("user should see 2 personal BYOK models, got %d\n"+ " If 0: provider.go scanProviders crashes on NULL model_default\n"+ " Check: warn: failed to load personal providers: sql: Scan error", personalModels) } // Verify the models have correct scope and fields if !hasModelWithScope(userModels, "llama-3.3-70b", "personal") { t.Error("llama-3.3-70b should appear with scope=personal") } if !hasModelWithScope(userModels, "deepseek-r1", "personal") { t.Error("deepseek-r1 should appear with scope=personal") } } // ── Journey 4: Team provider → member sees, non-member doesn't ── func TestUserJourney_TeamProvider_MemberVsNonMember(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create team members aliceID := database.SeedTestUser(t, "alice", "alice@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID) aliceToken := makeToken(aliceID, "alice@test.com", "user") bobID := database.SeedTestUser(t, "bob", "bob@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID) bobToken := makeToken(bobID, "bob@test.com", "user") // Step 1: Admin creates team via API w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ "name": "Engineering", "description": "Eng team", }) if w.Code != http.StatusCreated { t.Fatalf("create team: %d: %s", w.Code, w.Body.String()) } var team map[string]interface{} decode(w, &team) teamID := team["id"].(string) // Step 2: Admin adds alice as team admin, bob is NOT added h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, map[string]string{"user_id": aliceID, "role": "admin"}) // Step 3: Team admin (alice) creates team provider via self-service API w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), aliceToken, map[string]interface{}{ "name": "Team Venice", "provider": "venice", "endpoint": "https://api.venice.ai/api/v1", "api_key": "sk-team-key", }) if w.Code != http.StatusCreated { t.Fatalf("create team provider: want 201, got %d: %s", w.Code, w.Body.String()) } var prov map[string]interface{} decode(w, &prov) provID := prov["id"].(string) // Step 4: [SIMULATED FETCH] — what fetching from Venice API would return simulateFetch(t, provID, []string{"llama-3.3-70b", "deepseek-r1"}, "enabled") // Step 5: Also add a global model so we can verify additive behavior gw := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "GlobalOpenAI", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-global", }) var gcfg map[string]interface{} decode(gw, &gcfg) globalCfgID := gcfg["id"].(string) simulateFetch(t, globalCfgID, []string{"gpt-4o"}, "enabled") // Step 6: Alice (team member) sees global + team models aliceModels := h.getModels(aliceToken) if !hasModel(aliceModels, "gpt-4o") { t.Error("alice should see global model gpt-4o") } if !hasModel(aliceModels, "llama-3.3-70b") { t.Errorf("alice (team member) should see team model llama-3.3-70b, got: %v", getModelIDs(aliceModels)) } // Step 7: Bob (NOT in team) sees ONLY global models bobModels := h.getModels(bobToken) if !hasModel(bobModels, "gpt-4o") { t.Error("bob should see global model gpt-4o") } if hasModel(bobModels, "llama-3.3-70b") { t.Error("bob (non-member) should NOT see team model llama-3.3-70b") } if hasModel(bobModels, "deepseek-r1") { t.Error("bob (non-member) should NOT see team model deepseek-r1") } } // ── Journey 5: Full 4-actor matrix ── // // All providers created via API. All assertions via API. // Only raw SQL is [SIMULATED FETCH]. // // Actors: // platformAdmin — system admin, NOT in any team // teamAdmin — team_members.role='admin' in Engineering // teamMember — team_members.role='member' in Engineering // outsider — regular user, no team // // Expected: // | Actor | Global(en) | Team(en) | BYOK(own) | Total | // |--------------|------------|----------|-----------|-------| // | platformAdmin| 2 | 0 | 0 | 2 | // | teamAdmin | 2 | 1 | 0 | 3 | // | teamMember | 2 | 1 | 1 * | 4 | // | outsider | 2 | 0 | 1 * | 3 | // // * BYOK models require provider.go NULL scan fix func TestUserJourney_FullMatrix(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("platformadmin", "platformadmin@test.com") _ = adminID // Create all actors teamAdminID := database.SeedTestUser(t, "teamadmin", "teamadmin@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID) teamAdminToken := makeToken(teamAdminID, "teamadmin@test.com", "user") teamMemberID := database.SeedTestUser(t, "teammember", "teammember@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamMemberID) teamMemberToken := makeToken(teamMemberID, "teammember@test.com", "user") outsiderID := database.SeedTestUser(t, "outsider", "outsider@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), outsiderID) outsiderToken := makeToken(outsiderID, "outsider@test.com", "user") // ── Setup: Enable BYOK policy ── h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, map[string]interface{}{"value": "true"}) // ── Setup: Create team via admin API ── w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ "name": "Engineering", "description": "Eng team", }) if w.Code != http.StatusCreated { t.Fatalf("create team: %d", w.Code) } var team map[string]interface{} decode(w, &team) teamID := team["id"].(string) // Add teamAdmin and teamMember to team (platformAdmin and outsider are NOT added) h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, map[string]string{"user_id": teamAdminID, "role": "admin"}) h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, map[string]string{"user_id": teamMemberID, "role": "member"}) // ── Setup: Global provider via admin API ── w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "GlobalOpenAI", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-global", }) if w.Code != http.StatusCreated { t.Fatalf("create global config: %d", w.Code) } var gcfg map[string]interface{} decode(w, &gcfg) globalCfgID := gcfg["id"].(string) // [SIMULATED FETCH] for global provider: 2 enabled + 1 disabled simulateFetch(t, globalCfgID, []string{"gpt-4o", "gpt-4o-mini"}, "enabled") simulateFetch(t, globalCfgID, []string{"gpt-3.5-turbo"}, "disabled") // ── Setup: Team provider via team admin self-service API ── w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, map[string]interface{}{ "name": "TeamVenice", "provider": "venice", "endpoint": "https://api.venice.ai/api/v1", "api_key": "sk-team", }) if w.Code != http.StatusCreated { t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String()) } var tprov map[string]interface{} decode(w, &tprov) teamProvID := tprov["id"].(string) // [SIMULATED FETCH] for team provider: 1 enabled + 1 disabled simulateFetch(t, teamProvID, []string{"llama-3.3-70b"}, "enabled") simulateFetch(t, teamProvID, []string{"deepseek-r1"}, "disabled") // ── Setup: BYOK providers via user API ── // Use unreachable endpoints so auto-fetch fails — simulateFetch controls catalog. w = h.request("POST", "/api/v1/api-configs", teamMemberToken, map[string]interface{}{ "name": "MemberKey", "provider": "openai", "endpoint": "http://localhost:1/v1", "api_key": "sk-member", }) if w.Code != http.StatusCreated { t.Fatalf("create member BYOK: %d: %s", w.Code, w.Body.String()) } var memberCfg map[string]interface{} decode(w, &memberCfg) memberBYOKID := memberCfg["id"].(string) // [SIMULATED FETCH] for member BYOK simulateFetch(t, memberBYOKID, []string{"gpt-4o-member-byok"}, "enabled") w = h.request("POST", "/api/v1/api-configs", outsiderToken, map[string]interface{}{ "name": "OutsiderKey", "provider": "venice", "endpoint": "http://localhost:1/v1", "api_key": "sk-outsider", }) if w.Code != http.StatusCreated { t.Fatalf("create outsider BYOK: %d: %s", w.Code, w.Body.String()) } var outsiderCfg map[string]interface{} decode(w, &outsiderCfg) outsiderBYOKID := outsiderCfg["id"].(string) // [SIMULATED FETCH] for outsider BYOK simulateFetch(t, outsiderBYOKID, []string{"llama-outsider-byok"}, "enabled") // ════════════════════════════════════════════ // ASSERTIONS — what each actor actually sees // ════════════════════════════════════════════ t.Run("platformAdmin_sees_global_only", func(t *testing.T) { models := h.getModels(adminToken) ids := getModelIDs(models) if len(models) != 2 { t.Fatalf("platformAdmin: want 2 (global enabled), got %d: %v", len(models), ids) } if !hasModel(models, "gpt-4o") || !hasModel(models, "gpt-4o-mini") { t.Errorf("platformAdmin should see gpt-4o and gpt-4o-mini, got: %v", ids) } if hasModel(models, "gpt-3.5-turbo") { t.Error("platformAdmin should NOT see disabled gpt-3.5-turbo") } if hasModel(models, "llama-3.3-70b") { t.Error("platformAdmin should NOT see team model (not in team)") } }) t.Run("teamAdmin_sees_global_plus_team", func(t *testing.T) { models := h.getModels(teamAdminToken) ids := getModelIDs(models) if len(models) != 3 { t.Fatalf("teamAdmin: want 3 (2 global + 1 team), got %d: %v", len(models), ids) } if !hasModel(models, "llama-3.3-70b") { t.Errorf("teamAdmin should see team model llama-3.3-70b, got: %v", ids) } if hasModel(models, "deepseek-r1") { t.Error("teamAdmin should NOT see disabled team model deepseek-r1") } }) t.Run("teamMember_sees_global_plus_team_plus_byok", func(t *testing.T) { models := h.getModels(teamMemberToken) ids := getModelIDs(models) if len(models) != 4 { t.Fatalf("teamMember: want 4 (2 global + 1 team + 1 BYOK), got %d: %v\n"+ " If 3: provider.go NULL scan bug is hiding BYOK models\n"+ " If 2: team provider scan also failing", len(models), ids) } if !hasModelWithScope(models, "gpt-4o-member-byok", "personal") { t.Error("teamMember should see own BYOK model with scope=personal") } if hasModel(models, "llama-outsider-byok") { t.Error("teamMember should NOT see outsider's BYOK model") } }) t.Run("outsider_sees_global_plus_own_byok", func(t *testing.T) { models := h.getModels(outsiderToken) ids := getModelIDs(models) if len(models) != 3 { t.Fatalf("outsider: want 3 (2 global + 1 BYOK), got %d: %v\n"+ " If 2: provider.go NULL scan bug is hiding BYOK models", len(models), ids) } if !hasModelWithScope(models, "llama-outsider-byok", "personal") { t.Error("outsider should see own BYOK model with scope=personal") } if hasModel(models, "llama-3.3-70b") { t.Error("outsider should NOT see team model (not in team)") } if hasModel(models, "gpt-4o-member-byok") { t.Error("outsider should NOT see teamMember's BYOK model") } }) // ── Dynamic state changes ── t.Run("byok_policy_off_hides_personal_models", func(t *testing.T) { h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, map[string]interface{}{"value": "false"}) defer h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, map[string]interface{}{"value": "true"}) models := h.getModels(teamMemberToken) if hasModelWithScope(models, "gpt-4o-member-byok", "personal") { t.Error("BYOK off: teamMember should NOT see personal models") } if len(models) != 3 { t.Fatalf("BYOK off: teamMember want 3 (2 global + 1 team), got %d: %v", len(models), getModelIDs(models)) } models = h.getModels(outsiderToken) if len(models) != 2 { t.Fatalf("BYOK off: outsider want 2 (global only), got %d: %v", len(models), getModelIDs(models)) } }) t.Run("admin_disables_global_model_users_lose_it", func(t *testing.T) { var catalogID string database.TestDB.QueryRow( dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), globalCfgID, ).Scan(&catalogID) // Admin disables gpt-4o w := h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken, map[string]interface{}{"visibility": "disabled"}) if w.Code != http.StatusOK { t.Fatalf("disable model: %d", w.Code) } defer func() { h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken, map[string]interface{}{"visibility": "enabled"}) }() // Everyone loses gpt-4o for _, tc := range []struct { name string token string }{ {"platformAdmin", adminToken}, {"teamAdmin", teamAdminToken}, {"teamMember", teamMemberToken}, {"outsider", outsiderToken}, } { models := h.getModels(tc.token) if hasModel(models, "gpt-4o") { t.Errorf("%s should NOT see disabled gpt-4o", tc.name) } } }) t.Run("admin_models_shows_global_only", func(t *testing.T) { w := h.request("GET", "/api/v1/admin/models", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin/models: %d", w.Code) } var resp map[string]interface{} decode(w, &resp) allModels := resp["data"].([]interface{}) // Admin should see only global-scope models (3), not team(2) or BYOK(2) if len(allModels) != 3 { t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels)) } }) } // ═══════════════════════════════════════════ // ROLES TESTS // ═══════════════════════════════════════════ func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") w := h.request("GET", "/api/v1/admin/roles", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list roles: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) // Migration 004 seeds utility, embedding, generation // (generation removed from ValidRoles in v0.10.2 but still in JSONB seed data) for _, role := range []string{"utility", "embedding", "generation"} { if _, ok := resp[role]; !ok { t.Errorf("expected role %q in response, got: %v", role, resp) } } } func TestIntegration_Roles_UpdateAndGet(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create a global provider to reference w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "TestProvider", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test", }) if w.Code != http.StatusCreated { t.Fatalf("create config: %d: %s", w.Code, w.Body.String()) } var cfg map[string]interface{} decode(w, &cfg) cfgID := cfg["id"].(string) // Update utility role w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{ "primary": map[string]string{ "provider_config_id": cfgID, "model_id": "gpt-4o-mini", }, }) if w.Code != http.StatusOK { t.Fatalf("update role: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify via list w = h.request("GET", "/api/v1/admin/roles", adminToken, nil) var allRoles map[string]interface{} decode(w, &allRoles) utilRaw, ok := allRoles["utility"] if !ok { t.Fatal("utility role missing after update") } utilMap, ok := utilRaw.(map[string]interface{}) if !ok { t.Fatalf("utility role unexpected type: %T", utilRaw) } primary, _ := utilMap["primary"].(map[string]interface{}) if primary == nil { t.Fatal("utility primary should be set") } if primary["model_id"] != "gpt-4o-mini" { t.Errorf("utility primary model: want gpt-4o-mini, got %v", primary["model_id"]) } } func TestIntegration_Roles_InvalidRole400(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") w := h.request("GET", "/api/v1/admin/roles/nonexistent", adminToken, nil) if w.Code != http.StatusBadRequest { t.Fatalf("invalid role: want 400, got %d", w.Code) } } func TestIntegration_Roles_NonAdmin403(t *testing.T) { h := setupHarness(t) database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("user1", "user1@test.com", "password123") w := h.request("GET", "/api/v1/admin/roles", userToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("non-admin roles: want 403, got %d", w.Code) } } // ── Team Roles ──────────────────────────── func TestIntegration_TeamRoles_CRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create team admin user 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") // Create team w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ "name": "Eng", "description": "Engineering", }) var team map[string]interface{} decode(w, &team) teamID := team["id"].(string) // Add team admin h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, map[string]string{"user_id": teamAdminID, "role": "admin"}) // Create a provider to reference w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "Provider1", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test", }) var pcfg map[string]interface{} decode(w, &pcfg) cfgID := pcfg["id"].(string) // List team roles — initially empty w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String()) } var roleResp map[string]interface{} decode(w, &roleResp) if len(roleResp) != 0 { t.Fatalf("team roles should be empty initially, got %d", len(roleResp)) } // Set team role override w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken, map[string]interface{}{ "primary": map[string]string{ "provider_config_id": cfgID, "model_id": "gpt-4o", }, }) if w.Code != http.StatusOK { t.Fatalf("set team role: %d: %s", w.Code, w.Body.String()) } // List should now have override w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) roleResp = map[string]interface{}{} decode(w, &roleResp) if len(roleResp) == 0 { t.Fatal("team roles should have utility after update") } if _, ok := roleResp["utility"]; !ok { t.Fatal("team roles should have utility after update") } // Delete override w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete team role: %d: %s", w.Code, w.Body.String()) } // List should be empty again w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) roleResp = map[string]interface{}{} decode(w, &roleResp) if _, ok := roleResp["utility"]; ok { t.Fatal("team roles should not have utility after delete") } } // ═══════════════════════════════════════════ // USAGE TESTS // ═══════════════════════════════════════════ // seedUsage inserts a usage_log row directly for testing. func seedUsage(t *testing.T, userID, provCfgID, model, scope string, prompt, completion int, costIn, costOut float64) { t.Helper() seedExec(t, ` INSERT INTO usage_log (user_id, provider_config_id, provider_scope, model_id, prompt_tokens, completion_tokens, cost_input, cost_output) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, userID, provCfgID, scope, model, prompt, completion, costIn, costOut) } func TestIntegration_Usage_AdminView(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") userID := database.SeedTestUser(t, "alice", "alice@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) // Create global provider w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "TestOAI", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-test", }) var cfg map[string]interface{} decode(w, &cfg) cfgID := cfg["id"].(string) // Seed usage data seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.005, 0.006) seedUsage(t, userID, cfgID, "gpt-4o", "global", 500, 100, 0.0025, 0.003) seedUsage(t, userID, cfgID, "gpt-4o-mini", "global", 2000, 400, 0.001, 0.002) // Admin usage — grouped by model (default) w = h.request("GET", "/api/v1/admin/usage?group_by=model", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) totals := resp["totals"].(map[string]interface{}) if int(totals["requests"].(float64)) != 3 { t.Errorf("totals.requests: want 3, got %v", totals["requests"]) } if int(totals["input_tokens"].(float64)) != 3500 { t.Errorf("totals.input_tokens: want 3500, got %v", totals["input_tokens"]) } results := resp["results"].([]interface{}) if len(results) != 2 { t.Fatalf("want 2 model groups, got %d", len(results)) } } func TestIntegration_Usage_AdminExcludesBYOK(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 global provider w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "Global1", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-g", }) var cfg map[string]interface{} decode(w, &cfg) globalCfgID := cfg["id"].(string) // Seed: 1 global usage + 1 personal BYOK usage seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01) seedUsage(t, userID, globalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005) // Admin view should exclude personal w = h.request("GET", "/api/v1/admin/usage", adminToken, nil) var resp map[string]interface{} decode(w, &resp) totals := resp["totals"].(map[string]interface{}) if int(totals["requests"].(float64)) != 1 { t.Errorf("admin should see 1 request (excludes BYOK), got %v", totals["requests"]) } } func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") userID, userToken := h.registerUser("carol", "carol@test.com", "password123") // Create global provider (admin-managed) w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "Global2", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-g2", }) var globalCfg map[string]interface{} decode(w, &globalCfg) globalCfgID := globalCfg["id"].(string) // Create personal BYOK provider (user-managed) w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ "name": "MyKey", "provider": "openai", "endpoint": "http://localhost:1/v1", "api_key": "sk-user-carol", }) if w.Code != http.StatusCreated { t.Fatalf("create personal config: want 201, got %d: %s", w.Code, w.Body.String()) } var personalCfg map[string]interface{} decode(w, &personalCfg) personalCfgID := personalCfg["id"].(string) // Seed usage against both providers seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01) seedUsage(t, userID, personalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005) // Personal view should only show BYOK usage (not global) w = h.request("GET", "/api/v1/usage", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) totals := resp["totals"].(map[string]interface{}) if int(totals["requests"].(float64)) != 1 { t.Errorf("personal should see 1 request (BYOK only, not global), got %v", totals["requests"]) } if int(totals["input_tokens"].(float64)) != 500 { t.Errorf("personal should see 500 input tokens (BYOK row), got %v", totals["input_tokens"]) } // Admin view should show global usage but exclude BYOK w = h.request("GET", "/api/v1/admin/usage", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String()) } decode(w, &resp) totals = resp["totals"].(map[string]interface{}) if int(totals["requests"].(float64)) != 1 { t.Errorf("admin should see 1 request (global only, not BYOK), got %v", totals["requests"]) } if int(totals["input_tokens"].(float64)) != 1000 { t.Errorf("admin should see 1000 input tokens (global row), got %v", totals["input_tokens"]) } } func TestIntegration_Usage_NonAdmin403(t *testing.T) { h := setupHarness(t) database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("dave", "dave@test.com", "password123") w := h.request("GET", "/api/v1/admin/usage", userToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("non-admin usage: want 403, got %d", w.Code) } } // ── Team Usage ──────────────────────────── func TestIntegration_Usage_TeamAdmin(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create team admin + member teamAdminID := database.SeedTestUser(t, "teamlead2", "teamlead2@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID) teamAdminToken := makeToken(teamAdminID, "teamlead2@test.com", "user") memberID := database.SeedTestUser(t, "member2", "member2@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID) // Create team w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ "name": "EngTeam", "description": "Test", }) 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"}) h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, map[string]string{"user_id": memberID, "role": "member"}) // Create team provider via team admin self-service w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, map[string]interface{}{ "name": "TeamOpenAI", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-team", }) if w.Code != http.StatusCreated { t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String()) } var tprov map[string]interface{} decode(w, &tprov) teamProvID := tprov["id"].(string) // Also create a global provider (usage against it should NOT appear in team view) w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "GlobalProv", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-global", }) var gcfg map[string]interface{} decode(w, &gcfg) globalProvID := gcfg["id"].(string) // Seed: 2 rows against team provider, 1 against global seedUsage(t, memberID, teamProvID, "gpt-4o", "team", 1000, 200, 0.01, 0.01) seedUsage(t, teamAdminID, teamProvID, "gpt-4o", "team", 500, 100, 0.005, 0.005) seedUsage(t, memberID, globalProvID, "gpt-4o", "global", 2000, 400, 0.02, 0.02) // Team usage should show only team provider usage (2 rows) w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), teamAdminToken, nil) if w.Code != http.StatusOK { t.Fatalf("team usage: %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) totals := resp["totals"].(map[string]interface{}) if int(totals["requests"].(float64)) != 2 { t.Errorf("team usage should show 2 requests (team provider only), got %v", totals["requests"]) } if int(totals["input_tokens"].(float64)) != 1500 { t.Errorf("team usage input_tokens: want 1500, got %v", totals["input_tokens"]) } } func TestIntegration_Usage_TeamNonAdmin403(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") memberID := database.SeedTestUser(t, "member3", "member3@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID) memberToken := makeToken(memberID, "member3@test.com", "user") // Create team, add member (NOT admin) w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ "name": "EngTeam2", "description": "Test2", }) 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": memberID, "role": "member"}) // Non-admin member should be forbidden w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), memberToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("team usage non-admin: want 403, got %d", w.Code) } } // ═══════════════════════════════════════════ // PRICING TESTS // ═══════════════════════════════════════════ func TestIntegration_Pricing_CRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create provider to reference w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "PricingProv", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-p", }) var cfg map[string]interface{} decode(w, &cfg) cfgID := cfg["id"].(string) // Upsert pricing w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{ "provider_config_id": cfgID, "model_id": "gpt-4o", "input_per_m": 2.5, "output_per_m": 10.0, }) if w.Code != http.StatusOK { t.Fatalf("upsert pricing: %d: %s", w.Code, w.Body.String()) } // List pricing w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list pricing: %d: %s", w.Code, w.Body.String()) } var entries []interface{} decode(w, &entries) if len(entries) != 1 { t.Fatalf("want 1 pricing entry, got %d", len(entries)) } entry := entries[0].(map[string]interface{}) if entry["model_id"] != "gpt-4o" { t.Errorf("pricing model: want gpt-4o, got %v", entry["model_id"]) } if entry["source"] != "manual" { t.Errorf("pricing source: want manual, got %v", entry["source"]) } // Delete pricing w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/pricing/%s/gpt-4o", cfgID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete pricing: %d: %s", w.Code, w.Body.String()) } // List should be empty w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil) decode(w, &entries) if len(entries) != 0 { t.Fatalf("want 0 pricing entries after delete, got %d", len(entries)) } } func TestIntegration_Pricing_ExcludesBYOK(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("byokuser", "byokuser@test.com", "password123") // Global provider with pricing w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "GlobalP", "provider": "openai", "endpoint": "https://api.openai.com/v1", "api_key": "sk-global", }) var gcfg map[string]interface{} decode(w, &gcfg) globalID := gcfg["id"].(string) // Set global pricing h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{ "provider_config_id": globalID, "model_id": "gpt-4o", "input_per_m": 2.5, "output_per_m": 10.0, }) // BYOK provider (personal scope) w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ "name": "MyKey", "provider": "openai", "endpoint": "http://localhost:1/v1", "api_key": "sk-byok", }) if w.Code != http.StatusCreated { t.Fatalf("create BYOK: %d: %s", w.Code, w.Body.String()) } var bcfg map[string]interface{} decode(w, &bcfg) byokID := bcfg["id"].(string) // Simulate catalog pricing for BYOK provider (as model sync would) seedExec(t, ` INSERT INTO model_pricing (provider_config_id, model_id, input_per_m, output_per_m, source) VALUES ($1, 'gpt-4o-byok', 3.0, 15.0, 'catalog') `, byokID) // Admin list should only show the 1 global entry, not the BYOK one w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil) var entries []interface{} decode(w, &entries) if len(entries) != 1 { t.Fatalf("admin pricing should show 1 (global only), got %d", len(entries)) } entry := entries[0].(map[string]interface{}) if entry["provider_config_id"] != globalID { t.Errorf("pricing entry should be global provider, got %v", entry["provider_config_id"]) } } func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("byokuser2", "byokuser2@test.com", "password123") // Create BYOK provider w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ "name": "MyKey2", "provider": "openai", "endpoint": "http://localhost:1/v1", "api_key": "sk-byok2", }) var bcfg map[string]interface{} decode(w, &bcfg) byokID := bcfg["id"].(string) // Admin tries to set pricing on personal provider — should be rejected w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{ "provider_config_id": byokID, "model_id": "gpt-4o", "input_per_m": 2.5, "output_per_m": 10.0, }) if w.Code != http.StatusForbidden { t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String()) } } // ── GetRole Endpoint (exercises GetConfig signature) ── // The existing Roles_UpdateAndGet test validates via ListRoles. // This test hits GET /admin/roles/:role which routes through // resolver.GetConfig — the exact call site that broke in v0.10.2 // when the signature changed from 3-arg to 4-arg. func TestIntegration_Roles_GetSingleRole(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin_getrole", "admin_getrole@test.com") // Create provider + configure utility role w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ "name": "getrole-provider", "provider": "openai", "endpoint": "http://localhost:1/v1", "api_key": "sk-getrole", }) if w.Code != http.StatusCreated { t.Fatalf("create config: %d: %s", w.Code, w.Body.String()) } var cfg map[string]interface{} decode(w, &cfg) cfgID := cfg["id"].(string) w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{ "primary": map[string]string{ "provider_config_id": cfgID, "model_id": "gpt-4o-mini", }, }) if w.Code != http.StatusOK { t.Fatalf("update role: %d: %s", w.Code, w.Body.String()) } // GET /admin/roles/utility — the endpoint that broke w = h.request("GET", "/api/v1/admin/roles/utility", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get single role: want 200, got %d: %s", w.Code, w.Body.String()) } // Seeded role with null config via GetRole → 200 (migration seeds all roles) w = h.request("GET", "/api/v1/admin/roles/embedding", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get seeded-but-empty role: want 200, got %d: %s", w.Code, w.Body.String()) } } // ── KB CRUD ───────────────────────────────────── func TestIntegration_KnowledgeBaseCRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create personal KB w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Test KB", "description": "A test knowledge base", }) if w.Code != http.StatusCreated { t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String()) } var kb map[string]interface{} decode(w, &kb) kbID := kb["id"].(string) if kb["name"] != "Test KB" { t.Fatalf("create KB: name mismatch: got %s", kb["name"]) } if kb["scope"] != "personal" { t.Fatalf("create KB: scope should default to personal: got %s", kb["scope"]) } // List KBs w = h.request("GET", "/api/v1/knowledge-bases", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list KBs: want 200, got %d", w.Code) } var kbList map[string]interface{} decode(w, &kbList) items := kbList["data"].([]interface{}) if len(items) != 1 { t.Fatalf("list KBs: expected 1, got %d", len(items)) } // Get single KB w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get KB: want 200, got %d", w.Code) } // Update KB w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, map[string]interface{}{"name": "Updated KB"}) if w.Code != http.StatusOK { t.Fatalf("update KB: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify update w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil) var updated map[string]interface{} decode(w, &updated) if updated["name"] != "Updated KB" { t.Fatalf("update KB: name not updated, got %s", updated["name"]) } // Upload document — should 503 (no object store in test harness) w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil) if w.Code != http.StatusServiceUnavailable { t.Fatalf("upload doc without objstore: want 503, got %d", w.Code) } // List documents (empty) w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list docs: want 200, got %d", w.Code) } // Rebuild — should 503 (no ingester in test harness) w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/rebuild", kbID), adminToken, nil) if w.Code != http.StatusServiceUnavailable { t.Fatalf("rebuild without ingester: want 503, got %d", w.Code) } // Delete KB w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete KB: want 200, got %d", w.Code) } // Verify deleted w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("get deleted KB: want 404, got %d", w.Code) } } // ── KB Cross-User Isolation ───────────────────── func TestIntegration_KBCrossUserIsolation(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("alice", "alice@test.com", "password123") // Admin creates personal KB w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Admin Private KB", }) if w.Code != http.StatusCreated { t.Fatalf("admin create KB: want 201, got %d: %s", w.Code, w.Body.String()) } var adminKB map[string]interface{} decode(w, &adminKB) adminKBID := adminKB["id"].(string) // Alice should NOT see admin's personal KB w = h.request("GET", "/api/v1/knowledge-bases", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("alice list KBs: want 200, got %d", w.Code) } var aliceList map[string]interface{} decode(w, &aliceList) items := aliceList["data"].([]interface{}) if len(items) != 0 { t.Fatalf("alice should see 0 KBs, got %d", len(items)) } // Alice should NOT be able to access admin's KB directly w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("alice access admin KB: want 404 (hidden), got %d", w.Code) } // Alice should NOT be able to delete admin's KB // Returns 403 (no kb.write permission) or 404 (ownership isolation) — both are correct denials w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil) if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden { t.Fatalf("alice delete admin KB: want 403 or 404, got %d", w.Code) } } // ── Channel KB Linking ────────────────────────── func TestIntegration_ChannelKBLinking(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create a channel w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{ "title": "Test Channel", }) if w.Code != http.StatusCreated { t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String()) } var ch map[string]interface{} decode(w, &ch) channelID := ch["id"].(string) // Create a KB w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Channel KB", }) if w.Code != http.StatusCreated { t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String()) } var kb map[string]interface{} decode(w, &kb) kbID := kb["id"].(string) // Link KB to channel w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, map[string]interface{}{"kb_ids": []string{kbID}}) if w.Code != http.StatusOK { t.Fatalf("link KB to channel: want 200, got %d: %s", w.Code, w.Body.String()) } // Get channel KBs w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get channel KBs: want 200, got %d", w.Code) } var linked map[string]interface{} decode(w, &linked) data := linked["data"].([]interface{}) if len(data) != 1 { t.Fatalf("channel should have 1 linked KB, got %d", len(data)) } // Unlink (set empty) w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, map[string]interface{}{"kb_ids": []string{}}) if w.Code != http.StatusOK { t.Fatalf("unlink KBs: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify empty w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil) var empty map[string]interface{} decode(w, &empty) emptyData := empty["data"].([]interface{}) if len(emptyData) != 0 { t.Fatalf("channel should have 0 linked KBs after unlink, got %d", len(emptyData)) } } // ── KB Document Status + Delete ───────────────── func TestIntegration_KB_DocumentStatusAndDelete(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("admin", "admin@test.com") // Create a KB w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Doc Test KB", }) if w.Code != http.StatusCreated { t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String()) } var kb map[string]interface{} decode(w, &kb) kbID := kb["id"].(string) // Seed a document directly (upload handler requires object store) docID := uuid.NewString() _, err := database.TestDB.Exec(dialectSQL( `INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`), docID, kbID, "test.md", "text/markdown", 1234, "kb/"+kbID+"/"+docID+"_test.md", "chunking", adminID, time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339)) if err != nil { t.Fatalf("seed doc: %v", err) } // GET document status w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, docID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get doc status: want 200, got %d: %s", w.Code, w.Body.String()) } var status map[string]interface{} decode(w, &status) if status["id"] != docID { t.Fatalf("doc status: id mismatch: got %s, want %s", status["id"], docID) } if status["status"] != "chunking" { t.Fatalf("doc status: want 'chunking', got %s", status["status"]) } if status["filename"] != "test.md" { t.Fatalf("doc status: want filename 'test.md', got %s", status["filename"]) } // GET status for wrong KB — should 404 fakeKB := uuid.NewString() w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", fakeKB, docID), adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("doc status wrong KB: want 404, got %d", w.Code) } // GET status for wrong doc — should 404 w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, uuid.NewString()), adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("doc status wrong doc: want 404, got %d", w.Code) } // DELETE document w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s", kbID, docID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete doc: want 200, got %d: %s", w.Code, w.Body.String()) } var delResp map[string]interface{} decode(w, &delResp) if delResp["deleted"] != true { t.Fatalf("delete doc: want deleted=true, got %v", delResp["deleted"]) } // Verify gone w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, docID), adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("deleted doc status: want 404, got %d", w.Code) } // DELETE on wrong KB — should 404 docID2 := uuid.NewString() database.TestDB.Exec(dialectSQL( `INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`), docID2, kbID, "other.txt", "text/plain", 100, "placeholder", "pending", adminID, time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339)) w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s", fakeKB, docID2), adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("delete doc wrong KB: want 404, got %d", w.Code) } } // ── KB Update Empty Body ──────────────────────── func TestIntegration_KB_UpdateEmptyBody(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Create KB w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Empty Update KB", }) if w.Code != http.StatusCreated { t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String()) } var kb map[string]interface{} decode(w, &kb) kbID := kb["id"].(string) // Update with empty fields — should 400 w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, map[string]interface{}{}) if w.Code != http.StatusBadRequest { t.Fatalf("empty update: want 400, got %d: %s", w.Code, w.Body.String()) } } // ── KB Permission Enforcement ─────────────────── func TestIntegration_KB_PermissionEnforcement(t *testing.T) { h := setupHarness(t) _, _ = h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("normperm", "normperm@test.com", "password123") // Regular user without kb.create should get 403 // (TruncateAll wipes the Everyone group, so no default permissions) w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{ "name": "Should Fail", }) if w.Code != http.StatusForbidden { t.Fatalf("kb create without permission: want 403, got %d: %s", w.Code, w.Body.String()) } } // ══════════════════════════════════════════════ // 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(dialectSQL("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/personas", 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(dialectSQL("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) personaID := seedInsertReturningID(t, ` 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) if personaID == "" { t.Fatal("personaID is empty after insert") } // ── User should NOT see team persona ── w = h.request("GET", "/api/v1/personas", userToken, nil) var personasResp map[string]interface{} decode(w, &personasResp) personas, _ := personasResp["data"].([]interface{}) for _, p := range personas { 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()) } // ── User should NOW see team persona via group grant ── w = h.request("GET", "/api/v1/personas", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String()) } decode(w, &personasResp) personas, _ = personasResp["data"].([]interface{}) found := false for _, p := range personas { 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 personas (response: %s)", len(personas), 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/personas", userToken, nil) decode(w, &personasResp) personas, _ = personasResp["data"].([]interface{}) for _, p := range personas { pm := p.(map[string]interface{}) if pm["id"] == personaID { t.Fatalf("user should NOT see persona after group removal") } } } // ═══════════════════════════════════════════════════════════════ // v0.17.0 Integration Tests — Persona-KB Binding + Debt Fixes // ═══════════════════════════════════════════════════════════════ // ── Persona-KB Binding CRUD ── func TestIntegration_PersonaKB_Binding(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // 1. Create a global persona via admin API w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ "name": "KB Persona", "base_model_id": "test-model", "system_prompt": "You are a test persona.", }) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Fatalf("create persona: want 200/201, got %d: %s", w.Code, w.Body.String()) } var personaResp map[string]interface{} decode(w, &personaResp) personaID := personaResp["id"].(string) // 2. Create a global KB w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Test KB", "scope": "global", }) if w.Code != http.StatusOK && w.Code != http.StatusCreated { t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String()) } var kbResp map[string]interface{} decode(w, &kbResp) kbID := kbResp["id"].(string) // 3. Bind KB to persona w = h.request("PUT", fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID), adminToken, map[string]interface{}{ "kb_ids": []string{kbID}, "auto_search": map[string]bool{kbID: true}, }) if w.Code != http.StatusOK { t.Fatalf("bind KB to persona: want 200, got %d: %s", w.Code, w.Body.String()) } // 4. Read back bindings w = h.request("GET", fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String()) } var listResp map[string]interface{} decode(w, &listResp) data := listResp["data"].([]interface{}) if len(data) != 1 { t.Fatalf("expected 1 binding, got %d", len(data)) } binding := data[0].(map[string]interface{}) if binding["kb_id"] != kbID { t.Fatalf("binding kb_id mismatch: got %v, want %s", binding["kb_id"], kbID) } if binding["auto_search"] != true { t.Fatal("auto_search should be true") } // 5. Unbind — send empty list w = h.request("PUT", fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID), adminToken, map[string]interface{}{ "kb_ids": []string{}, }) if w.Code != http.StatusOK { t.Fatalf("unbind KB: want 200, got %d: %s", w.Code, w.Body.String()) } // 6. Verify empty w = h.request("GET", fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String()) } var emptyResp map[string]interface{} decode(w, &emptyResp) emptyData := emptyResp["data"].([]interface{}) if len(emptyData) != 0 { t.Fatalf("expected 0 bindings after unbind, got %d", len(emptyData)) } } // ── KB Create Auth (was TODO — now enforced) ── func TestIntegration_KB_CreateAuth(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") _ = adminToken database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") // Seed Everyone group with kb.create so the permission middleware passes // and we can test the handler-level scope checks below. _, err := database.TestDB.Exec(dialectSQL(` INSERT INTO groups (id, name, description, scope, created_by, source, permissions) VALUES ($1, $2, $3, $4, NULL, $5, $6)`), "00000000-0000-0000-0000-000000000001", "Everyone", "Implicit group — all authenticated users receive these permissions.", "global", "system", `["model.use","kb.read","kb.create","channel.create"]`) if err != nil { t.Fatalf("seed Everyone group: %v", err) } _, userToken := h.registerUser("kbuser", "kb@test.com", "password123") // Regular user should NOT be able to create global KBs w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{ "name": "Sneaky Global KB", "scope": "global", }) if w.Code != http.StatusForbidden { t.Fatalf("non-admin global KB create: want 403, got %d: %s", w.Code, w.Body.String()) } // Regular user should NOT be able to create team KBs without team admin role w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{ "name": "Sneaky Team KB", "scope": "team", "team_id": "00000000-0000-0000-0000-000000000001", }) if w.Code != http.StatusForbidden { t.Fatalf("non-team-admin team KB create: want 403, got %d: %s", w.Code, w.Body.String()) } // Regular user CAN create personal KBs w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{ "name": "My Personal KB", "scope": "personal", }) if w.Code != http.StatusOK && w.Code != http.StatusCreated { t.Fatalf("personal KB create: want 200/201, got %d: %s", w.Code, w.Body.String()) } } // ── KB Team Scope Creation ────────────────────── func TestIntegration_KB_TeamScopeCreate(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") // Seed Everyone group with kb.create database.TestDB.Exec(dialectSQL(` INSERT INTO groups (id, name, description, scope, created_by, source, permissions) VALUES ($1, $2, $3, $4, NULL, $5, $6)`), "00000000-0000-0000-0000-000000000001", "Everyone", "Implicit group", "global", "system", `["model.use","kb.read","kb.create","channel.create"]`) userID, userToken := h.registerUser("teamkb", "teamkb@test.com", "password123") // Create a team via admin w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ "name": "KB Team", "description": "team for KB test", }) if w.Code != http.StatusCreated { t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String()) } var team map[string]interface{} decode(w, &team) teamID := team["id"].(string) // Add user as regular member — should NOT be able to create team KB 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.StatusOK && w.Code != http.StatusCreated { t.Fatalf("add member: want 200/201, got %d: %s", w.Code, w.Body.String()) } w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{ "name": "Team KB by Member", "scope": "team", "team_id": teamID, }) if w.Code != http.StatusForbidden { t.Fatalf("member create team KB: want 403, got %d: %s", w.Code, w.Body.String()) } // Promote to team admin — should succeed database.TestDB.Exec(dialectSQL( `UPDATE team_members SET role = 'admin' WHERE team_id = $1 AND user_id = $2`), teamID, userID) w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{ "name": "Team KB by Admin", "scope": "team", "team_id": teamID, }) if w.Code != http.StatusCreated { t.Fatalf("team admin create team KB: want 201, got %d: %s", w.Code, w.Body.String()) } var kbResp map[string]interface{} decode(w, &kbResp) if kbResp["scope"] != "team" { t.Fatalf("team KB scope: want 'team', got %s", kbResp["scope"]) } if kbResp["team_id"] != teamID { t.Fatalf("team KB team_id: want %s, got %s", teamID, kbResp["team_id"]) } } // ── KB Discoverable Flag ── func TestIntegration_KB_Discoverable(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, userToken := h.registerUser("discuser", "disc@test.com", "password123") // 1. Admin creates a global KB (discoverable=true by default) w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Visible KB", "scope": "global", }) if w.Code != http.StatusOK && w.Code != http.StatusCreated { t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String()) } var kbResp map[string]interface{} decode(w, &kbResp) kbID := kbResp["id"].(string) // 2. User can see it in discoverable list w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("list discoverable: want 200, got %d: %s", w.Code, w.Body.String()) } var discovResp map[string]interface{} decode(w, &discovResp) kbs, _ := discovResp["data"].([]interface{}) if kbs == nil { t.Fatalf("discoverable list returned nil data (response: %s)", w.Body.String()) } found := false for _, kb := range kbs { kbMap := kb.(map[string]interface{}) if kbMap["id"] == kbID { found = true // Verify kbResponse shape (not raw model) if _, ok := kbMap["status"]; !ok { t.Fatal("discoverable response missing 'status' field (not using kbResponse?)") } if _, ok := kbMap["chunk_count"]; !ok { t.Fatal("discoverable response missing 'chunk_count' field (not using kbResponse?)") } // created_at should be ISO format (kbResponse), not Go default with nanoseconds ts, _ := kbMap["created_at"].(string) if len(ts) > 0 && strings.Contains(ts, ".") { t.Fatalf("discoverable created_at has nanoseconds (raw model leak): %s", ts) } break } } if !found { t.Fatalf("discoverable KB %s not visible to user (got %d KBs, response: %s)", kbID, len(kbs), w.Body.String()) } // 3. Admin hides the KB w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", kbID), adminToken, map[string]interface{}{"discoverable": false}) if w.Code != http.StatusOK { t.Fatalf("set discoverable=false: want 200, got %d: %s", w.Code, w.Body.String()) } // 4. User can no longer see it w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil) if w.Code != http.StatusOK { t.Fatalf("list discoverable after hide: want 200, got %d: %s", w.Code, w.Body.String()) } var discovResp2 map[string]interface{} decode(w, &discovResp2) kbs2, _ := discovResp2["data"].([]interface{}) for _, kb := range kbs2 { if kb.(map[string]interface{})["id"] == kbID { t.Fatal("hidden KB should not appear in discoverable list") } } } // ── Platform Policy: kb_direct_access seeded ── func TestIntegration_KB_DirectAccessPolicy(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("admin", "admin@test.com") // Verify the migration seeded the policy by reading it through admin settings w := h.request("GET", "/api/v1/settings/public", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get public settings: want 200, got %d", w.Code) } // Policy existence is verified by the migration running without error // (if kb_direct_access INSERT fails, the migration fails and no tests run) _ = w } // ── KB SetDiscoverable Auth Enforcement ───────── func TestIntegration_KB_SetDiscoverableAuth(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("admin", "admin@test.com") _ = adminID database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") _, aliceToken := h.registerUser("alice", "alice@test.com", "password123") _, bobToken := h.registerUser("bob", "bob@test.com", "password123") // Seed Everyone group with kb.create so users can create KBs database.TestDB.Exec(dialectSQL(` INSERT INTO groups (id, name, description, scope, created_by, source, permissions) VALUES ($1, $2, $3, $4, NULL, $5, $6)`), "00000000-0000-0000-0000-000000000001", "Everyone", "Implicit group", "global", "system", `["model.use","kb.read","kb.create","channel.create"]`) // Alice creates a personal KB w := h.request("POST", "/api/v1/knowledge-bases", aliceToken, map[string]interface{}{ "name": "Alice KB", }) if w.Code != http.StatusCreated { t.Fatalf("alice create KB: want 201, got %d: %s", w.Code, w.Body.String()) } var aliceKB map[string]interface{} decode(w, &aliceKB) aliceKBID := aliceKB["id"].(string) // Alice CAN toggle her own KB w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID), aliceToken, map[string]interface{}{"discoverable": false}) if w.Code != http.StatusOK { t.Fatalf("owner toggle discoverable: want 200, got %d: %s", w.Code, w.Body.String()) } // Bob CANNOT toggle Alice's KB (not owner, not admin) w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID), bobToken, map[string]interface{}{"discoverable": true}) if w.Code != http.StatusNotFound { // loadAndAuthorize returns 404 for personal KBs the user doesn't own t.Fatalf("non-owner toggle discoverable: want 404, got %d: %s", w.Code, w.Body.String()) } // Admin creates a global KB w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{ "name": "Global KB", "scope": "global", }) if w.Code != http.StatusCreated { t.Fatalf("admin create global KB: want 201, got %d: %s", w.Code, w.Body.String()) } var globalKB map[string]interface{} decode(w, &globalKB) globalKBID := globalKB["id"].(string) // Alice CANNOT toggle a global KB she doesn't own w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", globalKBID), aliceToken, map[string]interface{}{"discoverable": false}) if w.Code != http.StatusForbidden { t.Fatalf("non-admin toggle global discoverable: want 403, got %d: %s", w.Code, w.Body.String()) } // Admin CAN toggle the global KB w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", globalKBID), adminToken, map[string]interface{}{"discoverable": false}) if w.Code != http.StatusOK { t.Fatalf("admin toggle global discoverable: want 200, got %d: %s", w.Code, w.Body.String()) } // Admin CANNOT access Alice's personal KB (loadAndAuthorize returns 404 // for personal KBs not owned by the caller — consistent with Get/Update/Delete) w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID), adminToken, map[string]interface{}{"discoverable": true}) if w.Code != http.StatusNotFound { t.Fatalf("admin toggle alice personal KB: want 404 (not owner), got %d: %s", w.Code, w.Body.String()) } // Non-existent KB returns 404 w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", uuid.NewString()), adminToken, map[string]interface{}{"discoverable": false}) if w.Code != http.StatusNotFound { t.Fatalf("toggle nonexistent KB: want 404, got %d: %s", w.Code, w.Body.String()) } } // ═══════════════════════════════════════════════ // Messages + Treepath tests (SQLite compat) // ═══════════════════════════════════════════════ func TestIntegration_Messages_CRUD(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("msguser", "msg@test.com") // Create channel w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{ "title": "Message Test", }) if w.Code != http.StatusCreated { t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String()) } var ch map[string]interface{} decode(w, &ch) channelID := ch["id"].(string) // Create first message w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{ "role": "user", "content": "Hello, world!", }) if w.Code != http.StatusCreated { t.Fatalf("create message: want 201, got %d: %s", w.Code, w.Body.String()) } var msg1 map[string]interface{} decode(w, &msg1) if msg1["id"] == nil || msg1["id"].(string) == "" { t.Fatal("message should have an id") } if msg1["content"].(string) != "Hello, world!" { t.Fatalf("content mismatch: got %q", msg1["content"]) } msg1ID := msg1["id"].(string) // Create second message (child of first) w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{ "role": "assistant", "content": "Hi there!", "parent_id": msg1ID, }) if w.Code != http.StatusCreated { t.Fatalf("create message 2: want 201, got %d: %s", w.Code, w.Body.String()) } // List messages — should have 2 w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, nil) if w.Code != http.StatusOK { t.Fatalf("list messages: want 200, got %d: %s", w.Code, w.Body.String()) } var listResp map[string]interface{} decode(w, &listResp) total := int(listResp["total"].(float64)) if total != 2 { t.Fatalf("expected 2 messages, got %d", total) } } func TestIntegration_Messages_EditFork(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("forkuser", "fork@test.com") // Create channel w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{ "title": "Fork Test", }) if w.Code != http.StatusCreated { t.Fatalf("create channel: %d: %s", w.Code, w.Body.String()) } var ch map[string]interface{} decode(w, &ch) channelID := ch["id"].(string) // Create user message (root) w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{ "role": "user", "content": "First draft", }) if w.Code != http.StatusCreated { t.Fatalf("create message: %d: %s", w.Code, w.Body.String()) } var msg map[string]interface{} decode(w, &msg) msgID := msg["id"].(string) // Edit (fork) the message — creates a sibling w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages/%s/edit", channelID, msgID), token, map[string]interface{}{ "content": "Second draft", }) if w.Code != http.StatusCreated { t.Fatalf("edit message: want 201, got %d: %s", w.Code, w.Body.String()) } var edited map[string]interface{} decode(w, &edited) if edited["content"].(string) != "Second draft" { t.Fatalf("edited content mismatch: got %q", edited["content"]) } // Sibling count should be 2 (original + edit) sibCount := int(edited["sibling_count"].(float64)) if sibCount != 2 { t.Fatalf("expected sibling_count=2, got %d", sibCount) } // Verify via siblings endpoint w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages/%s/siblings", channelID, msgID), token, nil) if w.Code != http.StatusOK { t.Fatalf("list siblings: want 200, got %d: %s", w.Code, w.Body.String()) } var sibResp map[string]interface{} decode(w, &sibResp) siblings := sibResp["siblings"].([]interface{}) if len(siblings) != 2 { t.Fatalf("expected 2 siblings, got %d", len(siblings)) } } func TestIntegration_Messages_TreePath(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("pathuser", "path@test.com") // Create channel w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{ "title": "Path Test", }) if w.Code != http.StatusCreated { t.Fatalf("create channel: %d: %s", w.Code, w.Body.String()) } var ch map[string]interface{} decode(w, &ch) channelID := ch["id"].(string) // Build a 3-message chain: root → child → grandchild w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{ "role": "user", "content": "root message", }) if w.Code != http.StatusCreated { t.Fatalf("msg1: %d: %s", w.Code, w.Body.String()) } var m1 map[string]interface{} decode(w, &m1) w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{ "role": "assistant", "content": "response", "parent_id": m1["id"].(string), }) if w.Code != http.StatusCreated { t.Fatalf("msg2: %d: %s", w.Code, w.Body.String()) } var m2 map[string]interface{} decode(w, &m2) w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{ "role": "user", "content": "follow-up", "parent_id": m2["id"].(string), }) if w.Code != http.StatusCreated { t.Fatalf("msg3: %d: %s", w.Code, w.Body.String()) } // Get active path — should return 3 messages in root-first order w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/path", channelID), token, nil) if w.Code != http.StatusOK { t.Fatalf("get path: want 200, got %d: %s", w.Code, w.Body.String()) } var pathEnv map[string]interface{} decode(w, &pathEnv) pathResp := pathEnv["data"].([]interface{}) if len(pathResp) != 3 { t.Fatalf("expected 3 messages in path, got %d", len(pathResp)) } // Verify order: root first, grandchild last first := pathResp[0].(map[string]interface{}) last := pathResp[2].(map[string]interface{}) if first["content"].(string) != "root message" { t.Fatalf("first in path should be root, got %q", first["content"]) } if last["content"].(string) != "follow-up" { t.Fatalf("last in path should be follow-up, got %q", last["content"]) } } // ── Channel List with Type Filter (regression: $1 reuse in SQLite) ── func TestIntegration_ChannelListWithTypeFilter(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("admin", "admin@test.com") // Create a direct chat w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{ "title": "Test Direct Chat", "type": "direct", }) if w.Code != http.StatusCreated { t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String()) } // List without type filter — should succeed w = h.request("GET", "/api/v1/channels?page=1&per_page=100", token, nil) if w.Code != http.StatusOK { t.Fatalf("list channels (no filter): want 200, got %d: %s", w.Code, w.Body.String()) } // List with single type filter w = h.request("GET", "/api/v1/channels?page=1&per_page=100&type=direct", token, nil) if w.Code != http.StatusOK { t.Fatalf("list channels (type=direct): want 200, got %d: %s", w.Code, w.Body.String()) } // List with multi-value types filter — this triggers the $1 reuse in the // count subquery. Before the QArgs fix, this returned 500 on SQLite. w = h.request("GET", "/api/v1/channels?page=1&per_page=100&types=direct,group", token, nil) if w.Code != http.StatusOK { t.Fatalf("list channels (types=direct,group): want 200, got %d: %s", w.Code, w.Body.String()) } // Verify the response is valid var resp map[string]interface{} decode(w, &resp) // The response may use "channels" or "data" depending on the handler. // 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 ──────────────── // Roles endpoints return composite named-key maps (not lists). // Convention: composite endpoints return object directly, no {"data": ...} wrapper. 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) // Composite endpoint — must NOT have a "data" wrapper if _, ok := resp["data"]; ok { t.Fatalf("GET /teams/:teamId/roles should return named keys directly (composite convention), "+ "not wrapped in {\"data\": ...}") } } // ── 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 } // ═══════════════════════════════════════════════════════════════ // PERSONA AUDIT TESTS (v0.28.0-audit CS2) // ═══════════════════════════════════════════════════════════════ // ── Persona CRUD ──────────────────────────── func TestPersona_CRUD_Personal(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("padmin", "padmin@test.com") _ = adminID // Enable persona creation policy database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") // Create w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ "name": "My Helper", "base_model_id": "test-model", "system_prompt": "You are helpful.", "description": "Test persona", }) if w.Code != http.StatusCreated { t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) personaID := created["id"].(string) if created["handle"] == nil || created["handle"] == "" { t.Error("handle should be auto-generated from name") } // Update w = h.request("PUT", "/api/v1/personas/"+personaID, adminToken, map[string]interface{}{ "name": "My Updated Helper", "description": "Updated desc", }) if w.Code != http.StatusOK { t.Fatalf("update persona: want 200, got %d: %s", w.Code, w.Body.String()) } // List — verify updated name appears w = h.request("GET", "/api/v1/personas", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String()) } var listResp map[string]interface{} decode(w, &listResp) data := listResp["data"].([]interface{}) found := false for _, p := range data { pm := p.(map[string]interface{}) if pm["id"] == personaID && pm["name"] == "My Updated Helper" { found = true } } if !found { t.Error("updated persona not found in list with new name") } // Delete w = h.request("DELETE", "/api/v1/personas/"+personaID, adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete persona: want 200, got %d: %s", w.Code, w.Body.String()) } } func TestPersona_Update_OwnershipCheck(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("own1", "own1@test.com") otherID := database.SeedTestUser(t, "own2", "own2@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID) otherToken := makeToken(otherID, "own2@test.com", "user") // Enable persona creation database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") // Admin creates a personal persona w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ "name": "Admin's Bot", "base_model_id": "test-model", }) if w.Code != http.StatusCreated { t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) personaID := created["id"].(string) // Other user tries to update — should be forbidden w = h.request("PUT", "/api/v1/personas/"+personaID, otherToken, map[string]interface{}{ "name": "Stolen", }) if w.Code != http.StatusForbidden { t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String()) } // Other user tries to delete — should be forbidden w = h.request("DELETE", "/api/v1/personas/"+personaID, otherToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("delete by non-owner: want 403, got %d: %s", w.Code, w.Body.String()) } } func TestPersona_AdminCRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("apadmin", "apadmin@test.com") // Create global persona via admin w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ "name": "Global Bot", "base_model_id": "test-model", "system_prompt": "You are global.", }) if w.Code != http.StatusCreated { t.Fatalf("admin create: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) personaID := created["id"].(string) if created["scope"] != "global" { t.Fatalf("admin persona scope: want 'global', got %v", created["scope"]) } // Update w = h.request("PUT", "/api/v1/admin/personas/"+personaID, adminToken, map[string]interface{}{ "name": "Global Bot v2", }) if w.Code != http.StatusOK { t.Fatalf("admin update: want 200, got %d: %s", w.Code, w.Body.String()) } // List w = h.request("GET", "/api/v1/admin/personas", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String()) } var listResp map[string]interface{} decode(w, &listResp) if listResp["data"] == nil { t.Fatal("admin list should return 'data' key") } // Delete w = h.request("DELETE", "/api/v1/admin/personas/"+personaID, adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String()) } } // ── Envelope + Nil Slice ──────────────────── func TestPersona_EnvelopeKey(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("envadm", "envadm@test.com") // User list w := h.request("GET", "/api/v1/personas", adminToken, nil) var resp map[string]interface{} decode(w, &resp) if _, ok := resp["personas"]; ok { t.Error("response should use 'data' key, not 'personas'") } if resp["data"] == nil { t.Error("response should have 'data' key") } // Admin list w = h.request("GET", "/api/v1/admin/personas", adminToken, nil) decode(w, &resp) if _, ok := resp["personas"]; ok { t.Error("admin response should use 'data' key, not 'personas'") } if resp["data"] == nil { t.Error("admin response should have 'data' key") } } func TestPersona_NilSlice_ReturnsEmptyArray(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("niladm", "niladm@test.com") // Fresh user, no personas — should get [] not null w := h.request("GET", "/api/v1/personas", adminToken, nil) // Check raw JSON contains [] not null body := w.Body.String() if strings.Contains(body, `"data":null`) { t.Fatal("data should be [] not null for empty persona list") } if !strings.Contains(body, `"data":[]`) { t.Fatalf("data should be empty array, got: %s", body) } // Admin list w = h.request("GET", "/api/v1/admin/personas", adminToken, nil) body = w.Body.String() if strings.Contains(body, `"data":null`) { t.Fatal("admin data should be [] not null for empty persona list") } } // ── Tool Grants ───────────────────────────── func TestPersona_ToolGrants_RoundTrip(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("tgadm", "tgadm@test.com") // Create persona w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ "name": "Grant Bot", "base_model_id": "test-model", }) var created map[string]interface{} decode(w, &created) personaID := created["id"].(string) // Initially empty w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } var grantResp map[string]interface{} decode(w, &grantResp) grants := grantResp["data"].([]interface{}) if len(grants) != 0 { t.Fatalf("initial grants: want 0, got %d", len(grants)) } // Set grants w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{ "tool_names": []string{"web_search", "calculator", "kb_search"}, }) if w.Code != http.StatusOK { t.Fatalf("set tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } // Read back w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil) decode(w, &grantResp) grants = grantResp["data"].([]interface{}) if len(grants) != 3 { t.Fatalf("grants after set: want 3, got %d", len(grants)) } // Clear grants w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{ "tool_names": []string{}, }) if w.Code != http.StatusOK { t.Fatalf("clear tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } // Read back — should be empty w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil) decode(w, &grantResp) grants = grantResp["data"].([]interface{}) if len(grants) != 0 { t.Fatalf("grants after clear: want 0, got %d", len(grants)) } // Nil-slice check: verify "data":[] not "data":null body := w.Body.String() if strings.Contains(body, `"data":null`) { t.Fatal("tool grants should return [] not null when empty") } } func TestPersona_ToolGrants_UserScope(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("tguadm", "tguadm@test.com") database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") // Create personal persona w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{ "name": "User Grant Bot", "base_model_id": "test-model", }) var created map[string]interface{} decode(w, &created) personaID := created["id"].(string) // Set via user endpoint w = h.request("PUT", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{ "tool_names": []string{"web_search"}, }) if w.Code != http.StatusOK { t.Fatalf("user set tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } // Read back via user endpoint w = h.request("GET", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("user get tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } var grantResp map[string]interface{} decode(w, &grantResp) grants := grantResp["data"].([]interface{}) if len(grants) != 1 { t.Fatalf("user grants: want 1, got %d", len(grants)) } } // ── Team Persona CRUD + Scope Checks ──────── func TestTeamPersona_CRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("tpadm", "tpadm@test.com") // Create a team w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "Persona Team", }) var teamResp map[string]interface{} decode(w, &teamResp) teamID := teamResp["id"].(string) // Create team persona w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{ "name": "Team Bot", "base_model_id": "test-model", "system_prompt": "You are a team bot.", }) if w.Code != http.StatusCreated { t.Fatalf("create team persona: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) personaID := created["id"].(string) if created["scope"] != "team" { t.Fatalf("team persona scope: want 'team', got %v", created["scope"]) } // List team personas w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list team personas: want 200, got %d: %s", w.Code, w.Body.String()) } var listResp map[string]interface{} decode(w, &listResp) if listResp["data"] == nil { t.Fatal("team persona list should use 'data' key") } items := listResp["data"].([]interface{}) if len(items) != 1 { t.Fatalf("team personas: want 1, got %d", len(items)) } // Update team persona w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, map[string]interface{}{ "name": "Team Bot v2", }) if w.Code != http.StatusOK { t.Fatalf("update team persona: want 200, got %d: %s", w.Code, w.Body.String()) } // Delete team persona w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete team persona: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify deleted w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil) decode(w, &listResp) items = listResp["data"].([]interface{}) if len(items) != 0 { t.Fatalf("team personas after delete: want 0, got %d", len(items)) } } func TestTeamPersona_Delete_ScopeCheck(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("tpscadm", "tpscadm@test.com") // Create two teams w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "Team Alpha", }) var teamA map[string]interface{} decode(w, &teamA) teamAID := teamA["id"].(string) w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "Team Beta", }) var teamB map[string]interface{} decode(w, &teamB) teamBID := teamB["id"].(string) // Create persona on Team Alpha personaID := seedInsertReturningID(t, ` INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) VALUES ('Alpha Bot', 'test-model', 'team', $1, $2, true) RETURNING id `, teamAID, adminID) // Try to delete it via Team Beta route — should fail w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("cross-team delete: want 403, got %d: %s", w.Code, w.Body.String()) } // Try to update it via Team Beta route — should fail w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, map[string]interface{}{ "name": "Stolen", }) if w.Code != http.StatusForbidden { t.Fatalf("cross-team update: want 403, got %d: %s", w.Code, w.Body.String()) } // Delete via correct team — should succeed w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamAID, personaID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("same-team delete: want 200, got %d: %s", w.Code, w.Body.String()) } } func TestTeamPersona_KB_ScopeCheck(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("tpkbadm", "tpkbadm@test.com") // Create two teams w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "KB Team A", }) var teamA map[string]interface{} decode(w, &teamA) teamAID := teamA["id"].(string) w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "KB Team B", }) var teamB map[string]interface{} decode(w, &teamB) teamBID := teamB["id"].(string) // Create persona on Team A personaID := seedInsertReturningID(t, ` INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) VALUES ('KB Bot', 'test-model', 'team', $1, $2, true) RETURNING id `, teamAID, adminID) // Try to read KBs via Team B — should fail w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("cross-team get KBs: want 403, got %d: %s", w.Code, w.Body.String()) } // Try to set KBs via Team B — should fail w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, map[string]interface{}{ "kb_ids": []string{}, }) if w.Code != http.StatusForbidden { t.Fatalf("cross-team set KBs: want 403, got %d: %s", w.Code, w.Body.String()) } // Read via correct team — should succeed w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamAID, personaID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("same-team get KBs: want 200, got %d: %s", w.Code, w.Body.String()) } } // ── Persona Groups ────────────────────────── func TestPersonaGroup_CRUD(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("pgadm", "pgadm@test.com") // Create group w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{ "name": "Support Team", "description": "Front-line support personas", }) if w.Code != http.StatusCreated { t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} decode(w, &created) groupID := created["id"].(string) if created["scope"] != "personal" { t.Fatalf("group scope: want 'personal', got %v", created["scope"]) } // Get w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get group: want 200, got %d: %s", w.Code, w.Body.String()) } var fetched map[string]interface{} decode(w, &fetched) if fetched["name"] != "Support Team" { t.Fatalf("group name: want 'Support Team', got %v", fetched["name"]) } // Update w = h.request("PUT", "/api/v1/persona-groups/"+groupID, adminToken, map[string]interface{}{ "name": "Support Team v2", }) if w.Code != http.StatusOK { t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String()) } // List w = h.request("GET", "/api/v1/persona-groups", adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("list groups: want 200, got %d: %s", w.Code, w.Body.String()) } var listResp map[string]interface{} decode(w, &listResp) if listResp["data"] == nil { t.Fatal("group list should use 'data' key") } if _, ok := listResp["groups"]; ok { t.Error("group list should NOT use 'groups' key") } // Delete w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("delete group: want 200, got %d: %s", w.Code, w.Body.String()) } // Verify deleted w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("get deleted group: want 404, got %d", w.Code) } } func TestPersonaGroup_Members(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("pgmadm", "pgmadm@test.com") // Create a persona for the group w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ "name": "Group Persona A", "base_model_id": "test-model", }) var pA map[string]interface{} decode(w, &pA) personaAID := pA["id"].(string) w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ "name": "Group Persona B", "base_model_id": "test-model", }) var pB map[string]interface{} decode(w, &pB) personaBID := pB["id"].(string) // Create group w = h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{ "name": "Two-Bot Team", }) var group map[string]interface{} decode(w, &group) groupID := group["id"].(string) // Add member A as leader w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{ "persona_id": personaAID, "is_leader": true, }) if w.Code != http.StatusCreated { t.Fatalf("add member A: want 201, got %d: %s", w.Code, w.Body.String()) } // Add member B w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{ "persona_id": personaBID, }) if w.Code != http.StatusCreated { t.Fatalf("add member B: want 201, got %d: %s", w.Code, w.Body.String()) } // Get group — should have 2 members w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get group for members: want 200, got %d: %s", w.Code, w.Body.String()) } var fetched map[string]interface{} decode(w, &fetched) members := fetched["members"].([]interface{}) if len(members) != 2 { t.Fatalf("members: want 2, got %d", len(members)) } // Verify leader is first (sorted by is_leader DESC) first := members[0].(map[string]interface{}) if first["is_leader"] != true { t.Error("first member should be the leader") } // Swap leader to B — should clear A's leader flag w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{ "persona_id": personaBID, "is_leader": true, }) if w.Code != http.StatusCreated { t.Fatalf("swap leader: want 201, got %d: %s", w.Code, w.Body.String()) } // Get group — verify only one leader w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) decode(w, &fetched) members = fetched["members"].([]interface{}) leaderCount := 0 for _, m := range members { mm := m.(map[string]interface{}) if mm["is_leader"] == true { leaderCount++ } } if leaderCount != 1 { t.Fatalf("leaders after swap: want 1, got %d", leaderCount) } // Remove member A memberAID := "" for _, m := range members { mm := m.(map[string]interface{}) if mm["persona_id"] == personaAID { memberAID = mm["id"].(string) } } if memberAID == "" { t.Fatal("could not find member A in group") } w = h.request("DELETE", fmt.Sprintf("/api/v1/persona-groups/%s/members/%s", groupID, memberAID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String()) } // Get group — should have 1 member w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil) decode(w, &fetched) members = fetched["members"].([]interface{}) if len(members) != 1 { t.Fatalf("members after remove: want 1, got %d", len(members)) } } func TestPersonaGroup_OwnershipCheck(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("pgown1", "pgown1@test.com") otherID := database.SeedTestUser(t, "pgown2", "pgown2@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID) otherToken := makeToken(otherID, "pgown2@test.com", "user") // Admin creates a group w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{ "name": "Admin's Group", }) var group map[string]interface{} decode(w, &group) groupID := group["id"].(string) // Other user tries to update — should fail w = h.request("PUT", "/api/v1/persona-groups/"+groupID, otherToken, map[string]interface{}{ "name": "Stolen Group", }) if w.Code != http.StatusForbidden { t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String()) } // Other user tries to delete — should fail w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, otherToken, nil) if w.Code != http.StatusNotFound { t.Fatalf("delete by non-owner: want 404, got %d: %s", w.Code, w.Body.String()) } // Other user list — should not see admin's group w = h.request("GET", "/api/v1/persona-groups", otherToken, nil) var listResp map[string]interface{} decode(w, &listResp) items := listResp["data"].([]interface{}) if len(items) != 0 { t.Fatalf("non-owner should see 0 groups, got %d", len(items)) } } func TestPersonaGroup_EnvelopeKey(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("pgeadm", "pgeadm@test.com") w := h.request("GET", "/api/v1/persona-groups", adminToken, nil) var resp map[string]interface{} decode(w, &resp) if _, ok := resp["groups"]; ok { t.Error("persona-groups list should use 'data' key, not 'groups'") } if resp["data"] == nil { t.Error("persona-groups list should have 'data' key") } } // ── Team Persona Tool Grants ──────────────── func TestTeamPersona_ToolGrants_RoundTrip(t *testing.T) { h := setupHarness(t) _, adminToken := h.createAdminUser("ttgadm", "ttgadm@test.com") // Create team w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "ToolGrant Team", }) var team map[string]interface{} decode(w, &team) teamID := team["id"].(string) // Create team persona w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{ "name": "TG Bot", "base_model_id": "test-model", }) var created map[string]interface{} decode(w, &created) personaID := created["id"].(string) // Initially empty w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("get team tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} decode(w, &resp) grants := resp["data"].([]interface{}) if len(grants) != 0 { t.Fatalf("initial team grants: want 0, got %d", len(grants)) } // Set w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, map[string]interface{}{ "tool_names": []string{"web_search", "kb_search"}, }) if w.Code != http.StatusOK { t.Fatalf("set team tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } // Read back w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil) decode(w, &resp) grants = resp["data"].([]interface{}) if len(grants) != 2 { t.Fatalf("team grants after set: want 2, got %d", len(grants)) } } func TestTeamPersona_ToolGrants_ScopeCheck(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("ttgsadm", "ttgsadm@test.com") // Create two teams w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "TG Scope A", }) var teamA map[string]interface{} decode(w, &teamA) teamAID := teamA["id"].(string) w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "TG Scope B", }) var teamB map[string]interface{} decode(w, &teamB) teamBID := teamB["id"].(string) // Create persona on Team A personaID := seedInsertReturningID(t, ` INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) VALUES ('TG Scope Bot', 'test-model', 'team', $1, $2, true) RETURNING id `, teamAID, adminID) // Try to read via Team B — 403 w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("cross-team get tool grants: want 403, got %d: %s", w.Code, w.Body.String()) } // Try to set via Team B — 403 w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, map[string]interface{}{ "tool_names": []string{"web_search"}, }) if w.Code != http.StatusForbidden { t.Fatalf("cross-team set tool grants: want 403, got %d: %s", w.Code, w.Body.String()) } // Via correct team — 200 w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamAID, personaID), adminToken, nil) if w.Code != http.StatusOK { t.Fatalf("same-team get tool grants: want 200, got %d: %s", w.Code, w.Body.String()) } } // ── Team Persona Avatar ───────────────────── func TestTeamPersona_Avatar_ScopeCheck(t *testing.T) { h := setupHarness(t) adminID, adminToken := h.createAdminUser("taadm", "taadm@test.com") // Create two teams w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "Avatar Team A", }) var teamA map[string]interface{} decode(w, &teamA) teamAID := teamA["id"].(string) w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{ "name": "Avatar Team B", }) var teamB map[string]interface{} decode(w, &teamB) teamBID := teamB["id"].(string) // Create persona on Team A personaID := seedInsertReturningID(t, ` INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active) VALUES ('Avatar Bot', 'test-model', 'team', $1, $2, true) RETURNING id `, teamAID, adminID) // Try to delete avatar via Team B — 403 w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamBID, personaID), adminToken, nil) if w.Code != http.StatusForbidden { t.Fatalf("cross-team delete avatar: want 403, got %d: %s", w.Code, w.Body.String()) } // Try to delete via correct team — should succeed (persona has no avatar, so 404 from UPDATE rows=0 is fine too) w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamAID, personaID), adminToken, nil) if w.Code != http.StatusOK && w.Code != http.StatusNotFound { t.Fatalf("same-team delete avatar: want 200 or 404, got %d: %s", w.Code, w.Body.String()) } }