639 lines
21 KiB
Go
639 lines
21 KiB
Go
package control
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"gpt-load/internal/catalog"
|
|
"gpt-load/internal/platform/config"
|
|
app_errors "gpt-load/internal/platform/errors"
|
|
"gpt-load/internal/state"
|
|
"gpt-load/internal/storage/models"
|
|
)
|
|
|
|
func TestGetSettingsReturnsSnapshotDefaultsAndNoOverrides(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
got, err := fixture.service.GetSettings(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision != fixture.manager.Current().Revision || len(got.Overrides) != 0 {
|
|
t.Fatalf("GetSettings() = %#v", got)
|
|
}
|
|
if got.Values.FirstByteTimeout != 120 ||
|
|
got.Values.RequestTimeout != 600 || got.Values.StreamIdleTimeout != 300 ||
|
|
got.Values.ValidationInterval != 600 ||
|
|
got.Values.RequestLogRetentionDays != 7 || !got.Values.InjectUsageOptions {
|
|
t.Fatalf("values = %#v", got.Values)
|
|
}
|
|
if got.Values.HeaderRules.Set == nil || len(got.Values.HeaderRules.Set) != 0 {
|
|
t.Fatalf("header_rules.set = %#v, want empty map", got.Values.HeaderRules.Set)
|
|
}
|
|
if got.Values.HeaderRules.Remove == nil || len(got.Values.HeaderRules.Remove) != 0 {
|
|
t.Fatalf("header_rules.remove = %#v, want empty slice", got.Values.HeaderRules.Remove)
|
|
}
|
|
if got.Overrides == nil {
|
|
t.Fatal("overrides = nil, want empty slice")
|
|
}
|
|
if !got.Values.ModelsDevAutoSyncEnabled || got.ReadOnly == nil || len(got.ReadOnly) != 0 {
|
|
t.Fatalf("Models.dev settings = %#v/%#v, want true and no read-only keys", got.Values, got.ReadOnly)
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsChangesAndResetsValidationInterval(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
updated, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingValidationInterval: json.RawMessage("900"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if updated.Values.ValidationInterval != 900 ||
|
|
fixture.manager.Current().Settings.ValidationInterval != 15*time.Minute ||
|
|
!reflect.DeepEqual(updated.Overrides, []string{state.SettingValidationInterval}) {
|
|
t.Fatalf("updated validation interval = %#v", updated)
|
|
}
|
|
|
|
reset, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingValidationInterval: json.RawMessage("null"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reset.Values.ValidationInterval != 600 || len(reset.Overrides) != 0 {
|
|
t.Fatalf("reset validation interval = %#v", reset)
|
|
}
|
|
}
|
|
|
|
func TestModelsDevEnvironmentOverrideWinsAndIsReadOnlyWithoutPersistence(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
disabled := false
|
|
fixture.service.modelsDevAutoSyncOverride = &disabled
|
|
|
|
got, err := fixture.service.GetSettings(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Values.ModelsDevAutoSyncEnabled || !reflect.DeepEqual(got.ReadOnly, []string{state.SettingModelsDevAutoSyncEnabled}) {
|
|
t.Fatalf("GetSettings() = %#v, want effective false environment lock", got)
|
|
}
|
|
|
|
_, err = fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingModelsDevAutoSyncEnabled: json.RawMessage("true"),
|
|
},
|
|
})
|
|
if !errors.Is(err, app_errors.ErrValidation) {
|
|
t.Fatalf("UpdateSettings(environment locked) error = %v, want validation", err)
|
|
}
|
|
var count int64
|
|
if err := fixture.db.Model(&models.SystemSetting{}).
|
|
Where("key = ?", state.SettingModelsDevAutoSyncEnabled).
|
|
Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("environment override persisted %d rows", count)
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsInjectUsageOptionsBooleanAndNullReset(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
updated, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{state.SettingInjectUsageOptions: json.RawMessage("false")},
|
|
})
|
|
if err != nil || updated.Values.InjectUsageOptions {
|
|
t.Fatalf("UpdateSettings(false) = %#v, %v", updated, err)
|
|
}
|
|
if !reflect.DeepEqual(updated.Overrides, []string{state.SettingInjectUsageOptions}) {
|
|
t.Fatalf("overrides = %#v", updated.Overrides)
|
|
}
|
|
reset, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{state.SettingInjectUsageOptions: json.RawMessage("null")},
|
|
})
|
|
if err != nil || !reset.Values.InjectUsageOptions || len(reset.Overrides) != 0 {
|
|
t.Fatalf("UpdateSettings(null) = %#v, %v", reset, err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsEnablingModelsDevRequestsImmediateSyncOnce(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
if _, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingModelsDevAutoSyncEnabled: json.RawMessage("false"),
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
coordinator := newCatalogSyncCoordinator(
|
|
fixture.service,
|
|
nil,
|
|
"unused",
|
|
catalog.Metadata{},
|
|
false,
|
|
)
|
|
|
|
if _, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingModelsDevAutoSyncEnabled: json.RawMessage("true"),
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-coordinator.immediateWake:
|
|
default:
|
|
t.Fatal("false -> true did not request immediate catalog sync")
|
|
}
|
|
|
|
if _, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingModelsDevAutoSyncEnabled: json.RawMessage("true"),
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-coordinator.immediateWake:
|
|
t.Fatal("true -> true requested another immediate catalog sync")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsIfMatchEnablingModelsDevRequestsImmediateSync(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
if _, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingModelsDevAutoSyncEnabled: json.RawMessage("false"),
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
current, err := fixture.service.GetSettings(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
representation, err := newSettingsWireRepresentation("settings", current.DTO())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
coordinator := newCatalogSyncCoordinator(
|
|
fixture.service,
|
|
nil,
|
|
"unused",
|
|
catalog.Metadata{},
|
|
false,
|
|
)
|
|
if _, err := fixture.service.UpdateSettingsIfMatch(
|
|
t.Context(),
|
|
SettingsUpdateRequest{Settings: map[string]json.RawMessage{
|
|
state.SettingModelsDevAutoSyncEnabled: json.RawMessage("true"),
|
|
}},
|
|
representation.ETag,
|
|
"settings",
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-coordinator.immediateWake:
|
|
default:
|
|
t.Fatal("If-Match false -> true did not request immediate catalog sync")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsRejectsNonBooleanInjectUsageWithoutMutation(t *testing.T) {
|
|
for _, raw := range []json.RawMessage{json.RawMessage("0"), json.RawMessage("1"), json.RawMessage(`"true"`), json.RawMessage("[]"), json.RawMessage("{}")} {
|
|
fixture := newServiceFixture(t)
|
|
before := fixture.manager.Current().Revision
|
|
_, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{state.SettingInjectUsageOptions: raw},
|
|
})
|
|
if !errors.Is(err, app_errors.ErrValidation) || fixture.manager.Current().Revision != before {
|
|
t.Fatalf("UpdateSettings(%s) error/revision = %v/%d, want validation/%d", raw, err, fixture.manager.Current().Revision, before)
|
|
}
|
|
var count int64
|
|
if err := fixture.db.Model(&models.SystemSetting{}).Where("key = ?", state.SettingInjectUsageOptions).Count(&count).Error; err != nil || count != 0 {
|
|
t.Fatalf("persisted rows = %d, %v", count, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetSettingsFiltersAndSortsPublicRuntimeOverrides(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
_, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingRequestTimeout: json.RawMessage("900"),
|
|
state.SettingFirstByteTimeout: json.RawMessage("20"),
|
|
state.SettingStreamIdleTimeout: json.RawMessage("45"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, row := range []models.SystemSetting{
|
|
{Key: "unknown_public_setting", Value: `"hidden"`, UpdatedAtMS: time.Now().UnixMilli()},
|
|
{Key: models.InternalSystemSettingPrefix + "hidden", Value: `"marker"`, UpdatedAtMS: time.Now().UnixMilli()},
|
|
} {
|
|
if err := fixture.db.Create(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
got, err := fixture.service.GetSettings(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantOverrides := []string{
|
|
state.SettingFirstByteTimeout,
|
|
state.SettingRequestTimeout,
|
|
state.SettingStreamIdleTimeout,
|
|
}
|
|
if !reflect.DeepEqual(got.Overrides, wantOverrides) {
|
|
t.Fatalf("overrides = %#v, want %#v", got.Overrides, wantOverrides)
|
|
}
|
|
if got.Values.FirstByteTimeout != 20 || got.Values.RequestTimeout != 900 ||
|
|
got.Values.StreamIdleTimeout != 45 {
|
|
t.Fatalf("values = %#v", got.Values)
|
|
}
|
|
}
|
|
|
|
func TestGetSettingsRejectsMissingSnapshot(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
fixture.service.manager = state.NewManager()
|
|
|
|
_, err := fixture.service.GetSettings(t.Context())
|
|
if !errors.Is(err, app_errors.ErrInternalServer) {
|
|
t.Fatalf("GetSettings() error = %v, want ErrInternalServer", err)
|
|
}
|
|
}
|
|
|
|
func TestGetSettingsWaitsForConfigurationWriteLock(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
fixture.service.writeMu.Lock()
|
|
locked := true
|
|
defer func() {
|
|
if locked {
|
|
fixture.service.writeMu.Unlock()
|
|
}
|
|
}()
|
|
|
|
type result struct {
|
|
settings SettingsResponse
|
|
err error
|
|
}
|
|
done := make(chan result, 1)
|
|
started := make(chan struct{})
|
|
go func() {
|
|
close(started)
|
|
settings, err := fixture.service.GetSettings(t.Context())
|
|
done <- result{settings: settings, err: err}
|
|
}()
|
|
<-started
|
|
select {
|
|
case got := <-done:
|
|
t.Fatalf("GetSettings completed while writeMu held: %#v", got)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
fixture.service.writeMu.Unlock()
|
|
locked = false
|
|
select {
|
|
case got := <-done:
|
|
if got.err != nil || got.settings.Revision != 1 {
|
|
t.Fatalf("GetSettings after unlock = %#v", got)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("GetSettings did not complete after writeMu release")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsPersistsPublishesAndResetsOverrides(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
before := fixture.manager.Current().Revision
|
|
got, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingRequestTimeout: json.RawMessage("900"),
|
|
state.SettingRequestLogRetentionDays: json.RawMessage("30"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision != before+1 || got.Values.RequestTimeout != 900 ||
|
|
got.Values.RequestLogRetentionDays != 30 {
|
|
t.Fatalf("result = %#v", got)
|
|
}
|
|
wantOverrides := []string{state.SettingRequestLogRetentionDays, state.SettingRequestTimeout}
|
|
if !reflect.DeepEqual(got.Overrides, wantOverrides) {
|
|
t.Fatalf("overrides = %#v, want %#v", got.Overrides, wantOverrides)
|
|
}
|
|
|
|
reset, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingRequestTimeout: json.RawMessage("null"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reset.Revision != before+2 || reset.Values.RequestTimeout != 600 {
|
|
t.Fatalf("reset = %#v", reset)
|
|
}
|
|
if !reflect.DeepEqual(reset.Overrides, []string{state.SettingRequestLogRetentionDays}) {
|
|
t.Fatalf("reset overrides = %#v", reset.Overrides)
|
|
}
|
|
var count int64
|
|
if err := fixture.db.Model(&models.SystemSetting{}).
|
|
Where("key = ?", state.SettingRequestTimeout).Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("request_timeout rows = %d", count)
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsCanonicalizesValuesAndReturnsEffectiveHeaderRules(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
fixture.service.now = func() time.Time {
|
|
return time.Date(2026, time.July, 24, 12, 30, 0, 0, time.FixedZone("offset", 8*60*60))
|
|
}
|
|
got, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingHeaderRules: json.RawMessage(` {
|
|
"remove": ["x-old"],
|
|
"set": {"x-zed": "distinctive-value", "A-Test": "first"}
|
|
} `),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantRules := HeaderRulesResponse{
|
|
Set: map[string]string{
|
|
"A-Test": "first",
|
|
"X-Zed": "distinctive-value",
|
|
},
|
|
Remove: []string{"X-Old"},
|
|
}
|
|
if !reflect.DeepEqual(got.Values.HeaderRules, wantRules) {
|
|
t.Fatalf("header rules = %#v, want %#v", got.Values.HeaderRules, wantRules)
|
|
}
|
|
var row models.SystemSetting
|
|
if err := fixture.db.First(&row, "key = ?", state.SettingHeaderRules).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const wantValue = `{"remove":["x-old"],"set":{"A-Test":"first","x-zed":"distinctive-value"}}`
|
|
if row.Value != wantValue {
|
|
t.Fatalf("persisted value = %q, want %q", row.Value, wantValue)
|
|
}
|
|
wantTime := time.Date(2026, time.July, 24, 4, 30, 0, 0, time.UTC)
|
|
if row.UpdatedAtMS != wantTime.UnixMilli() {
|
|
t.Fatalf("updated_at_ms = %d, want %d", row.UpdatedAtMS, wantTime.UnixMilli())
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsRejectsSDKOwnedCredentialHeaders(t *testing.T) {
|
|
initControlI18n(t)
|
|
const (
|
|
authKey = "settings-template-test-auth"
|
|
providerToken = "fake-provider-token-canary"
|
|
)
|
|
fixture := newServiceFixture(t)
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: authKey}, fixture.service).RegisterRoutes(engine)
|
|
|
|
beforeSnapshot := fixture.manager.Current()
|
|
for _, value := range []string{"Bearer ${API_KEY}", "Bearer " + providerToken} {
|
|
rejected := serveSettingsRequest(
|
|
t,
|
|
engine,
|
|
http.MethodPut,
|
|
authKey,
|
|
`{"settings":{"header_rules":{"set":{"Authorization":`+strconv.Quote(value)+`}}}}`,
|
|
)
|
|
if rejected.Code != http.StatusBadRequest ||
|
|
!strings.Contains(rejected.Body.String(), `"code":"VALIDATION_FAILED"`) {
|
|
t.Fatalf("credential header response = %d %s, want 400 validation", rejected.Code, rejected.Body.String())
|
|
}
|
|
}
|
|
if removed := serveSettingsRequest(
|
|
t,
|
|
engine,
|
|
http.MethodPut,
|
|
authKey,
|
|
`{"settings":{"header_rules":{"remove":["Authorization"]}}}`,
|
|
); removed.Code != http.StatusBadRequest {
|
|
t.Fatalf("credential removal response = %d %s, want 400", removed.Code, removed.Body.String())
|
|
}
|
|
if fixture.manager.Current() != beforeSnapshot {
|
|
t.Fatal("rejected credential HeaderRules published a new Snapshot revision")
|
|
}
|
|
|
|
var rows []models.SystemSetting
|
|
if err := fixture.db.Where("key = ?", state.SettingHeaderRules).Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Fatalf("rejected credential HeaderRules changed persisted rows: %#v", rows)
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsRejectsInvalidChangesWithoutPublishing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
updates map[string]json.RawMessage
|
|
wantErr *app_errors.APIError
|
|
}{
|
|
{name: "unknown", updates: map[string]json.RawMessage{"unknown": json.RawMessage("true")}, wantErr: app_errors.ErrValidation},
|
|
{name: "internal", updates: map[string]json.RawMessage{models.InternalSystemSettingPrefix + "marker": json.RawMessage("true")}, wantErr: app_errors.ErrValidation},
|
|
{name: "fractional timeout", updates: map[string]json.RawMessage{state.SettingRequestTimeout: json.RawMessage("1.5")}, wantErr: app_errors.ErrValidation},
|
|
{name: "zero retention", updates: map[string]json.RawMessage{state.SettingRequestLogRetentionDays: json.RawMessage("0")}, wantErr: app_errors.ErrValidation},
|
|
{name: "excess retention", updates: map[string]json.RawMessage{state.SettingRequestLogRetentionDays: json.RawMessage("366")}, wantErr: app_errors.ErrValidation},
|
|
{name: "unknown header rule", updates: map[string]json.RawMessage{state.SettingHeaderRules: json.RawMessage(`{"append":{}}`)}, wantErr: app_errors.ErrValidation},
|
|
{name: "case folded duplicate header", updates: map[string]json.RawMessage{state.SettingHeaderRules: json.RawMessage(`{"set":{"X-Test":"one","x-test":"two"}}`)}, wantErr: app_errors.ErrValidation},
|
|
{name: "malformed raw value", updates: map[string]json.RawMessage{state.SettingRequestTimeout: json.RawMessage("900 800")}, wantErr: app_errors.ErrValidation},
|
|
{name: "empty", updates: map[string]json.RawMessage{}, wantErr: app_errors.ErrBadRequest},
|
|
{name: "nil", updates: nil, wantErr: app_errors.ErrBadRequest},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
before := fixture.manager.Current()
|
|
_, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{Settings: test.updates})
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("error = %v, want %s", err, test.wantErr.Code)
|
|
}
|
|
if fixture.manager.Current() != before {
|
|
t.Fatal("revision or Snapshot pointer changed")
|
|
}
|
|
var count int64
|
|
if err := fixture.db.Model(&models.SystemSetting{}).Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("persisted %d rows", count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateSettingsRollsBackAllRowsWithoutPublishing(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
if err := fixture.db.Exec(`
|
|
CREATE TRIGGER reject_request_timeout
|
|
BEFORE INSERT ON system_settings
|
|
WHEN NEW.key = 'request_timeout'
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'forced settings rollback');
|
|
END
|
|
`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.service.db = fixture.db.Session(&gorm.Session{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
before := fixture.manager.Current()
|
|
|
|
_, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
|
Settings: map[string]json.RawMessage{
|
|
state.SettingStreamIdleTimeout: json.RawMessage("25"),
|
|
state.SettingRequestTimeout: json.RawMessage("900"),
|
|
},
|
|
})
|
|
if !errors.Is(err, app_errors.ErrDatabase) {
|
|
t.Fatalf("UpdateSettings() error = %v, want ErrDatabase", err)
|
|
}
|
|
if fixture.manager.Current() != before {
|
|
t.Fatal("failed transaction published a Snapshot")
|
|
}
|
|
var count int64
|
|
if err := fixture.db.Model(&models.SystemSetting{}).Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("system setting rows = %d, want rollback to zero", count)
|
|
}
|
|
}
|
|
|
|
func TestSettingsUpdateRequestRejectsDuplicateUnknownAndWrongShapeJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{name: "duplicate top level", body: `{"settings":{},"settings":{}}`},
|
|
{name: "duplicate runtime setting", body: `{"settings":{"request_timeout":900,"request_timeout":800}}`},
|
|
{name: "duplicate header rules field", body: `{"settings":{"header_rules":{"set":{},"set":{}}}}`},
|
|
{name: "duplicate header set member", body: `{"settings":{"header_rules":{"set":{"X-Test":"one","X-Test":"two"}}}}`},
|
|
{name: "duplicate object nested in array", body: `{"settings":{"header_rules":{"remove":[{"future":1,"future":2}]}}}`},
|
|
{name: "unknown top level", body: `{"settings":{},"other":{}}`},
|
|
{name: "missing settings", body: `{}`},
|
|
{name: "null settings", body: `{"settings":null}`},
|
|
{name: "array settings", body: `{"settings":[]}`},
|
|
{name: "top level array", body: `[]`},
|
|
{name: "top level scalar", body: `true`},
|
|
{name: "malformed", body: `{"settings":{"request_timeout":900}`},
|
|
{name: "trailing value", body: `{"settings":{}} {}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var request SettingsUpdateRequest
|
|
if err := json.Unmarshal([]byte(test.body), &request); err == nil {
|
|
t.Fatalf("json.Unmarshal(%s) accepted", test.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSettingsUpdateRequestPreservesRawNullAndNumbers(t *testing.T) {
|
|
var request SettingsUpdateRequest
|
|
if err := json.Unmarshal([]byte(
|
|
`{"settings":{"request_timeout":9e2,"header_rules":null}}`,
|
|
), &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(request.Settings) != 2 ||
|
|
!bytes.Equal(request.Settings[state.SettingRequestTimeout], []byte("9e2")) ||
|
|
!bytes.Equal(request.Settings[state.SettingHeaderRules], []byte("null")) {
|
|
t.Fatalf("settings = %#v", request.Settings)
|
|
}
|
|
}
|
|
|
|
func TestRejectDuplicateJSONFieldsWalksArraysScalarsAndTrailingValues(t *testing.T) {
|
|
for _, valid := range []string{
|
|
`1`,
|
|
`[true,null,"value",{"one":[{"two":2}]}]`,
|
|
`{"array":[1,2,3],"scalar":false}`,
|
|
} {
|
|
if err := rejectDuplicateJSONFields([]byte(valid)); err != nil {
|
|
t.Errorf("rejectDuplicateJSONFields(%s) error = %v", valid, err)
|
|
}
|
|
}
|
|
for _, invalid := range []string{
|
|
`[{"duplicate":1,"duplicate":2}]`,
|
|
`{} []`,
|
|
`{"array":[1,2}`,
|
|
} {
|
|
if err := rejectDuplicateJSONFields([]byte(invalid)); err == nil {
|
|
t.Errorf("rejectDuplicateJSONFields(%s) accepted", invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConcurrentSettingsUpdatesPublishDatabaseTruth(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
before := fixture.manager.Current().Revision
|
|
updates := []SettingsUpdateRequest{
|
|
{Settings: map[string]json.RawMessage{state.SettingFirstByteTimeout: json.RawMessage("21")}},
|
|
{Settings: map[string]json.RawMessage{state.SettingRequestTimeout: json.RawMessage("901")}},
|
|
}
|
|
start := make(chan struct{})
|
|
errs := make(chan error, len(updates))
|
|
var ready sync.WaitGroup
|
|
ready.Add(len(updates))
|
|
for _, update := range updates {
|
|
update := update
|
|
go func() {
|
|
ready.Done()
|
|
<-start
|
|
_, err := fixture.service.UpdateSettings(t.Context(), update)
|
|
errs <- err
|
|
}()
|
|
}
|
|
ready.Wait()
|
|
close(start)
|
|
for range updates {
|
|
if err := <-errs; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
got, err := fixture.service.GetSettings(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision != before+2 || got.Values.FirstByteTimeout != 21 ||
|
|
got.Values.RequestTimeout != 901 {
|
|
t.Fatalf("settings = %#v", got)
|
|
}
|
|
wantOverrides := []string{state.SettingFirstByteTimeout, state.SettingRequestTimeout}
|
|
if !reflect.DeepEqual(got.Overrides, wantOverrides) {
|
|
t.Fatalf("overrides = %#v, want %#v", got.Overrides, wantOverrides)
|
|
}
|
|
}
|