This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/integration_test.go
2026-02-23 23:55:22 +00:00

1564 lines
57 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/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)
r := gin.New()
api := r.Group("/api/v1")
// Auth (unprotected)
auth := NewAuthHandler(cfg, stores)
authGroup := api.Group("/auth")
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
// Public settings
adm := NewAdminHandler(stores)
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)
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()
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)
}
// Profile / Settings
settings := NewSettingsHandler()
protected.GET("/profile", settings.GetProfile)
// Presets
presets := NewPersonaHandler(stores)
protected.GET("/presets", presets.ListUserPersonas)
protected.POST("/presets", presets.CreateUserPersona)
// 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)
// Completions
completions := NewCompletionHandler()
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)
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, &note)
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))
}
})
}