3038 lines
111 KiB
Go
3038 lines
111 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
|
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
|
)
|
|
|
|
// ── Test Harness ────────────────────────────
|
|
|
|
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: "",
|
|
}
|
|
|
|
stores := postgres.NewStores(database.TestDB)
|
|
|
|
// 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)
|
|
authGroup := api.Group("/auth")
|
|
authGroup.POST("/register", auth.Register)
|
|
authGroup.POST("/login", auth.Login)
|
|
|
|
// Public settings
|
|
adm := NewAdminHandler(stores, nil, nil)
|
|
api.GET("/settings/public", adm.PublicSettings)
|
|
|
|
// Protected routes
|
|
protected := api.Group("")
|
|
protected.Use(middleware.Auth(cfg))
|
|
|
|
// 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(nil)
|
|
protected.GET("/teams/mine", teams.MyTeams)
|
|
|
|
teamScoped := protected.Group("/teams/:teamId")
|
|
teamScoped.Use(middleware.RequireTeamAdmin())
|
|
{
|
|
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 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)
|
|
}
|
|
|
|
// User usage
|
|
usage := NewUsageHandler(stores)
|
|
protected.GET("/usage", usage.PersonalUsage)
|
|
|
|
// Profile / Settings
|
|
settings := NewSettingsHandler(nil)
|
|
protected.GET("/profile", settings.GetProfile)
|
|
|
|
// Presets
|
|
presets := NewPersonaHandler(stores)
|
|
protected.GET("/presets", presets.ListUserPersonas)
|
|
protected.POST("/presets", presets.CreateUserPersona)
|
|
protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
|
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
|
|
|
// Notes
|
|
notes := NewNoteHandler()
|
|
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()
|
|
protected.GET("/channels", channels.ListChannels)
|
|
protected.POST("/channels", channels.CreateChannel)
|
|
protected.GET("/channels/:id", channels.GetChannel)
|
|
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
|
|
|
// Knowledge Bases (nil storage/ingester/embedder — CRUD works, upload/rebuild return 503)
|
|
kbH := NewKnowledgeBaseHandler(stores, nil, nil, nil)
|
|
protected.POST("/knowledge-bases", kbH.CreateKB)
|
|
protected.GET("/knowledge-bases", kbH.ListKBs)
|
|
protected.GET("/knowledge-bases/:id", kbH.GetKB)
|
|
protected.PUT("/knowledge-bases/:id", kbH.UpdateKB)
|
|
protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB)
|
|
protected.POST("/knowledge-bases/:id/documents", kbH.UploadDocument)
|
|
protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
|
|
protected.POST("/knowledge-bases/:id/search", kbH.SearchKB)
|
|
protected.POST("/knowledge-bases/:id/rebuild", 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
|
|
|
|
// Attachments (nil storage = upload returns 503, but metadata works)
|
|
attachH := NewAttachmentHandler(stores, nil, nil)
|
|
protected.POST("/channels/:id/attachments", attachH.Upload)
|
|
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
|
|
protected.GET("/attachments/:id", attachH.GetMetadata)
|
|
protected.GET("/attachments/:id/download", attachH.Download)
|
|
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
|
|
|
// Completions
|
|
completions := NewCompletionHandler(nil, stores, nil, nil)
|
|
protected.POST("/chat/completions", completions.Complete)
|
|
|
|
// Admin routes
|
|
admin := api.Group("/admin")
|
|
admin.Use(middleware.Auth(cfg), 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.GET("/teams/:id/members", teams.ListMembers)
|
|
admin.POST("/teams/:id/members", teams.AddMember)
|
|
admin.GET("/presets", presets.ListAdminPersonas)
|
|
admin.POST("/presets", presets.CreateAdminPersona)
|
|
admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
|
admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.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)
|
|
|
|
// 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)
|
|
|
|
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)
|
|
|
|
// 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("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["users"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("response must have 'users' array, got %T", resp["users"])
|
|
}
|
|
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(
|
|
"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(
|
|
"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(
|
|
"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)
|
|
_, err := database.TestDB.Exec(`
|
|
INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
|
|
VALUES ($1, 'gpt-4o', 'GPT-4o', 'disabled')
|
|
`, configID)
|
|
if err != nil {
|
|
t.Fatalf("insert catalog entry: %v", err)
|
|
}
|
|
|
|
// 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["models"].([]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("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["models"].([]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("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["users"]; !ok {
|
|
t.Fatal("admin/users response MUST have 'users' key (not 'data')")
|
|
}
|
|
users := usersResp["users"].([]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. Presets (Policy Gated) ────────────────
|
|
|
|
func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
|
|
h := setupHarness(t)
|
|
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
|
_ = adminID
|
|
|
|
// Create a provider config (presets 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 preset as admin (role=admin but policy says no)
|
|
w = h.request("POST", "/api/v1/presets", adminToken, map[string]interface{}{
|
|
"name": "My Preset", "base_model_id": "gpt-4o",
|
|
"provider_config_id": configID, "system_prompt": "You are helpful",
|
|
})
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("preset 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/presets", adminToken, map[string]interface{}{
|
|
"name": "My Preset", "base_model_id": "gpt-4o",
|
|
"provider_config_id": configID, "system_prompt": "You are helpful",
|
|
})
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("preset create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// List presets
|
|
w = h.request("GET", "/api/v1/presets", adminToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list presets: 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"} {
|
|
_, err := database.TestDB.Exec(`
|
|
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)
|
|
if err != nil {
|
|
t.Fatalf("insert %s: %v", mid, err)
|
|
}
|
|
}
|
|
|
|
// ── 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["models"].([]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 {"models": null})
|
|
if adminResp["models"] == 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("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["models"].([]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["models"] == 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(
|
|
"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["models"].([]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 preset 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["models"] == nil {
|
|
h.t.Fatal("models response must never be null")
|
|
}
|
|
return resp["models"].([]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 {
|
|
_, err := database.TestDB.Exec(`
|
|
INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
|
|
VALUES ($1, $2, $3, $4)
|
|
`, providerConfigID, modelID, modelID, visibility)
|
|
if err != nil {
|
|
t.Fatalf("[SIMULATED FETCH] insert %s: %v", modelID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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("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(
|
|
"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("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("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("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("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("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(
|
|
"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["models"].([]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("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 roleList map[string]interface{}
|
|
decode(w, &roleList)
|
|
if len(roleList) != 0 {
|
|
t.Fatalf("team roles should be empty initially, got %d", len(roleList))
|
|
}
|
|
|
|
// 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)
|
|
decode(w, &roleList)
|
|
if _, ok := roleList["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)
|
|
roleList = map[string]interface{}{} // reset — json.Unmarshal merges into existing maps
|
|
decode(w, &roleList)
|
|
if _, ok := roleList["utility"]; ok {
|
|
t.Fatal("team roles should not have utility after delete")
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// 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()
|
|
_, err := database.TestDB.Exec(`
|
|
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)
|
|
if err != nil {
|
|
t.Fatalf("seedUsage: %v", err)
|
|
}
|
|
}
|
|
|
|
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("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("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("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("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("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)
|
|
database.TestDB.Exec(`
|
|
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
|
|
w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("alice delete admin KB: want 404 (hidden), 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))
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// GROUPS + RESOURCE GRANTS (v0.16.0)
|
|
// ══════════════════════════════════════════════
|
|
|
|
func TestGroupCRUD(t *testing.T) {
|
|
h := setupHarness(t)
|
|
|
|
_, adminToken := h.createAdminUser("gadmin", "gadmin@test.com")
|
|
|
|
// ── Create global group ──
|
|
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
|
"name": "Engineering",
|
|
"description": "All engineers",
|
|
"scope": "global",
|
|
})
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created models.Group
|
|
decode(w, &created)
|
|
if created.ID == "" || created.Name != "Engineering" || created.Scope != "global" {
|
|
t.Fatalf("unexpected group: %+v", created)
|
|
}
|
|
|
|
// ── List groups ──
|
|
w = h.request("GET", "/api/v1/admin/groups", adminToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list groups: want 200, got %d", w.Code)
|
|
}
|
|
var listResp map[string]interface{}
|
|
decode(w, &listResp)
|
|
data := listResp["data"].([]interface{})
|
|
if len(data) != 1 {
|
|
t.Fatalf("expected 1 group, got %d", len(data))
|
|
}
|
|
|
|
// ── Get group ──
|
|
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get group: want 200, got %d", w.Code)
|
|
}
|
|
|
|
// ── Update group ──
|
|
w = h.request("PUT", "/api/v1/admin/groups/"+created.ID, adminToken, map[string]interface{}{
|
|
"name": "Platform Engineering",
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify update
|
|
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
|
var updated models.Group
|
|
decode(w, &updated)
|
|
if updated.Name != "Platform Engineering" {
|
|
t.Fatalf("name should be updated, got %q", updated.Name)
|
|
}
|
|
|
|
// ── Duplicate name conflict ──
|
|
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
|
"name": "Platform Engineering",
|
|
"scope": "global",
|
|
})
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("duplicate name: want 409, got %d", w.Code)
|
|
}
|
|
|
|
// ── Delete group ──
|
|
w = h.request("DELETE", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("delete group: want 200, got %d", w.Code)
|
|
}
|
|
|
|
// Verify deleted
|
|
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("deleted group: want 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGroupMembers(t *testing.T) {
|
|
h := setupHarness(t)
|
|
|
|
_, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com")
|
|
userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com")
|
|
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
|
userToken := makeToken(userID, "gmuser@test.com", "user")
|
|
|
|
// Create group
|
|
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
|
"name": "Testers",
|
|
"scope": "global",
|
|
})
|
|
var group models.Group
|
|
decode(w, &group)
|
|
|
|
// ── Add member ──
|
|
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
|
|
"user_id": userID,
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("add member: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// ── List members ──
|
|
w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list members: want 200, got %d", w.Code)
|
|
}
|
|
var memberResp map[string]interface{}
|
|
decode(w, &memberResp)
|
|
members := memberResp["data"].([]interface{})
|
|
if len(members) != 1 {
|
|
t.Fatalf("expected 1 member, got %d", len(members))
|
|
}
|
|
m := members[0].(map[string]interface{})
|
|
if m["username"] != "gmuser" {
|
|
t.Fatalf("expected username gmuser, got %v", m["username"])
|
|
}
|
|
|
|
// ── My groups (user endpoint) ──
|
|
w = h.request("GET", "/api/v1/groups/mine", userToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("my groups: want 200, got %d", w.Code)
|
|
}
|
|
var myResp map[string]interface{}
|
|
decode(w, &myResp)
|
|
myGroups := myResp["data"].([]interface{})
|
|
if len(myGroups) != 1 {
|
|
t.Fatalf("user should be in 1 group, got %d", len(myGroups))
|
|
}
|
|
|
|
// ── Idempotent add (no error) ──
|
|
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
|
|
"user_id": userID,
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("idempotent add: want 200, got %d", w.Code)
|
|
}
|
|
|
|
// ── Remove member ──
|
|
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify removed
|
|
w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
|
|
decode(w, &memberResp)
|
|
members = memberResp["data"].([]interface{})
|
|
if len(members) != 0 {
|
|
t.Fatalf("expected 0 members after removal, got %d", len(members))
|
|
}
|
|
}
|
|
|
|
func TestGroupScopeValidation(t *testing.T) {
|
|
h := setupHarness(t)
|
|
|
|
_, adminToken := h.createAdminUser("gsadmin", "gsadmin@test.com")
|
|
|
|
// Team-scoped without team_id → 400
|
|
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
|
"name": "Bad Group",
|
|
"scope": "team",
|
|
})
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("team scope without team_id: want 400, got %d", w.Code)
|
|
}
|
|
|
|
// Global with team_id → 400
|
|
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
|
"name": "Bad Group",
|
|
"scope": "global",
|
|
"team_id": "00000000-0000-0000-0000-000000000001",
|
|
})
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("global scope with team_id: want 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestResourceGrants(t *testing.T) {
|
|
h := setupHarness(t)
|
|
|
|
_, adminToken := h.createAdminUser("rgadmin", "rgadmin@test.com")
|
|
|
|
// Create a group
|
|
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
|
"name": "Grantees",
|
|
"scope": "global",
|
|
})
|
|
var group models.Group
|
|
decode(w, &group)
|
|
|
|
// Create a persona (admin)
|
|
w = h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
|
"name": "Test Bot",
|
|
"base_model_id": "test-model",
|
|
"scope": "global",
|
|
})
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var persona map[string]interface{}
|
|
decode(w, &persona)
|
|
personaID := persona["id"].(string)
|
|
|
|
// ── Get grant (no grant yet → default team_only) ──
|
|
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get grant: want 200, got %d", w.Code)
|
|
}
|
|
var grantResp map[string]interface{}
|
|
decode(w, &grantResp)
|
|
if grantResp["grant_scope"] != "team_only" {
|
|
t.Fatalf("default grant_scope should be team_only, got %v", grantResp["grant_scope"])
|
|
}
|
|
|
|
// ── Set grant: groups scope ──
|
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
|
"grant_scope": "groups",
|
|
"granted_groups": []string{group.ID},
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// ── Get grant (now groups) ──
|
|
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
|
decode(w, &grantResp)
|
|
if grantResp["grant_scope"] != "groups" {
|
|
t.Fatalf("grant_scope should be groups, got %v", grantResp["grant_scope"])
|
|
}
|
|
|
|
// ── Set grant: global scope ──
|
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
|
"grant_scope": "global",
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("set global grant: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// ── Revoke: set back to team_only (deletes grant row) ──
|
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
|
"grant_scope": "team_only",
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("revoke grant: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// ── Validation: groups scope without groups → 400 ──
|
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
|
"grant_scope": "groups",
|
|
})
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("groups without list: want 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGroupBasedPersonaAccess(t *testing.T) {
|
|
h := setupHarness(t)
|
|
|
|
// Setup: admin + regular user (not on any team)
|
|
adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com")
|
|
userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com")
|
|
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
|
userToken := makeToken(userID, "gpuser@test.com", "user")
|
|
|
|
// Create a team
|
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
|
|
"name": "Secret Team",
|
|
})
|
|
var teamResp map[string]interface{}
|
|
decode(w, &teamResp)
|
|
teamID := teamResp["id"].(string)
|
|
|
|
// Create persona scoped to that team (user shouldn't see it without group access)
|
|
var personaID string
|
|
err := database.DB.QueryRow(`
|
|
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
|
|
VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true)
|
|
RETURNING id
|
|
`, teamID, adminID).Scan(&personaID)
|
|
if err != nil {
|
|
t.Fatalf("insert persona: %v", err)
|
|
}
|
|
if personaID == "" {
|
|
t.Fatal("personaID is empty after insert")
|
|
}
|
|
|
|
// ── User should NOT see team persona ──
|
|
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
|
var presetsResp map[string]interface{}
|
|
decode(w, &presetsResp)
|
|
presets, _ := presetsResp["presets"].([]interface{})
|
|
for _, p := range presets {
|
|
pm := p.(map[string]interface{})
|
|
if pm["id"] == personaID {
|
|
t.Fatalf("user should NOT see team persona without group access")
|
|
}
|
|
}
|
|
|
|
// ── Create group + add user + grant persona ──
|
|
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
|
"name": "Secret Readers",
|
|
"scope": "global",
|
|
})
|
|
var group models.Group
|
|
decode(w, &group)
|
|
|
|
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
|
|
"user_id": userID,
|
|
})
|
|
|
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
|
"grant_scope": "groups",
|
|
"granted_groups": []string{group.ID},
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// ── User should NOW see team persona via group grant ──
|
|
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
decode(w, &presetsResp)
|
|
presets, _ = presetsResp["presets"].([]interface{})
|
|
found := false
|
|
for _, p := range presets {
|
|
pm := p.(map[string]interface{})
|
|
if pm["id"] == personaID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("user should see team persona after group grant, but didn't find it in %d presets (response: %s)", len(presets), w.Body.String())
|
|
}
|
|
|
|
// ── Remove user from group → should lose access ──
|
|
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
|
|
|
|
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
|
decode(w, &presetsResp)
|
|
presets, _ = presetsResp["presets"].([]interface{})
|
|
for _, p := range presets {
|
|
pm := p.(map[string]interface{})
|
|
if pm["id"] == personaID {
|
|
t.Fatalf("user should NOT see persona after group removal")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// 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/presets", 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/presets/%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/presets/%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/presets/%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/presets/%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'")
|
|
_, 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 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 {
|
|
if kb.(map[string]interface{})["id"] == kbID {
|
|
found = true
|
|
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
|
|
}
|