323 lines
13 KiB
Go
323 lines
13 KiB
Go
package control
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/health"
|
|
"gpt-load/internal/platform/config"
|
|
app_errors "gpt-load/internal/platform/errors"
|
|
"gpt-load/internal/state"
|
|
"gpt-load/internal/storage/models"
|
|
)
|
|
|
|
func TestGetGroupSettingsReturnsPersistedDraftOverridesAndEffectiveConfig(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
group := validControlGroup("settings-read")
|
|
group.Overrides = models.JSON(`{
|
|
"first_byte_timeout":180,
|
|
"request_timeout":480,
|
|
"stream_idle_timeout":270,
|
|
"header_rules":{"set":{"X-Group":"value"},"remove":["X-Removed"]},
|
|
"inject_usage_options":false
|
|
}`)
|
|
if err := fixture.db.Create(group).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := fixture.manager.Publish(mustBuildCompileInput(t, fixture.db)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := fixture.service.GetGroupSettings(t.Context(), group.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetGroupSettings() error = %v", err)
|
|
}
|
|
if got.Name != group.Name || got.ChannelID != channel.OpenAICompatible ||
|
|
string(got.Params) != `{"base_url":"https://settings-read.example/v1"}` ||
|
|
!got.Enabled || got.WeightManual != nil {
|
|
t.Fatalf("persisted settings = %#v", got)
|
|
}
|
|
for _, key := range []string{
|
|
state.SettingFirstByteTimeout,
|
|
state.SettingRequestTimeout,
|
|
state.SettingStreamIdleTimeout,
|
|
state.SettingHeaderRules,
|
|
state.SettingInjectUsageOptions,
|
|
} {
|
|
if got.Overrides[key] == nil {
|
|
t.Fatalf("overrides missing %q: %#v", key, got.Overrides)
|
|
}
|
|
}
|
|
if got.Effective.FirstByteTimeout != 180 ||
|
|
got.Effective.RequestTimeout != 480 || got.Effective.StreamIdleTimeout != 270 ||
|
|
got.Effective.InjectUsageOptions ||
|
|
!reflect.DeepEqual(got.Effective.HeaderRules.Set, map[string]string{"X-Group": "value"}) ||
|
|
!reflect.DeepEqual(got.Effective.HeaderRules.Remove, []string{"X-Removed"}) {
|
|
t.Fatalf("effective = %#v", got.Effective)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupSettingsPublishesOnceAndReturnsNewSettings(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://settings-before.example.com/v1"}`),
|
|
Models: optionalGroupModels{Set: true}, Credentials: "sk-settings-update",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
groupID := created.GroupID
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
weight := 75
|
|
result, err := fixture.service.UpdateGroupSettings(t.Context(), groupID, GroupSettingsUpdateRequest{
|
|
Name: optionalField[string]{Set: true, Value: " updated settings "},
|
|
Params: optionalField[json.RawMessage]{Set: true,
|
|
Value: json.RawMessage(`{"base_url":" HTTPS://SETTINGS-UPDATED.EXAMPLE.COM/v1/ "}`)},
|
|
ValidationModel: optionalField[string]{Set: true, Value: " gpt-4.1 "},
|
|
Enabled: optionalField[bool]{Set: true, Value: false},
|
|
WeightManual: optionalField[int]{Set: true, Value: weight},
|
|
Overrides: optionalField[config.Settings]{Set: true, Value: config.Settings{"request_timeout": json.Number("720")}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateGroupSettings() error = %v", err)
|
|
}
|
|
if result.Name != "updated settings" || result.ChannelID != channel.OpenAICompatible ||
|
|
string(result.Params) != `{"base_url":"https://settings-updated.example.com/v1"}` ||
|
|
result.ValidationModel == nil || *result.ValidationModel != "gpt-4.1" || result.Enabled ||
|
|
result.WeightManual == nil || *result.WeightManual != weight || result.Effective.RequestTimeout != 720 {
|
|
t.Fatalf("UpdateGroupSettings() = %#v", result)
|
|
}
|
|
if got := fixture.manager.Current().Revision; got != beforeRevision+1 {
|
|
t.Fatalf("snapshot revision = %d, want %d", got, beforeRevision+1)
|
|
}
|
|
stored, err := fixture.service.GetGroupSettings(t.Context(), groupID)
|
|
if err != nil {
|
|
t.Fatalf("GetGroupSettings() after update error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(stored, result) {
|
|
t.Fatalf("stored settings = %#v, want %#v", stored, result)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupSettingsAllowsDuplicateUpstreamURLWithoutConfirmation(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
first, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://settings-first.example.com/v1"}`),
|
|
Models: optionalGroupModels{Set: true, Values: []GroupModel{}},
|
|
Credentials: "sk-settings-first",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first CreateGroup() error = %v", err)
|
|
}
|
|
sharedURL := "https://settings-shared.example.com/v1"
|
|
_, err = fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://settings-shared.example.com/v1"}`),
|
|
Models: optionalGroupModels{Set: true, Values: []GroupModel{}},
|
|
Credentials: "sk-settings-second",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second CreateGroup() error = %v", err)
|
|
}
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
|
|
result, err := fixture.service.UpdateGroupSettings(t.Context(), first.GroupID, GroupSettingsUpdateRequest{
|
|
Params: optionalField[json.RawMessage]{
|
|
Set: true,
|
|
Value: json.RawMessage(`{"base_url":" HTTPS://SETTINGS-SHARED.EXAMPLE.COM/v1/ "}`),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateGroupSettings() error = %v", err)
|
|
}
|
|
if string(result.Params) != `{"base_url":"`+sharedURL+`"}` {
|
|
t.Fatalf("updated params = %s, want shared target", result.Params)
|
|
}
|
|
if got := fixture.manager.Current().Revision; got != beforeRevision+1 {
|
|
t.Fatalf("snapshot revision = %d, want %d", got, beforeRevision+1)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupTargetResetsCredentialHealthIdentity(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://health-old.example/v1"}`),
|
|
Models: optionalGroupModels{Set: true}, Credentials: "sk-target-health",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var credential models.Credential
|
|
if err := fixture.db.Where("group_id = ?", created.GroupID).Take(&credential).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, exists := findRuntimeCredential(fixture.registry.Snapshot(), credential.ID)
|
|
if !exists || !fixture.registry.SetBlacklisted(credential.ID) {
|
|
t.Fatal("credential runtime state is unavailable")
|
|
}
|
|
now := time.Now().UTC()
|
|
fixture.stats.RecordFailure(
|
|
credential.ID,
|
|
health.FailureCategoryUpstreamHostError,
|
|
http.StatusBadGateway,
|
|
now,
|
|
)
|
|
|
|
if _, err := fixture.service.UpdateGroupSettings(t.Context(), created.GroupID, GroupSettingsUpdateRequest{
|
|
Params: optionalField[json.RawMessage]{
|
|
Set: true, Value: json.RawMessage(`{"base_url":"https://health-new.example/v1"}`),
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("UpdateGroupSettings() error = %v", err)
|
|
}
|
|
after, exists := findRuntimeCredential(fixture.registry.Snapshot(), credential.ID)
|
|
if !exists {
|
|
t.Fatal("credential disappeared after target update")
|
|
}
|
|
if after.IdentityGeneration == before.IdentityGeneration || after.Blacklisted || after.FailureCount != 0 {
|
|
t.Fatalf("credential health identity before=%#v after=%#v", before, after)
|
|
}
|
|
if got := fixture.stats.Snapshot(credential.ID, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("credential stats after target update = %#v, want zero", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupSettingsValidatesWeightAndAllowsUsageObservationAcrossChannels(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
groupID := createGroupForCredentialImport(t, fixture, "sk-settings-validation")
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
|
|
for _, weight := range []optionalField[int]{
|
|
{Set: true, Value: 0},
|
|
{Set: true, Value: -1},
|
|
{Set: true, Value: 101},
|
|
} {
|
|
if _, err := fixture.service.UpdateGroupSettings(t.Context(), groupID, GroupSettingsUpdateRequest{WeightManual: weight}); !errors.Is(err, app_errors.ErrValidation) {
|
|
t.Fatalf("weight %#v error = %v, want validation", weight, err)
|
|
}
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
field optionalField[int]
|
|
want *int
|
|
}{
|
|
{name: "null", field: optionalField[int]{Set: true, Null: true}},
|
|
{name: "minimum", field: optionalField[int]{Set: true, Value: 1}, want: settingsWeightPointer(1)},
|
|
{name: "maximum", field: optionalField[int]{Set: true, Value: 100}, want: settingsWeightPointer(100)},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := fixture.service.UpdateGroupSettings(
|
|
t.Context(),
|
|
groupID,
|
|
GroupSettingsUpdateRequest{WeightManual: test.field},
|
|
)
|
|
if err != nil || !reflect.DeepEqual(got.WeightManual, test.want) {
|
|
t.Fatalf("weight update = %#v, %v; want %#v", got, err, test.want)
|
|
}
|
|
})
|
|
}
|
|
anthropic, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
Name: stringPointer("settings-anthropic"), ChannelID: channel.Anthropic,
|
|
Params: json.RawMessage(`{}`), Models: optionalGroupModels{Set: true}, Credentials: "sk-anthropic",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
updated, err := fixture.service.UpdateGroupSettings(t.Context(), anthropic.GroupID, GroupSettingsUpdateRequest{
|
|
Overrides: optionalField[config.Settings]{Set: true, Value: config.Settings{state.SettingInjectUsageOptions: true}},
|
|
})
|
|
if err != nil || !updated.Effective.InjectUsageOptions {
|
|
t.Fatalf("cross-channel usage observation update = %#v, %v", updated, err)
|
|
}
|
|
if got := fixture.manager.Current().Revision; got != beforeRevision+5 {
|
|
t.Fatalf("settings mutation revision = %d, want %d", got, beforeRevision+5)
|
|
}
|
|
}
|
|
|
|
func TestGroupSettingsHTTPRejectsStrictJSONAndUnauthorizedWithoutMutation(t *testing.T) {
|
|
initControlI18n(t)
|
|
fixture := newServiceFixture(t)
|
|
groupID := createGroupForCredentialImport(t, fixture, "sk-settings-http")
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
path := "/api/groups/" + stringGroupID(groupID) + "/settings"
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
|
|
for _, body := range []string{
|
|
`{"unknown":true}`,
|
|
`{"name":"one","name":"two"}`,
|
|
`{"weight_manual":0}`,
|
|
} {
|
|
recorder := serveGroupSettingsRequest(t, engine, http.MethodPut, path, "test-auth-key", body)
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("PUT settings body %s = %d %s, want 400", body, recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
unauthorized := serveGroupSettingsRequest(t, engine, http.MethodPut, path, "", `{"name":"changed"}`)
|
|
if unauthorized.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthorized PUT settings = %d %s", unauthorized.Code, unauthorized.Body.String())
|
|
}
|
|
if got := fixture.manager.Current().Revision; got != beforeRevision {
|
|
t.Fatalf("rejected HTTP settings updates revision = %d, want %d", got, beforeRevision)
|
|
}
|
|
}
|
|
|
|
func TestGroupSettingsHTTPNotFoundAndDatabaseFailureDoNotMutate(t *testing.T) {
|
|
initControlI18n(t)
|
|
fixture := newServiceFixture(t)
|
|
groupID := createGroupForCredentialImport(t, fixture, "sk-settings-errors")
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
|
|
missing := serveGroupSettingsRequest(t, engine, http.MethodPut, "/api/groups/999/settings", "test-auth-key", `{"name":"missing"}`)
|
|
if missing.Code != http.StatusNotFound {
|
|
t.Fatalf("missing group PUT settings = %d %s, want 404", missing.Code, missing.Body.String())
|
|
}
|
|
if got := fixture.manager.Current().Revision; got != beforeRevision {
|
|
t.Fatalf("missing group published revision %d, want %d", got, beforeRevision)
|
|
}
|
|
|
|
closeMutationAuditDB(t, fixture)
|
|
database := serveGroupSettingsRequest(t, engine, http.MethodPut, "/api/groups/"+stringGroupID(groupID)+"/settings", "test-auth-key", `{"name":"database"}`)
|
|
if database.Code != http.StatusInternalServerError || !strings.Contains(database.Body.String(), app_errors.ErrDatabase.Code) {
|
|
t.Fatalf("database PUT settings = %d %s, want database error", database.Code, database.Body.String())
|
|
}
|
|
if got := fixture.manager.Current().Revision; got != beforeRevision {
|
|
t.Fatalf("database failure published revision %d, want %d", got, beforeRevision)
|
|
}
|
|
}
|
|
|
|
func serveGroupSettingsRequest(t *testing.T, engine *gin.Engine, method, path, authKey, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
if authKey != "" {
|
|
request.Header.Set("Authorization", "Bearer "+authKey)
|
|
}
|
|
engine.ServeHTTP(recorder, request)
|
|
return recorder
|
|
}
|
|
|
|
func stringGroupID(groupID uint) string {
|
|
return strconv.FormatUint(uint64(groupID), 10)
|
|
}
|
|
|
|
func settingsWeightPointer(value int) *int {
|
|
return &value
|
|
}
|