Files
tbphp 18079996f0 refactor(settings): 重构设置页分区并统一分组设置的覆盖交互 (#576)
* refactor(settings): 按业务语义重排配置分区,收敛默认态展示密度

- 路由策略并入亲和分区(路由与调度),validation_interval 移出超时分组并入重试/凭据健康,
  models_dev_auto_sync_enabled 从请求转发移至数据与维护,header_rules 与 inject_usage_options
  合并为上游请求改写;6 个分区拆为 7 个语义更清晰的分区。
- 新增 SettingRow 承载默认态单行展示、覆盖态才展开控件与说明文字,默认态徽标与操作按钮收敛,
  避免逐字段重复常驻的说明文案。

* refactor(settings): 抽取 SettingRow 为共享组件并同步分组设置页展示密度

- SettingRow 从设置页移至 components/config,替换分组设置页运行时区块里的
  RuntimeOverrideRow,行为对齐:默认态收敛徽标/按钮、覆盖态才展开控件与说明。
- RuntimeOverrideRow 无剩余引用,一并删除。
- 顺带修正分组页 inject_usage_options 折叠态展示 help 文案而非当前值的问题,
  改为展示已启用/已禁用,说明文字移入 help 提示。
- 请求亲和沿用原有 SegmentedControl 三态控件,未纳入本次迁移。

* fix(settings): 按反馈调整 SettingRow 交互细节

- 提示统一用已有 AppTooltip 组件,替换原生 title;且不论是否覆盖,说明都只在
  标签旁以 tooltip 展示,不再于覆盖态额外常驻一份。
- 覆盖/恢复默认按钮从行尾移到值后面,改回可见的按钮样式,不再靠悬停淡入。
- 覆盖态不再叠加“显式覆盖”徽标,仅保留左侧强调色与底色区分;待恢复/环境变量
  锁定两种状态的徽标保留。
- 路由策略改用 SegmentedControl 二选一切换,替换下拉选择,和代理等控件观感一致。
- 各分区行列表之间补上小间距,避免行与行贴在一起。

* fix(settings): 覆盖/恢复默认按钮改为图标 + tooltip

文字按钮换成小方形图标按钮:覆盖用 PencilLine,恢复默认用 RotateCcw,配色沿用
原有 action/warning 两种 tone;按钮不再显示文字,改为 hover/聚焦时通过
AppTooltip 展示对应文案,无障碍上保留 aria-label。

* fix(settings): 覆盖/恢复默认图标去掉按钮底色,仅悬停反馈

去掉常驻边框和背景色,图标默认裸露显示,与旁边的说明提示图标风格保持一致;
悬停/聚焦时才出现对应 tone 的浅色背景。

* fix(settings): 覆盖图标恢复悬停显示

未覆盖态(含待恢复默认)的“覆盖”图标恢复为鼠标悬停/键盘聚焦到该行才显示,
平时隐藏;已覆盖态的“恢复默认”图标保持常驻可见,因为该行本身已有强调色和
底色区分,不需要靠图标常驻来提示。窄屏和纯触屏环境下始终显示,避免无法触发。

* refactor(settings): 移除 inject_usage_options 并统一设置页控件尺寸

移除 inject_usage_options 配置,改为始终注入:
- 该开关只在「OpenAI Completions + 原生透传 + 流式」这一条路径生效(转换路径的
  usage 来自 SDK,Anthropic/Gemini 原生流自带 usage),默认即为开启,暴露为用户
  配置的收益远低于理解成本。
- 后端删除设置键、分组覆盖与渠道约束校验,IncludeUsage 固定为 ObserveUsage;
  新增迁移 0008 清理 system_settings 行与分组 overrides 中的历史键,否则解析器
  会以 "unknown runtime setting" 拒绝整份配置。
- 前端删除两处设置行、资源投影字段与三语文案。

设置页与分组设置页的控件:
- 新增 --setting-control-height,并在 .setting-row/.setting-block 上覆盖
  --control-compact/xs/sm,让输入框、切换控件、下拉统一小一档;变量继承进子组件,
  不需要和组件自身 scoped 样式比优先级。
- 固定行内容高度,折叠态与覆盖态不再有高度差,点开编辑时行不会跳动。
- 块级说明(CORS 策略、下游响应头规则)从常驻小字改为标签旁 tooltip,与设置行一致。

分组设置页删除只读的渠道展示:顶部已有渠道信息,此处重复且不可编辑。

* fix(storage): 修复迁移 0008 在 PostgreSQL 上的两处方言问题

- 反引号是 MySQL/SQLite 语法,PostgreSQL 解析 `key` = ? 直接报 syntax error,
  导致启动中断。改用 GORM 结构体条件,由驱动按方言自行加引号。
- 往 json 列写 []byte 时 PostgreSQL 驱动按 bytea 处理,报 invalid input syntax
  for type json。改为写字符串。

已在 SQLite / PostgreSQL 16 / MySQL 8 三种方言上实跑升级路径:注入历史的
system_settings 行与分组 overrides 后启动,确认键被清除、分组只剩其余覆盖、
迁移正常记账。

* refactor(settings): 合并上游 Header 到跨域分区,统一控件尺寸与请求头/响应头命名

- 分组页控件尺寸未跟着变小,是因为上一轮的尺寸变量覆盖只挂在 .setting-row/
  .setting-block 上;分组页的姓名/验活模型输入框、权重与请求亲和的 SegmentedControl
  都不在这两个类名内。改为挂在 .settings__content/.group-settings__content 内容区
  (不含侧边导航与保存栏),覆盖 --control-compact/xs/sm/md 四个尺寸变量,让设置页
  和分组页的所有交互控件统一收敛到 26px。

- "上游请求改写"分区被删除,header_rules 合并进浏览器访问分区,与 CORS、
  下游响应头规则放在一起(顺序:CORS → 上游请求头规则 → 下游响应头规则);
  该分区原本只剩 header_rules 一项(inject_usage_options 上一轮已删除),
  单独占一个侧边菜单已经没有意义。

- 请求头/响应头命名统一:全局设置页"全局 Header Rules"改名"上游请求头规则",
  与已有的"下游响应头规则"对称;分组页"HeaderRules"同步改名"上游请求头规则"。
  新分区标题定为"Header 与跨域"。

- 清理合并后失效的 settings.headers.* 键(title/currentPublishedRuleCount/
  defaultSource/overrideSource/pendingRestoreSource/override/restoreDefault/
  inherited/resetPending/replacementWarning/securityNotice),改用已有的
  settings.runtime.* 通用状态词汇,与 CORS、下游响应头规则保持一致。

已用真实浏览器验证:分区合并后的顺序与文案、校验横幅跳转到正确分区并定位到
非法输入框、分组页控件高度与设置页一致。

* refactor(group-settings): 统一请求亲和与上游请求头规则的覆盖交互

请求亲和由三态 SegmentedControl 改为 SettingRow 覆盖/恢复默认模式;
上游请求头规则由 details 折叠改为 SettingBlock,并补上独立的覆盖开关,
与设置页的交互模式完全一致;同步移除废弃的展开态路由状态与文案。

* fix(group-settings): 恢复默认时不再回显未保存的旧覆盖值

分组已保存覆盖值后,草稿中点击恢复默认会读到仍是旧覆盖值的 effective
快照,导致视觉上"看起来没变"。为出站代理、超时/次数类、请求亲和、
上游请求头规则补充待恢复态:展示占位文案与徽标,保存后才回填真实的
全局生效值,与设置页面的重置反馈保持一致。

* feat(group-settings): 验活模型改为可搜索候选输入

复用参数覆盖规则里模型输入的 datalist 模式,把分组当前模型作为候选,
输入即过滤;候选取上游模型 ID(验活按该值请求上游),别名作为副标题
展示。后端不限制该值,仍保留自由输入。

* fix(settings): 按 review 处理迁移账本断言与三处设置项细节

- 外部数据库契约里的迁移账本断言硬编码到 0007,只在 PostgreSQL/MySQL 下
  执行,本地 SQLite 会跳过,导致 0008 落库后仅 CI 报错
- 下游响应头规则的待恢复态未取草稿值,取消覆盖后仍回显旧规则,与同区的
  上游请求头规则表现不一致
- 连接与可靠性分区的数字输入清空后写入 0,其中重试次数与拉黑阈值的 0 合法,
  会被静默保存;改为与路由分区一致按 NaN 处理
- 修正 pendingRestoreSource 的英文文案
2026-09-06 22:54:21 +08:00

875 lines
30 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/outboundproxy"
"gpt-load/internal/platform/config"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/state"
stateloader "gpt-load/internal/state/loader"
"gpt-load/internal/storage/models"
)
func TestSettingsProxyConfigIsEncryptedMaskedAndResettable(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
const endpoint = "http://proxy-user:proxy-password@proxy.example.com:8080"
updated, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{
outboundproxy.SystemSettingKey: json.RawMessage(`{"mode":"custom","url":"` + endpoint + `"}`),
},
})
if err != nil {
t.Fatalf("UpdateSettings(proxy) error = %v", err)
}
view := updated.Values.ProxyConfig
if view.ConfiguredMode != outboundproxy.ModeCustom || view.EffectiveSource != outboundproxy.SourceGlobal ||
view.DisplayURL != "http://proxy-user:******@proxy.example.com:8080" || !view.HasAuth {
t.Fatalf("proxy view = %#v", view)
}
var row models.SystemSetting
if err := fixture.db.Where("key = ?", outboundproxy.SystemSettingKey).Take(&row).Error; err != nil {
t.Fatalf("read proxy setting: %v", err)
}
if strings.Contains(row.Value, endpoint) || strings.Contains(row.Value, "proxy-password") {
t.Fatalf("proxy setting stored plaintext: %q", row.Value)
}
plaintext, err := fixture.encryption.Decrypt(row.Value)
if err != nil {
t.Fatalf("decrypt proxy setting: %v", err)
}
config, err := outboundproxy.Decode(plaintext)
if err != nil || config.URL != endpoint {
t.Fatalf("stored proxy config = %#v, %v", config, err)
}
reset, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{outboundproxy.SystemSettingKey: json.RawMessage("null")},
})
if err != nil {
t.Fatalf("UpdateSettings(proxy null) error = %v", err)
}
if reset.Values.ProxyConfig.ConfiguredMode != outboundproxy.ModeInherit {
t.Fatalf("reset proxy view = %#v", reset.Values.ProxyConfig)
}
var count int64
if err := fixture.db.Model(&models.SystemSetting{}).Where("key = ?", outboundproxy.SystemSettingKey).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("proxy setting rows after reset = %d", count)
}
}
func TestUpdateSettingsRouteStrategyPersistsPublishesReloadsAndResets(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
for _, test := range []struct {
name string
raw json.RawMessage
strategy state.RouteStrategy
}{
{name: "weighted mix", raw: json.RawMessage(`"weighted_mix"`), strategy: state.RouteStrategyWeightedMix},
{name: "explicit native first", raw: json.RawMessage(`"native_first"`), strategy: state.RouteStrategyNativeFirst},
{name: "weighted mix again", raw: json.RawMessage(`"weighted_mix"`), strategy: state.RouteStrategyWeightedMix},
{name: "reset", raw: json.RawMessage(`null`), strategy: state.RouteStrategyNativeFirst},
} {
t.Run(test.name, func(t *testing.T) {
before := fixture.manager.Current()
previousStrategy := before.Settings.RouteStrategy
got, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{state.SettingRouteStrategy: test.raw},
})
if err != nil {
t.Fatal(err)
}
after := fixture.manager.Current()
if got.Values.RouteStrategy != test.strategy || after.Settings.RouteStrategy != test.strategy ||
after.Revision != before.Revision+1 || before.Settings.RouteStrategy != previousStrategy {
t.Fatalf("response/snapshots = %#v / %#v / %#v", got, before.Settings, after.Settings)
}
var rows []models.SystemSetting
if err := fixture.db.Where("key = ?", state.SettingRouteStrategy).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if string(test.raw) == "null" {
if len(got.Overrides) != 0 || len(rows) != 0 {
t.Fatalf("reset overrides/rows = %#v / %#v", got.Overrides, rows)
}
} else if !reflect.DeepEqual(got.Overrides, []string{state.SettingRouteStrategy}) ||
len(rows) != 1 || rows[0].Value != string(test.raw) {
t.Fatalf("persisted overrides/rows = %#v / %#v", got.Overrides, rows)
}
reloaded := state.NewManager()
if err := stateloader.New(fixture.db, reloaded, state.NewCredentialRegistry()).Load(t.Context()); err != nil {
t.Fatal(err)
}
if got := reloaded.Current().Settings.RouteStrategy; got != test.strategy {
t.Fatalf("reloaded route strategy = %q, want %q", got, test.strategy)
}
})
}
}
func TestSettingsProxyRejectsInvalidConfigWithoutMutation(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
_, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{outboundproxy.SystemSettingKey: json.RawMessage(
`{"mode":"custom","url":"ftp://user:password@proxy.example.com"}`,
)},
})
if !errors.Is(err, app_errors.ErrValidation) {
t.Fatalf("UpdateSettings(invalid proxy) error = %v, want validation", err)
}
var count int64
if err := fixture.db.Model(&models.SystemSetting{}).Where("key = ?", outboundproxy.SystemSettingKey).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("invalid proxy persisted %d rows", count)
}
}
func TestGetSettingsReturnsSnapshotDefaultsAndNoOverrides(t *testing.T) {
t.Parallel()
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.RouteStrategy != state.RouteStrategyNativeFirst ||
got.Values.RequestLogRetentionDays != 7 {
t.Fatalf("values = %#v", got.Values)
}
if !got.Values.AffinityEnabled || got.Values.AffinityTTL != 3600 ||
got.Values.AffinityCapacity != 10_000 {
t.Fatalf("affinity 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.Values.CORS.Enabled ||
!reflect.DeepEqual(got.Values.CORS.AllowedMethods, []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}) ||
!reflect.DeepEqual(got.Values.CORS.AllowedHeaders, []string{"*"}) ||
got.Values.CORS.MaxAge != 600 {
t.Fatalf("cors = %#v, want disabled browser-access defaults", got.Values.CORS)
}
if got.Values.CORS.AllowedOrigins == nil || got.Values.CORS.ExposedHeaders == nil {
t.Fatalf("cors collections must be non-nil: %#v", got.Values.CORS)
}
if got.Values.ResponseHeaderRules.Set == nil || got.Values.ResponseHeaderRules.Remove == nil {
t.Fatalf("response_header_rules collections must be non-nil: %#v", got.Values.ResponseHeaderRules)
}
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 TestUpdateSettingsPublishesCORSAndResponseHeaderRules(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
updated, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{
state.SettingCORS: json.RawMessage(`{
"enabled": true,
"allowed_origins": ["app://obsidian.md"],
"allowed_methods": ["post"],
"allowed_headers": ["authorization", "content-type"],
"exposed_headers": ["x-request-id"],
"allow_credentials": true,
"max_age": 900
}`),
state.SettingResponseHeaderRules: json.RawMessage(`{
"set": {"x-browser-client": "enabled"},
"remove": ["x-upstream-marker"]
}`),
},
})
if err != nil {
t.Fatal(err)
}
wantCORS := CORSConfigResponse{
Enabled: true,
AllowedOrigins: []string{"app://obsidian.md"},
AllowedMethods: []string{"POST"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
ExposedHeaders: []string{"X-Request-Id"},
AllowCredentials: true,
MaxAge: 900,
}
if !reflect.DeepEqual(updated.Values.CORS, wantCORS) {
t.Fatalf("cors = %#v, want %#v", updated.Values.CORS, wantCORS)
}
wantRules := HeaderRulesResponse{
Set: map[string]string{"X-Browser-Client": "enabled"},
Remove: []string{"X-Upstream-Marker"},
}
if !reflect.DeepEqual(updated.Values.ResponseHeaderRules, wantRules) {
t.Fatalf("response header rules = %#v, want %#v", updated.Values.ResponseHeaderRules, wantRules)
}
wantOverrides := []string{state.SettingCORS, state.SettingResponseHeaderRules}
if !reflect.DeepEqual(updated.Overrides, wantOverrides) {
t.Fatalf("overrides = %#v, want %#v", updated.Overrides, wantOverrides)
}
var rows []models.SystemSetting
if err := fixture.db.Order("key").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].Key != state.SettingCORS || rows[1].Key != state.SettingResponseHeaderRules {
t.Fatalf("persisted rows = %#v", rows)
}
}
func TestUpdateSettingsResolvesRetryAndBlacklistPolicies(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
defaults, err := fixture.service.GetSettings(t.Context())
if err != nil {
t.Fatal(err)
}
assertSettingsPolicyJSON(t, defaults.Values, 2, 3)
updated, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{
state.SettingRetryCount: json.RawMessage("0"),
state.SettingBlacklistThreshold: json.RawMessage("0"),
},
})
if err != nil {
t.Fatal(err)
}
assertSettingsPolicyJSON(t, updated.Values, 0, 0)
wantOverrides := []string{
state.SettingBlacklistThreshold,
state.SettingRetryCount,
}
if !reflect.DeepEqual(updated.Overrides, wantOverrides) {
t.Fatalf("overrides = %#v, want %#v", updated.Overrides, wantOverrides)
}
}
func assertSettingsPolicyJSON(
t *testing.T,
values SettingsValuesResponse,
retryCount int,
blacklistThreshold int,
) {
t.Helper()
encoded, err := json.Marshal(values)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(encoded, &got); err != nil {
t.Fatal(err)
}
want := map[string]any{
state.SettingRetryCount: float64(retryCount),
state.SettingBlacklistThreshold: float64(blacklistThreshold),
}
for key, expected := range want {
if got[key] != expected {
t.Errorf("%s = %#v, want %#v; values=%s", key, got[key], expected, encoded)
}
}
}
func TestUpdateSettingsChangesAndResetsValidationInterval(t *testing.T) {
t.Parallel()
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 TestUpdateSettingsChangesAndResetsAffinityDefaults(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
updated, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{
state.SettingAffinityEnabled: json.RawMessage("false"),
state.SettingAffinityTTL: json.RawMessage("7200"),
state.SettingAffinityCapacity: json.RawMessage("20000"),
},
})
if err != nil {
t.Fatal(err)
}
if updated.Values.AffinityEnabled || updated.Values.AffinityTTL != 7200 ||
updated.Values.AffinityCapacity != 20_000 {
t.Fatalf("updated affinity settings = %#v", updated)
}
snapshot := fixture.manager.Current()
if snapshot.Settings.AffinityEnabled || snapshot.Settings.AffinityTTL != 2*time.Hour ||
snapshot.Settings.AffinityCapacity != 20_000 {
t.Fatalf("published affinity settings = %#v", snapshot.Settings)
}
reset, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
Settings: map[string]json.RawMessage{
state.SettingAffinityEnabled: json.RawMessage("null"),
state.SettingAffinityTTL: json.RawMessage("null"),
state.SettingAffinityCapacity: json.RawMessage("null"),
},
})
if err != nil {
t.Fatal(err)
}
if !reset.Values.AffinityEnabled || reset.Values.AffinityTTL != 3600 ||
reset.Values.AffinityCapacity != 10_000 {
t.Fatalf("reset affinity settings = %#v", reset)
}
}
func TestModelsDevEnvironmentOverrideWinsAndIsReadOnlyWithoutPersistence(t *testing.T) {
t.Parallel()
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 TestUpdateSettingsEnablingModelsDevRequestsImmediateSyncOnce(t *testing.T) {
t.Parallel()
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 TestGetSettingsFiltersAndSortsPublicRuntimeOverrides(t *testing.T) {
t.Parallel()
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) {
t.Parallel()
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) {
t.Parallel()
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) {
t.Parallel()
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) {
t.Parallel()
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) {
t.Parallel()
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) {
t.Parallel()
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: "negative retry count", updates: map[string]json.RawMessage{state.SettingRetryCount: json.RawMessage("-1")}, wantErr: app_errors.ErrValidation},
{name: "fractional blacklist threshold", updates: map[string]json.RawMessage{state.SettingBlacklistThreshold: 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: "enabled CORS without origins", updates: map[string]json.RawMessage{state.SettingCORS: json.RawMessage(`{"enabled":true,"allowed_origins":[]}`)}, wantErr: app_errors.ErrValidation},
{name: "reserved response header", updates: map[string]json.RawMessage{state.SettingResponseHeaderRules: json.RawMessage(`{"set":{"Access-Control-Allow-Origin":"*"}}`)}, 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) {
t.Parallel()
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) {
t.Parallel()
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: "duplicate CORS field", body: `{"settings":{"cors":{"enabled":true,"enabled":false}}}`},
{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) {
t.Parallel()
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) {
t.Parallel()
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) {
t.Parallel()
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)
}
}