Changeset 0.10.0 (#56)
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,9 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
|
||||
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")
|
||||
|
||||
@@ -52,7 +56,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
authGroup.POST("/login", auth.Login)
|
||||
|
||||
// Public settings
|
||||
adm := NewAdminHandler(stores, nil)
|
||||
adm := NewAdminHandler(stores, nil, nil)
|
||||
api.GET("/settings/public", adm.PublicSettings)
|
||||
|
||||
// Protected routes
|
||||
@@ -91,10 +95,24 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
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()
|
||||
settings := NewSettingsHandler(nil)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
|
||||
// Presets
|
||||
@@ -116,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
|
||||
// Completions
|
||||
completions := NewCompletionHandler(nil)
|
||||
completions := NewCompletionHandler(nil, stores)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Admin routes
|
||||
@@ -143,6 +161,21 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.GET("/presets", presets.ListAdminPersonas)
|
||||
admin.POST("/presets", presets.CreateAdminPersona)
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
@@ -1561,3 +1594,551 @@ func TestUserJourney_FullMatrix(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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
|
||||
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'")
|
||||
userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
|
||||
|
||||
// Create global provider
|
||||
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 cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
cfgID := cfg["id"].(string)
|
||||
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
|
||||
|
||||
// Personal view should include all scopes
|
||||
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)) != 2 {
|
||||
t.Errorf("personal should see 2 requests (includes BYOK), got %v", totals["requests"])
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user