package config import ( "fmt" "math" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/spf13/viper" "github.com/stretchr/testify/require" ) func resetViperWithJWTSecret(t *testing.T) { t.Helper() viper.Reset() t.Cleanup(viper.Reset) t.Setenv("CONFIG_FILE", "") t.Setenv("DATA_DIR", "") t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) } func TestLoadTimezonePrecedence(t *testing.T) { tests := []struct { name string fileTimezone string timezoneEnv string tzEnv string want string }{ {name: "default", want: "Asia/Shanghai"}, {name: "config_file", fileTimezone: "Europe/London", want: "Europe/London"}, {name: "timezone_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", want: "UTC"}, {name: "tz_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", tzEnv: "America/New_York", want: "America/New_York"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("TIMEZONE", tt.timezoneEnv) t.Setenv("TZ", tt.tzEnv) if tt.fileTimezone != "" { configFile := filepath.Join(t.TempDir(), "config.yaml") require.NoError(t, os.WriteFile(configFile, []byte("timezone: "+tt.fileTimezone+"\n"), 0o600)) t.Setenv("CONFIG_FILE", configFile) } cfg, err := Load() require.NoError(t, err) require.Equal(t, tt.want, cfg.Timezone) }) } } func TestLoadServerTimingConfig(t *testing.T) { t.Run("disabled by default", func(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.False(t, cfg.Server.EnableServerTiming) }) t.Run("enabled by exact environment variable", func(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("ENABLE_SERVER_TIMING", "true") cfg, err := Load() require.NoError(t, err) require.True(t, cfg.Server.EnableServerTiming) }) } func TestLoadRedisUsernameFromEnvironment(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("REDIS_USERNAME", "app-user") cfg, err := Load() require.NoError(t, err) require.Equal(t, "app-user", cfg.Redis.Username) } func TestLoadHTTPIngressSafetyDefaults(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.Equal(t, 10, cfg.Server.ReadHeaderTimeout) require.Equal(t, 64*1024, cfg.Server.MaxHeaderBytes) require.Empty(t, cfg.Server.TrustedProxies) require.False(t, cfg.Server.TrustedProxiesConfigured) require.True(t, cfg.TrustForwardedIPForAPIKeyACL()) require.Equal(t, int64(32*1024*1024), cfg.Gateway.TextMaxBodySize) require.True(t, cfg.APIKeyAuth.InvalidAbuse.Enabled) require.Equal(t, 120, cfg.APIKeyAuth.InvalidAbuse.Threshold) require.Equal(t, 16384, cfg.APIKeyAuth.InvalidAbuse.Capacity) } func TestNormalizeForwardedClientIPHeaders(t *testing.T) { headers, err := NormalizeForwardedClientIPHeaders([]string{ " x-cdn-client-ip ", "X-CDN-CLIENT-IP", "true-client-ip", }) require.NoError(t, err) require.Equal(t, []string{"X-Cdn-Client-Ip", "True-Client-Ip"}, headers) _, err = NormalizeForwardedClientIPHeaders([]string{"X Invalid"}) require.ErrorContains(t, err, "invalid HTTP header field name") } func TestNormalizeForwardedClientIPHeadersLimit(t *testing.T) { headers := make([]string, 0, MaxForwardedClientIPHeaders+1) for i := 0; i <= MaxForwardedClientIPHeaders; i++ { headers = append(headers, fmt.Sprintf("X-CDN-IP-%d", i)) } _, err := NormalizeForwardedClientIPHeaders(headers) require.ErrorContains(t, err, "at most 16 unique names") } func TestLoadForwardedClientIPHeadersNormalizesAndSnapshots(t *testing.T) { resetViperWithJWTSecret(t) viper.Set("security.forwarded_client_ip_headers", []string{" x-cdn-ip ", "X-CDN-IP", "true-client-ip"}) cfg, err := Load() require.NoError(t, err) snapshot := cfg.ForwardedClientIPSettings() require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, snapshot.Headers) snapshot.Headers[0] = "X-Mutated" require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers) } func TestForwardedClientIPSettingsConcurrentPublication(t *testing.T) { cfg := &Config{} cfg.SetForwardedClientIPSettings(true, []string{"X-Public-A"}) const iterations = 2000 start := make(chan struct{}) errCh := make(chan error, 8) var wg sync.WaitGroup for _, settings := range []ForwardedClientIPSettings{ {TrustForwardedIP: true, Headers: []string{"X-Public-A"}}, {TrustForwardedIP: false, Headers: []string{"X-Public-B"}}, } { settings := settings wg.Add(1) go func() { defer wg.Done() <-start for i := 0; i < iterations; i++ { cfg.SetForwardedClientIPSettings(settings.TrustForwardedIP, settings.Headers) } }() } for i := 0; i < cap(errCh); i++ { wg.Add(1) go func() { defer wg.Done() <-start for j := 0; j < iterations; j++ { snapshot := cfg.ForwardedClientIPSettings() validA := snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-A" validB := !snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-B" if !validA && !validB { errCh <- fmt.Errorf("observed inconsistent forwarded IP settings: %+v", snapshot) return } } }() } close(start) wg.Wait() close(errCh) for err := range errCh { require.NoError(t, err) } } func TestLoadForwardedClientIPHeadersFromEnvironment(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", " x-cdn-ip , X-CDN-IP, true-client-ip ") cfg, err := Load() require.NoError(t, err) require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers) } func TestLoadExplicitEmptyForwardedClientIPHeadersFromEnvironment(t *testing.T) { resetViperWithJWTSecret(t) viper.Set("security.forwarded_client_ip_headers", []string{"X-Yaml-IP"}) t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "") cfg, err := Load() require.NoError(t, err) require.Empty(t, cfg.ForwardedClientIPSettings().Headers) } func TestLoadRejectsInvalidForwardedClientIPHeaderFromEnvironment(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "X-Valid-IP, X Invalid") _, err := Load() require.ErrorContains(t, err, "security.forwarded_client_ip_headers") } func TestLoadRejectsInvalidForwardedClientIPHeader(t *testing.T) { resetViperWithJWTSecret(t) viper.Set("security.forwarded_client_ip_headers", []string{"X Invalid"}) _, err := Load() require.ErrorContains(t, err, "security.forwarded_client_ip_headers") } func TestLoadExplicitEmptyTrustedProxiesEnablesConfiguredMode(t *testing.T) { resetViperWithJWTSecret(t) viper.Set("server.trusted_proxies", []string{}) cfg, err := Load() require.NoError(t, err) require.Empty(t, cfg.Server.TrustedProxies) require.True(t, cfg.Server.TrustedProxiesConfigured) } func TestLoadExplicitTrustedProxiesEnablesConfiguredMode(t *testing.T) { resetViperWithJWTSecret(t) viper.Set("server.trusted_proxies", []string{"127.0.0.1/32"}) cfg, err := Load() require.NoError(t, err) require.Equal(t, []string{"127.0.0.1/32"}, cfg.Server.TrustedProxies) require.True(t, cfg.Server.TrustedProxiesConfigured) } func TestLoadTrustedProxiesFromEnvironment(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("SERVER_TRUSTED_PROXIES", "127.0.0.1/32, ::1/128") cfg, err := Load() require.NoError(t, err) require.Equal(t, []string{"127.0.0.1/32", "::1/128"}, cfg.Server.TrustedProxies) require.True(t, cfg.Server.TrustedProxiesConfigured) } func TestLoadExplicitEmptyTrustedProxiesFromEnvironment(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("SERVER_TRUSTED_PROXIES", "") cfg, err := Load() require.NoError(t, err) require.Empty(t, cfg.Server.TrustedProxies) require.True(t, cfg.Server.TrustedProxiesConfigured) } func TestLoadTrustedProxiesPresenceFromYAML(t *testing.T) { tests := []struct { name string yaml string want []string configured bool }{ {name: "absent", yaml: "server:\n mode: debug\n", configured: false}, {name: "explicit empty", yaml: "server:\n trusted_proxies: []\n", want: []string{}, configured: true}, { name: "populated", yaml: "server:\n trusted_proxies:\n - 127.0.0.1/32\n - ::1/128\n", want: []string{"127.0.0.1/32", "::1/128"}, configured: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { resetViperWithJWTSecret(t) configDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(test.yaml), 0o600)) t.Setenv("DATA_DIR", configDir) cfg, err := Load() require.NoError(t, err) require.Equal(t, test.want, cfg.Server.TrustedProxies) require.Equal(t, test.configured, cfg.Server.TrustedProxiesConfigured) }) } } func TestLoadTrustedProxiesFromConfigFile(t *testing.T) { resetViperWithJWTSecret(t) configFile := filepath.Join(t.TempDir(), "reporter-config.yaml") require.NoError(t, os.WriteFile(configFile, []byte(`server: host: 192.0.2.10 trusted_proxies: - 127.0.0.1/32 - 10.0.0.0/8 - 172.16.0.0/12 - 192.168.0.0/16 `), 0o600)) t.Setenv("CONFIG_FILE", configFile) cfg, err := Load() require.NoError(t, err) require.Equal(t, "192.0.2.10", cfg.Server.Host) require.Equal(t, []string{"127.0.0.1/32", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}, cfg.Server.TrustedProxies) require.True(t, cfg.Server.TrustedProxiesConfigured) } func TestConfigFileTakesPrecedenceOverDataDir(t *testing.T) { resetViperWithJWTSecret(t) dataDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("server:\n host: 192.0.2.20\n"), 0o600)) configFile := filepath.Join(t.TempDir(), "explicit.yaml") require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.30\n"), 0o600)) t.Setenv("DATA_DIR", dataDir) t.Setenv("CONFIG_FILE", configFile) cfg, err := Load() require.NoError(t, err) require.Equal(t, "192.0.2.30", cfg.Server.Host) } func TestLoadReturnsErrorForMissingConfigFile(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("CONFIG_FILE", filepath.Join(t.TempDir(), "missing.yaml")) _, err := Load() require.ErrorContains(t, err, "read config error") } func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) { viper.Reset() t.Cleanup(viper.Reset) t.Setenv("CONFIG_FILE", "") t.Setenv("DATA_DIR", "") t.Setenv("JWT_SECRET", "") cfg, err := LoadForBootstrap() if err != nil { t.Fatalf("LoadForBootstrap() error: %v", err) } if cfg.JWT.Secret != "" { t.Fatalf("LoadForBootstrap() should keep empty jwt.secret during bootstrap") } } func TestNormalizeRunMode(t *testing.T) { tests := []struct { input string expected string }{ {"simple", "simple"}, {"SIMPLE", "simple"}, {"standard", "standard"}, {"invalid", "standard"}, {"", "standard"}, } for _, tt := range tests { result := NormalizeRunMode(tt.input) if result != tt.expected { t.Errorf("NormalizeRunMode(%q) = %q, want %q", tt.input, result, tt.expected) } } } func TestLoadDefaultSchedulingConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Gateway.Scheduling.StickySessionMaxWaiting != 3 { t.Fatalf("StickySessionMaxWaiting = %d, want 3", cfg.Gateway.Scheduling.StickySessionMaxWaiting) } if cfg.Gateway.Scheduling.StickySessionWaitTimeout != 120*time.Second { t.Fatalf("StickySessionWaitTimeout = %v, want 120s", cfg.Gateway.Scheduling.StickySessionWaitTimeout) } if cfg.Gateway.Scheduling.FallbackWaitTimeout != 30*time.Second { t.Fatalf("FallbackWaitTimeout = %v, want 30s", cfg.Gateway.Scheduling.FallbackWaitTimeout) } if cfg.Gateway.Scheduling.FallbackMaxWaiting != 100 { t.Fatalf("FallbackMaxWaiting = %d, want 100", cfg.Gateway.Scheduling.FallbackMaxWaiting) } if !cfg.Gateway.Scheduling.LoadBatchEnabled { t.Fatalf("LoadBatchEnabled = false, want true") } if cfg.Gateway.Scheduling.LoadBatchCacheTTLMS != 200 { t.Fatalf("LoadBatchCacheTTLMS = %d, want 200", cfg.Gateway.Scheduling.LoadBatchCacheTTLMS) } if cfg.Gateway.Scheduling.SlotCleanupInterval != 30*time.Second { t.Fatalf("SlotCleanupInterval = %v, want 30s", cfg.Gateway.Scheduling.SlotCleanupInterval) } } func TestLoadDefaultOpenAIFirstOutputTimeoutsDisabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.Zero(t, cfg.Gateway.OpenAIFirstOutputTimeoutSeconds) require.Zero(t, cfg.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds) } func TestLoadOpenAIFirstOutputTimeoutsFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_OPENAI_FIRST_OUTPUT_TIMEOUT_SECONDS", "90") t.Setenv("GATEWAY_OPENAI_HIGH_EFFORT_FIRST_OUTPUT_TIMEOUT_SECONDS", "240") cfg, err := Load() require.NoError(t, err) require.Equal(t, 90, cfg.Gateway.OpenAIFirstOutputTimeoutSeconds) require.Equal(t, 240, cfg.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds) } func TestValidateOpenAIFirstOutputTimeoutMinimum(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 30 require.NoError(t, cfg.Validate()) } func TestLoadDefaultOpenAIWSConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if !cfg.Gateway.OpenAIWS.Enabled { t.Fatalf("Gateway.OpenAIWS.Enabled = false, want true") } if !cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 { t.Fatalf("Gateway.OpenAIWS.ResponsesWebsocketsV2 = false, want true") } if cfg.Gateway.OpenAIWS.ResponsesWebsockets { t.Fatalf("Gateway.OpenAIWS.ResponsesWebsockets = true, want false") } if !cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled { t.Fatalf("Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = false, want true") } if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 1.0 { t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor) } if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 1.0 { t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor) } if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 { t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds) } if !cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled { t.Fatalf("Gateway.OpenAIScheduler.StickyEscapeEnabled = false, want true") } if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs != 15000 { t.Fatalf("Gateway.OpenAIScheduler.StickyEscapeTTFTMs = %d, want 15000", cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs) } if cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate != 0.5 { t.Fatalf("Gateway.OpenAIScheduler.StickyEscapeErrorRate = %v, want 0.5", cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate) } if !cfg.Gateway.OpenAIWS.SessionHashReadOldFallback { t.Fatalf("Gateway.OpenAIWS.SessionHashReadOldFallback = false, want true") } if !cfg.Gateway.OpenAIWS.SessionHashDualWriteOld { t.Fatalf("Gateway.OpenAIWS.SessionHashDualWriteOld = false, want true") } if !cfg.Gateway.OpenAIWS.MetadataBridgeEnabled { t.Fatalf("Gateway.OpenAIWS.MetadataBridgeEnabled = false, want true") } if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds != 3600 { t.Fatalf("Gateway.OpenAIWS.StickyResponseIDTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds) } if cfg.Gateway.OpenAIWS.FallbackCooldownSeconds != 30 { t.Fatalf("Gateway.OpenAIWS.FallbackCooldownSeconds = %d, want 30", cfg.Gateway.OpenAIWS.FallbackCooldownSeconds) } if cfg.Gateway.OpenAIWS.EventFlushBatchSize != 1 { t.Fatalf("Gateway.OpenAIWS.EventFlushBatchSize = %d, want 1", cfg.Gateway.OpenAIWS.EventFlushBatchSize) } if cfg.Gateway.OpenAIWS.EventFlushIntervalMS != 10 { t.Fatalf("Gateway.OpenAIWS.EventFlushIntervalMS = %d, want 10", cfg.Gateway.OpenAIWS.EventFlushIntervalMS) } if cfg.Gateway.OpenAIWS.PrewarmCooldownMS != 300 { t.Fatalf("Gateway.OpenAIWS.PrewarmCooldownMS = %d, want 300", cfg.Gateway.OpenAIWS.PrewarmCooldownMS) } if cfg.Gateway.OpenAIWS.ClientReadLimitBytes != 64*1024*1024 { t.Fatalf("Gateway.OpenAIWS.ClientReadLimitBytes = %d, want %d", cfg.Gateway.OpenAIWS.ClientReadLimitBytes, 64*1024*1024) } if !cfg.Gateway.OpenAIWS.HTTPBridgeEnabled { t.Fatalf("Gateway.OpenAIWS.HTTPBridgeEnabled = false, want true") } if cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes != 15*1024*1024 { t.Fatalf("Gateway.OpenAIWS.HTTPBridgeThresholdBytes = %d, want %d", cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes, 15*1024*1024) } if cfg.Gateway.OpenAIWS.RetryBackoffInitialMS != 120 { t.Fatalf("Gateway.OpenAIWS.RetryBackoffInitialMS = %d, want 120", cfg.Gateway.OpenAIWS.RetryBackoffInitialMS) } if cfg.Gateway.OpenAIWS.RetryBackoffMaxMS != 2000 { t.Fatalf("Gateway.OpenAIWS.RetryBackoffMaxMS = %d, want 2000", cfg.Gateway.OpenAIWS.RetryBackoffMaxMS) } if cfg.Gateway.OpenAIWS.RetryJitterRatio != 0.2 { t.Fatalf("Gateway.OpenAIWS.RetryJitterRatio = %v, want 0.2", cfg.Gateway.OpenAIWS.RetryJitterRatio) } if cfg.Gateway.OpenAIWS.RetryTotalBudgetMS != 5000 { t.Fatalf("Gateway.OpenAIWS.RetryTotalBudgetMS = %d, want 5000", cfg.Gateway.OpenAIWS.RetryTotalBudgetMS) } if cfg.Gateway.OpenAIWS.PayloadLogSampleRate != 0.2 { t.Fatalf("Gateway.OpenAIWS.PayloadLogSampleRate = %v, want 0.2", cfg.Gateway.OpenAIWS.PayloadLogSampleRate) } if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom != 0 { t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom) } if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost != 0 { t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost) } if !cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn { t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true") } if cfg.Gateway.OpenAIWS.StoreDisabledConnMode != "strict" { t.Fatalf("Gateway.OpenAIWS.StoreDisabledConnMode = %q, want %q", cfg.Gateway.OpenAIWS.StoreDisabledConnMode, "strict") } if cfg.Gateway.OpenAIWS.ModeRouterV2Enabled { t.Fatalf("Gateway.OpenAIWS.ModeRouterV2Enabled = true, want false") } if cfg.Gateway.OpenAIWS.IngressModeDefault != "ctx_pool" { t.Fatalf("Gateway.OpenAIWS.IngressModeDefault = %q, want %q", cfg.Gateway.OpenAIWS.IngressModeDefault, "ctx_pool") } if cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds != DefaultOpenAIWSClientFirstMessageTimeoutSeconds { t.Fatalf( "Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = %d, want %d", cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds, DefaultOpenAIWSClientFirstMessageTimeoutSeconds, ) } if cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds != 300 { t.Fatalf("Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = %d, want 300", cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds) } if cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey != 64 { t.Fatalf("Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = %d, want 64", cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey) } } func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_OPENAI_WS_CLIENT_FIRST_MESSAGE_TIMEOUT_SECONDS", "120") cfg, err := Load() require.NoError(t, err) require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds) } func TestLoadDefaultOpenAICompactModel(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel) } func TestLoadOpenAICompactModelFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_OPENAI_COMPACT_MODEL", "gpt-5.3-codex") cfg, err := Load() require.NoError(t, err) require.Equal(t, "gpt-5.3-codex", cfg.Gateway.OpenAICompactModel) } func TestLoadDefaultGrokFreeQuotaSoftGate(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.False(t, cfg.Gateway.Grok.PasswordAuthEnabled) require.True(t, cfg.Gateway.Grok.FreeQuotaSoftGateEnabled) require.Equal(t, int64(500_000), cfg.Gateway.Grok.FreeQuotaTokenLimit) require.Equal(t, 95, cfg.Gateway.Grok.FreeQuotaSoftGatePercent) require.Equal(t, 24, cfg.Gateway.Grok.FreeQuotaWindowHours) require.Equal(t, 60, cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds) } func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.True(t, cfg.Gateway.OpenAIHTTP2.Enabled) require.True(t, cfg.Gateway.OpenAIHTTP2.AllowProxyFallbackToHTTP1) require.False(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled) require.Equal(t, 2, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold) require.Equal(t, 60, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds) require.Equal(t, 600, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds) } func TestLoadOpenAIProxyStreamCircuitFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_DISABLED", "true") t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD", "3") t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS", "90") t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS", "420") cfg, err := Load() require.NoError(t, err) require.True(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled) require.Equal(t, 3, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold) require.Equal(t, 90, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds) require.Equal(t, 420, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds) } func TestLoadOpenAIHTTP2DisabledFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_OPENAI_HTTP2_ENABLED", "false") cfg, err := Load() require.NoError(t, err) require.False(t, cfg.Gateway.OpenAIHTTP2.Enabled) } func TestLoadDefaultOpenAIResponseHeaderTimeoutUnlimited(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.Equal(t, 0, cfg.Gateway.OpenAIResponseHeaderTimeout) } func TestLoadOpenAIResponseHeaderTimeoutFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT", "1800") cfg, err := Load() require.NoError(t, err) require.Equal(t, 1800, cfg.Gateway.OpenAIResponseHeaderTimeout) } func TestLoadImageNonstreamKeepaliveFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL", "15") cfg, err := Load() require.NoError(t, err) require.Equal(t, 15, cfg.Gateway.ImageNonstreamKeepaliveInterval) } func TestLoadOpenAIWSStickyTTLCompatibility(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_OPENAI_WS_STICKY_RESPONSE_ID_TTL_SECONDS", "0") t.Setenv("GATEWAY_OPENAI_WS_STICKY_PREVIOUS_RESPONSE_TTL_SECONDS", "7200") cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds != 7200 { t.Fatalf("StickyResponseIDTTLSeconds = %d, want 7200", cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds) } } func TestLoadDefaultIdempotencyConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if !cfg.Idempotency.ObserveOnly { t.Fatalf("Idempotency.ObserveOnly = false, want true") } if cfg.Idempotency.DefaultTTLSeconds != 86400 { t.Fatalf("Idempotency.DefaultTTLSeconds = %d, want 86400", cfg.Idempotency.DefaultTTLSeconds) } if cfg.Idempotency.SystemOperationTTLSeconds != 3600 { t.Fatalf("Idempotency.SystemOperationTTLSeconds = %d, want 3600", cfg.Idempotency.SystemOperationTTLSeconds) } } func TestLoadDefaultBatchImageQueueDisabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.False(t, cfg.BatchImage.QueueEnabled) } func TestLoadIdempotencyConfigFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("IDEMPOTENCY_OBSERVE_ONLY", "false") t.Setenv("IDEMPOTENCY_DEFAULT_TTL_SECONDS", "600") cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Idempotency.ObserveOnly { t.Fatalf("Idempotency.ObserveOnly = true, want false") } if cfg.Idempotency.DefaultTTLSeconds != 600 { t.Fatalf("Idempotency.DefaultTTLSeconds = %d, want 600", cfg.Idempotency.DefaultTTLSeconds) } } func TestLoadSchedulingConfigFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING", "5") cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Gateway.Scheduling.StickySessionMaxWaiting != 5 { t.Fatalf("StickySessionMaxWaiting = %d, want 5", cfg.Gateway.Scheduling.StickySessionMaxWaiting) } } func TestLoadWeChatConnectConfigFromLegacyEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app") t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret") t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx-mp-app") t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wx-mp-secret") t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/legacy-callback") cfg, err := Load() require.NoError(t, err) require.True(t, cfg.WeChat.Enabled) require.True(t, cfg.WeChat.OpenEnabled) require.True(t, cfg.WeChat.MPEnabled) require.False(t, cfg.WeChat.MobileEnabled) require.Equal(t, "open", cfg.WeChat.Mode) require.Equal(t, "wx-open-app", cfg.WeChat.OpenAppID) require.Equal(t, "wx-open-secret", cfg.WeChat.OpenAppSecret) require.Equal(t, "wx-mp-app", cfg.WeChat.MPAppID) require.Equal(t, "wx-mp-secret", cfg.WeChat.MPAppSecret) require.Equal(t, "/auth/wechat/legacy-callback", cfg.WeChat.FrontendRedirectURL) } func TestLoadDefaultOIDCSecurityDefaults(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) require.True(t, cfg.OIDC.UsePKCE) require.True(t, cfg.OIDC.ValidateIDToken) require.False(t, cfg.OIDC.UsePKCEExplicit) require.False(t, cfg.OIDC.ValidateIDTokenExplicit) } func TestLoadExplicitOIDCSecurityDefaultsFromEnvMarksFlagsExplicit(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("OIDC_CONNECT_USE_PKCE", "false") t.Setenv("OIDC_CONNECT_VALIDATE_ID_TOKEN", "false") cfg, err := Load() require.NoError(t, err) require.False(t, cfg.OIDC.UsePKCE) require.False(t, cfg.OIDC.ValidateIDToken) require.True(t, cfg.OIDC.UsePKCEExplicit) require.True(t, cfg.OIDC.ValidateIDTokenExplicit) } func TestLoadForcedCodexInstructionsTemplate(t *testing.T) { resetViperWithJWTSecret(t) tempDir := t.TempDir() templatePath := filepath.Join(tempDir, "codex-instructions.md.tmpl") configPath := filepath.Join(tempDir, "config.yaml") require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644)) yamlSafePath := filepath.ToSlash(templatePath) require.NoError(t, os.WriteFile(configPath, []byte("gateway:\n forced_codex_instructions_template_file: \""+yamlSafePath+"\"\n"), 0o644)) t.Setenv("DATA_DIR", tempDir) cfg, err := Load() require.NoError(t, err) require.Equal(t, yamlSafePath, cfg.Gateway.ForcedCodexInstructionsTemplateFile) require.Equal(t, "server-prefix\n\n{{ .ExistingInstructions }}", cfg.Gateway.ForcedCodexInstructionsTemplate) } func TestLoadDefaultSecurityToggles(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Security.URLAllowlist.Enabled { t.Fatalf("URLAllowlist.Enabled = true, want false") } if !cfg.Security.URLAllowlist.AllowInsecureHTTP { t.Fatalf("URLAllowlist.AllowInsecureHTTP = false, want true") } if !cfg.Security.URLAllowlist.AllowPrivateHosts { t.Fatalf("URLAllowlist.AllowPrivateHosts = false, want true") } if !cfg.Security.ResponseHeaders.Enabled { t.Fatalf("ResponseHeaders.Enabled = false, want true") } wantHosts := []string{ "api.kimi.com", "api.moonshot.ai", "api.moonshot.cn", } hostSet := make(map[string]struct{}, len(cfg.Security.URLAllowlist.UpstreamHosts)) for _, h := range cfg.Security.URLAllowlist.UpstreamHosts { hostSet[h] = struct{}{} } for _, want := range wantHosts { if _, ok := hostSet[want]; !ok { t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts) } } } func TestLoadDefaultServerMode(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Server.Mode != "release" { t.Fatalf("Server.Mode = %q, want %q", cfg.Server.Mode, "release") } } func TestLoadDefaultJWTAccessTokenExpireMinutes(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.JWT.ExpireHour != 24 { t.Fatalf("JWT.ExpireHour = %d, want 24", cfg.JWT.ExpireHour) } if cfg.JWT.AccessTokenExpireMinutes != 0 { t.Fatalf("JWT.AccessTokenExpireMinutes = %d, want 0", cfg.JWT.AccessTokenExpireMinutes) } } func TestLoadJWTAccessTokenExpireMinutesFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "90") cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.JWT.AccessTokenExpireMinutes != 90 { t.Fatalf("JWT.AccessTokenExpireMinutes = %d, want 90", cfg.JWT.AccessTokenExpireMinutes) } } func TestLoadDefaultDatabaseSSLMode(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Database.SSLMode != "prefer" { t.Fatalf("Database.SSLMode = %q, want %q", cfg.Database.SSLMode, "prefer") } } func TestValidateLinuxDoFrontendRedirectURL(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.LinuxDo.Enabled = true cfg.LinuxDo.ClientID = "test-client" cfg.LinuxDo.ClientSecret = "test-secret" cfg.LinuxDo.RedirectURL = "https://example.com/api/v1/auth/oauth/linuxdo/callback" cfg.LinuxDo.TokenAuthMethod = "client_secret_post" cfg.LinuxDo.UsePKCE = true cfg.LinuxDo.FrontendRedirectURL = "javascript:alert(1)" err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for javascript scheme, got nil") } if !strings.Contains(err.Error(), "linuxdo_connect.frontend_redirect_url") { t.Fatalf("Validate() expected frontend_redirect_url error, got: %v", err) } } func TestValidateLinuxDoAllowsDisablingPKCEForCompatibility(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.LinuxDo.Enabled = true cfg.LinuxDo.ClientID = "test-client" cfg.LinuxDo.ClientSecret = "" cfg.LinuxDo.RedirectURL = "https://example.com/api/v1/auth/oauth/linuxdo/callback" cfg.LinuxDo.FrontendRedirectURL = "/auth/linuxdo/callback" cfg.LinuxDo.TokenAuthMethod = "none" cfg.LinuxDo.UsePKCE = false err = cfg.Validate() if err != nil { t.Fatalf("Validate() expected LinuxDo config without PKCE to pass for compatibility, got: %v", err) } } func TestValidateOIDCScopesMustContainOpenID(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.OIDC.Enabled = true cfg.OIDC.ClientID = "oidc-client" cfg.OIDC.ClientSecret = "oidc-secret" cfg.OIDC.IssuerURL = "https://issuer.example.com" cfg.OIDC.AuthorizeURL = "https://issuer.example.com/auth" cfg.OIDC.TokenURL = "https://issuer.example.com/token" cfg.OIDC.JWKSURL = "https://issuer.example.com/jwks" cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback" cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback" cfg.OIDC.Scopes = "profile email" cfg.OIDC.UsePKCE = true err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error when scopes do not include openid, got nil") } if !strings.Contains(err.Error(), "oidc_connect.scopes") { t.Fatalf("Validate() expected oidc_connect.scopes error, got: %v", err) } } func TestValidateOIDCAllowsIssuerOnlyEndpointsWithDiscoveryFallback(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.OIDC.Enabled = true cfg.OIDC.ClientID = "oidc-client" cfg.OIDC.ClientSecret = "oidc-secret" cfg.OIDC.IssuerURL = "https://issuer.example.com" cfg.OIDC.AuthorizeURL = "" cfg.OIDC.TokenURL = "" cfg.OIDC.JWKSURL = "" cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback" cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback" cfg.OIDC.Scopes = "openid email profile" cfg.OIDC.ValidateIDToken = true cfg.OIDC.UsePKCE = true err = cfg.Validate() if err != nil { t.Fatalf("Validate() expected issuer-only OIDC config to pass with discovery fallback, got: %v", err) } } func TestValidateOIDCAllowsExplicitCompatibilityOverridesForPKCEAndIDTokenValidation(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.OIDC.Enabled = true cfg.OIDC.ClientID = "oidc-client" cfg.OIDC.ClientSecret = "oidc-secret" cfg.OIDC.IssuerURL = "https://issuer.example.com" cfg.OIDC.AuthorizeURL = "https://issuer.example.com/auth" cfg.OIDC.TokenURL = "https://issuer.example.com/token" cfg.OIDC.UserInfoURL = "https://issuer.example.com/userinfo" cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback" cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback" cfg.OIDC.Scopes = "openid email profile" cfg.OIDC.UsePKCE = false cfg.OIDC.ValidateIDToken = false cfg.OIDC.JWKSURL = "" cfg.OIDC.AllowedSigningAlgs = "" err = cfg.Validate() if err != nil { t.Fatalf("Validate() expected OIDC config without PKCE/id_token validation to pass for compatibility, got: %v", err) } } func TestLoadDefaultDashboardCacheConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if !cfg.Dashboard.Enabled { t.Fatalf("Dashboard.Enabled = false, want true") } if cfg.Dashboard.KeyPrefix != "sub2api:" { t.Fatalf("Dashboard.KeyPrefix = %q, want %q", cfg.Dashboard.KeyPrefix, "sub2api:") } if cfg.Dashboard.StatsFreshTTLSeconds != 15 { t.Fatalf("Dashboard.StatsFreshTTLSeconds = %d, want 15", cfg.Dashboard.StatsFreshTTLSeconds) } if cfg.Dashboard.StatsTTLSeconds != 30 { t.Fatalf("Dashboard.StatsTTLSeconds = %d, want 30", cfg.Dashboard.StatsTTLSeconds) } if cfg.Dashboard.StatsRefreshTimeoutSeconds != 30 { t.Fatalf("Dashboard.StatsRefreshTimeoutSeconds = %d, want 30", cfg.Dashboard.StatsRefreshTimeoutSeconds) } } func TestValidateDashboardCacheConfigEnabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.Dashboard.Enabled = true cfg.Dashboard.StatsFreshTTLSeconds = 10 cfg.Dashboard.StatsTTLSeconds = 5 err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for stats_fresh_ttl_seconds > stats_ttl_seconds, got nil") } if !strings.Contains(err.Error(), "dashboard_cache.stats_fresh_ttl_seconds") { t.Fatalf("Validate() expected stats_fresh_ttl_seconds error, got: %v", err) } } func TestValidateDashboardCacheConfigDisabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.Dashboard.Enabled = false cfg.Dashboard.StatsTTLSeconds = -1 err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for negative stats_ttl_seconds, got nil") } if !strings.Contains(err.Error(), "dashboard_cache.stats_ttl_seconds") { t.Fatalf("Validate() expected stats_ttl_seconds error, got: %v", err) } } func TestLoadDefaultDashboardAggregationConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if !cfg.DashboardAgg.Enabled { t.Fatalf("DashboardAgg.Enabled = false, want true") } if cfg.DashboardAgg.IntervalSeconds != 60 { t.Fatalf("DashboardAgg.IntervalSeconds = %d, want 60", cfg.DashboardAgg.IntervalSeconds) } if cfg.DashboardAgg.LookbackSeconds != 120 { t.Fatalf("DashboardAgg.LookbackSeconds = %d, want 120", cfg.DashboardAgg.LookbackSeconds) } if cfg.DashboardAgg.BackfillEnabled { t.Fatalf("DashboardAgg.BackfillEnabled = true, want false") } if cfg.DashboardAgg.BackfillMaxDays != 31 { t.Fatalf("DashboardAgg.BackfillMaxDays = %d, want 31", cfg.DashboardAgg.BackfillMaxDays) } if cfg.DashboardAgg.Retention.UsageLogsDays != 90 { t.Fatalf("DashboardAgg.Retention.UsageLogsDays = %d, want 90", cfg.DashboardAgg.Retention.UsageLogsDays) } if cfg.DashboardAgg.Retention.UsageBillingDedupDays != 365 { t.Fatalf("DashboardAgg.Retention.UsageBillingDedupDays = %d, want 365", cfg.DashboardAgg.Retention.UsageBillingDedupDays) } if cfg.DashboardAgg.Retention.HourlyDays != 180 { t.Fatalf("DashboardAgg.Retention.HourlyDays = %d, want 180", cfg.DashboardAgg.Retention.HourlyDays) } if cfg.DashboardAgg.Retention.DailyDays != 730 { t.Fatalf("DashboardAgg.Retention.DailyDays = %d, want 730", cfg.DashboardAgg.Retention.DailyDays) } if cfg.DashboardAgg.RecomputeDays != 2 { t.Fatalf("DashboardAgg.RecomputeDays = %d, want 2", cfg.DashboardAgg.RecomputeDays) } } func TestValidateDashboardAggregationConfigDisabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.DashboardAgg.Enabled = false cfg.DashboardAgg.IntervalSeconds = -1 err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for negative dashboard_aggregation.interval_seconds, got nil") } if !strings.Contains(err.Error(), "dashboard_aggregation.interval_seconds") { t.Fatalf("Validate() expected interval_seconds error, got: %v", err) } } func TestValidateDashboardAggregationBackfillMaxDays(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.DashboardAgg.BackfillEnabled = true cfg.DashboardAgg.BackfillMaxDays = 0 err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for dashboard_aggregation.backfill_max_days, got nil") } if !strings.Contains(err.Error(), "dashboard_aggregation.backfill_max_days") { t.Fatalf("Validate() expected backfill_max_days error, got: %v", err) } } func TestLoadDefaultUsageCleanupConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if !cfg.UsageCleanup.Enabled { t.Fatalf("UsageCleanup.Enabled = false, want true") } if cfg.UsageCleanup.MaxRangeDays != 31 { t.Fatalf("UsageCleanup.MaxRangeDays = %d, want 31", cfg.UsageCleanup.MaxRangeDays) } if cfg.UsageCleanup.BatchSize != 5000 { t.Fatalf("UsageCleanup.BatchSize = %d, want 5000", cfg.UsageCleanup.BatchSize) } if cfg.UsageCleanup.WorkerIntervalSeconds != 10 { t.Fatalf("UsageCleanup.WorkerIntervalSeconds = %d, want 10", cfg.UsageCleanup.WorkerIntervalSeconds) } if cfg.UsageCleanup.TaskTimeoutSeconds != 1800 { t.Fatalf("UsageCleanup.TaskTimeoutSeconds = %d, want 1800", cfg.UsageCleanup.TaskTimeoutSeconds) } } func TestValidateUsageCleanupConfigEnabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.UsageCleanup.Enabled = true cfg.UsageCleanup.MaxRangeDays = 0 err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for usage_cleanup.max_range_days, got nil") } if !strings.Contains(err.Error(), "usage_cleanup.max_range_days") { t.Fatalf("Validate() expected max_range_days error, got: %v", err) } } func TestValidateUsageCleanupConfigDisabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.UsageCleanup.Enabled = false cfg.UsageCleanup.BatchSize = -1 err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for usage_cleanup.batch_size, got nil") } if !strings.Contains(err.Error(), "usage_cleanup.batch_size") { t.Fatalf("Validate() expected batch_size error, got: %v", err) } } func TestConfigAddressHelpers(t *testing.T) { server := ServerConfig{Host: "127.0.0.1", Port: 9000} if server.Address() != "127.0.0.1:9000" { t.Fatalf("ServerConfig.Address() = %q", server.Address()) } dbCfg := DatabaseConfig{ Host: "localhost", Port: 5432, User: "postgres", Password: "", DBName: "sub2api", SSLMode: "disable", } if !strings.Contains(dbCfg.DSN(), "password=") { } else { t.Fatalf("DatabaseConfig.DSN() should not include password when empty") } dbCfg.Password = "secret" if !strings.Contains(dbCfg.DSN(), "password=secret") { t.Fatalf("DatabaseConfig.DSN() missing password") } dbCfg.Password = "" if strings.Contains(dbCfg.DSNWithTimezone("UTC"), "password=") { t.Fatalf("DatabaseConfig.DSNWithTimezone() should omit password when empty") } if !strings.Contains(dbCfg.DSNWithTimezone(""), "TimeZone=Asia/Shanghai") { t.Fatalf("DatabaseConfig.DSNWithTimezone() should use default timezone") } if !strings.Contains(dbCfg.DSNWithTimezone("UTC"), "TimeZone=UTC") { t.Fatalf("DatabaseConfig.DSNWithTimezone() should use provided timezone") } redis := RedisConfig{Host: "redis", Port: 6379} if redis.Address() != "redis:6379" { t.Fatalf("RedisConfig.Address() = %q", redis.Address()) } } func TestNormalizeStringSlice(t *testing.T) { values := normalizeStringSlice([]string{" a ", "", "b", " ", "c"}) if len(values) != 3 || values[0] != "a" || values[1] != "b" || values[2] != "c" { t.Fatalf("normalizeStringSlice() unexpected result: %#v", values) } if normalizeStringSlice(nil) != nil { t.Fatalf("normalizeStringSlice(nil) expected nil slice") } } func TestGetServerAddressFromEnv(t *testing.T) { t.Setenv("CONFIG_FILE", "") t.Setenv("DATA_DIR", "") t.Setenv("SERVER_HOST", "127.0.0.1") t.Setenv("SERVER_PORT", "9090") address := GetServerAddress() if address != "127.0.0.1:9090" { t.Fatalf("GetServerAddress() = %q", address) } } func TestGetServerAddressFromConfigFile(t *testing.T) { configFile := filepath.Join(t.TempDir(), "setup.yaml") require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.40\n port: 9191\n"), 0o600)) t.Setenv("CONFIG_FILE", configFile) t.Setenv("DATA_DIR", "") t.Setenv("SERVER_HOST", "") t.Setenv("SERVER_PORT", "") require.Equal(t, "192.0.2.40:9191", GetServerAddress()) } func TestValidateAbsoluteHTTPURL(t *testing.T) { if err := ValidateAbsoluteHTTPURL("https://example.com/path"); err != nil { t.Fatalf("ValidateAbsoluteHTTPURL valid url error: %v", err) } if err := ValidateAbsoluteHTTPURL(""); err == nil { t.Fatalf("ValidateAbsoluteHTTPURL should reject empty url") } if err := ValidateAbsoluteHTTPURL("/relative"); err == nil { t.Fatalf("ValidateAbsoluteHTTPURL should reject relative url") } if err := ValidateAbsoluteHTTPURL("ftp://example.com"); err == nil { t.Fatalf("ValidateAbsoluteHTTPURL should reject ftp scheme") } if err := ValidateAbsoluteHTTPURL("https://example.com/#frag"); err == nil { t.Fatalf("ValidateAbsoluteHTTPURL should reject fragment") } } func TestValidateServerFrontendURL(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.Server.FrontendURL = "https://example.com" if err := cfg.Validate(); err != nil { t.Fatalf("Validate() frontend_url valid error: %v", err) } cfg.Server.FrontendURL = "https://example.com/path" if err := cfg.Validate(); err != nil { t.Fatalf("Validate() frontend_url with path valid error: %v", err) } cfg.Server.FrontendURL = "https://example.com?utm=1" if err := cfg.Validate(); err == nil { t.Fatalf("Validate() should reject server.frontend_url with query") } cfg.Server.FrontendURL = "https://user:pass@example.com" if err := cfg.Validate(); err == nil { t.Fatalf("Validate() should reject server.frontend_url with userinfo") } cfg.Server.FrontendURL = "/relative" if err := cfg.Validate(); err == nil { t.Fatalf("Validate() should reject relative server.frontend_url") } } func TestValidateFrontendRedirectURL(t *testing.T) { if err := ValidateFrontendRedirectURL("/auth/callback"); err != nil { t.Fatalf("ValidateFrontendRedirectURL relative error: %v", err) } if err := ValidateFrontendRedirectURL("https://example.com/auth"); err != nil { t.Fatalf("ValidateFrontendRedirectURL absolute error: %v", err) } if err := ValidateFrontendRedirectURL("example.com/path"); err == nil { t.Fatalf("ValidateFrontendRedirectURL should reject non-absolute url") } if err := ValidateFrontendRedirectURL("//evil.com"); err == nil { t.Fatalf("ValidateFrontendRedirectURL should reject // prefix") } if err := ValidateFrontendRedirectURL("javascript:alert(1)"); err == nil { t.Fatalf("ValidateFrontendRedirectURL should reject javascript scheme") } } func TestWarnIfInsecureURL(t *testing.T) { warnIfInsecureURL("test", "http://example.com") warnIfInsecureURL("test", "bad://url") warnIfInsecureURL("test", "://invalid") } func TestGenerateJWTSecretDefaultLength(t *testing.T) { secret, err := generateJWTSecret(0) if err != nil { t.Fatalf("generateJWTSecret error: %v", err) } if len(secret) == 0 { t.Fatalf("generateJWTSecret returned empty string") } } func TestValidateOpsCleanupScheduleRequired(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.Ops.Cleanup.Enabled = true cfg.Ops.Cleanup.Schedule = "" err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for ops.cleanup.schedule") } if !strings.Contains(err.Error(), "ops.cleanup.schedule") { t.Fatalf("Validate() expected ops.cleanup.schedule error, got: %v", err) } } func TestValidateConcurrencyPingInterval(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.Concurrency.PingInterval = 3 err = cfg.Validate() if err == nil { t.Fatalf("Validate() expected error for concurrency.ping_interval") } if !strings.Contains(err.Error(), "concurrency.ping_interval") { t.Fatalf("Validate() expected concurrency.ping_interval error, got: %v", err) } } func TestProvideConfig(t *testing.T) { resetViperWithJWTSecret(t) if _, err := ProvideConfig(); err != nil { t.Fatalf("ProvideConfig() error: %v", err) } } func TestValidateConfigWithLinuxDoEnabled(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.Security.CSP.Enabled = true cfg.Security.CSP.Policy = "default-src 'self'" cfg.LinuxDo.Enabled = true cfg.LinuxDo.ClientID = "client" cfg.LinuxDo.ClientSecret = "secret" cfg.LinuxDo.AuthorizeURL = "https://example.com/oauth2/authorize" cfg.LinuxDo.TokenURL = "https://example.com/oauth2/token" cfg.LinuxDo.UserInfoURL = "https://example.com/oauth2/userinfo" cfg.LinuxDo.RedirectURL = "https://example.com/api/v1/auth/oauth/linuxdo/callback" cfg.LinuxDo.FrontendRedirectURL = "/auth/linuxdo/callback" cfg.LinuxDo.TokenAuthMethod = "client_secret_post" cfg.LinuxDo.UsePKCE = true if err := cfg.Validate(); err != nil { t.Fatalf("Validate() unexpected error: %v", err) } } func TestValidateJWTSecretStrength(t *testing.T) { if !isWeakJWTSecret("change-me-in-production") { t.Fatalf("isWeakJWTSecret should detect weak secret") } if isWeakJWTSecret("StrongSecretValue") { t.Fatalf("isWeakJWTSecret should accept strong secret") } } func TestGenerateJWTSecretWithLength(t *testing.T) { secret, err := generateJWTSecret(16) if err != nil { t.Fatalf("generateJWTSecret error: %v", err) } if len(secret) == 0 { t.Fatalf("generateJWTSecret returned empty string") } } func TestDatabaseDSNWithTimezone_WithPassword(t *testing.T) { d := &DatabaseConfig{ Host: "localhost", Port: 5432, User: "u", Password: "p", DBName: "db", SSLMode: "prefer", } got := d.DSNWithTimezone("UTC") if !strings.Contains(got, "password=p") { t.Fatalf("DSNWithTimezone should include password: %q", got) } if !strings.Contains(got, "TimeZone=UTC") { t.Fatalf("DSNWithTimezone should include TimeZone=UTC: %q", got) } } func TestValidateAbsoluteHTTPURLMissingHost(t *testing.T) { if err := ValidateAbsoluteHTTPURL("https://"); err == nil { t.Fatalf("ValidateAbsoluteHTTPURL should reject missing host") } } func TestValidateFrontendRedirectURLInvalidChars(t *testing.T) { if err := ValidateFrontendRedirectURL("/auth/\ncallback"); err == nil { t.Fatalf("ValidateFrontendRedirectURL should reject invalid chars") } if err := ValidateFrontendRedirectURL("http://"); err == nil { t.Fatalf("ValidateFrontendRedirectURL should reject missing host") } if err := ValidateFrontendRedirectURL("mailto:user@example.com"); err == nil { t.Fatalf("ValidateFrontendRedirectURL should reject mailto") } } func TestWarnIfInsecureURLHTTPS(t *testing.T) { warnIfInsecureURL("secure", "https://example.com") } func TestValidateJWTSecret_UTF8Bytes(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } // 31 bytes (< 32) even though it's 31 characters. cfg.JWT.Secret = strings.Repeat("a", 31) err = cfg.Validate() if err == nil { t.Fatalf("Validate() should reject 31-byte secret") } if !strings.Contains(err.Error(), "at least 32 bytes") { t.Fatalf("Validate() error = %v", err) } // 32 bytes OK. cfg.JWT.Secret = strings.Repeat("a", 32) err = cfg.Validate() if err != nil { t.Fatalf("Validate() should accept 32-byte secret: %v", err) } } func TestValidateConfigErrors(t *testing.T) { buildValid := func(t *testing.T) *Config { t.Helper() resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } return cfg } cases := []struct { name string mutate func(*Config) wantErr string }{ { name: "server read header timeout", mutate: func(c *Config) { c.Server.ReadHeaderTimeout = 0 }, wantErr: "server.read_header_timeout", }, { name: "server max header bytes too small", mutate: func(c *Config) { c.Server.MaxHeaderBytes = 4096 }, wantErr: "server.max_header_bytes", }, { name: "server max request body size", mutate: func(c *Config) { c.Server.MaxRequestBodySize = -1 }, wantErr: "server.max_request_body_size", }, { name: "h2c zero concurrent streams", mutate: func(c *Config) { c.Server.H2C.Enabled = true c.Server.H2C.MaxConcurrentStreams = 0 }, wantErr: "server.h2c.max_concurrent_streams", }, { name: "h2c oversized read frame", mutate: func(c *Config) { c.Server.H2C.Enabled = true c.Server.H2C.MaxReadFrameSize = 16 * 1024 * 1024 }, wantErr: "server.h2c.max_read_frame_size", }, { name: "invalid auth abuse threshold too small", mutate: func(c *Config) { c.APIKeyAuth.InvalidAbuse.Threshold = 9 }, wantErr: "api_key_auth_cache.invalid_abuse.threshold", }, { name: "invalid auth abuse capacity too small", mutate: func(c *Config) { c.APIKeyAuth.InvalidAbuse.Capacity = 255 }, wantErr: "api_key_auth_cache.invalid_abuse.capacity", }, { name: "jwt secret required", mutate: func(c *Config) { c.JWT.Secret = "" }, wantErr: "jwt.secret is required", }, { name: "jwt secret min bytes", mutate: func(c *Config) { c.JWT.Secret = strings.Repeat("a", 31) }, wantErr: "jwt.secret must be at least 32 bytes", }, { name: "subscription maintenance worker_count non-negative", mutate: func(c *Config) { c.SubscriptionMaintenance.WorkerCount = -1 }, wantErr: "subscription_maintenance.worker_count", }, { name: "subscription maintenance queue_size non-negative", mutate: func(c *Config) { c.SubscriptionMaintenance.QueueSize = -1 }, wantErr: "subscription_maintenance.queue_size", }, { name: "jwt expire hour positive", mutate: func(c *Config) { c.JWT.ExpireHour = 0 }, wantErr: "jwt.expire_hour must be positive", }, { name: "jwt expire hour max", mutate: func(c *Config) { c.JWT.ExpireHour = 200 }, wantErr: "jwt.expire_hour must be <= 168", }, { name: "jwt access token expire minutes non-negative", mutate: func(c *Config) { c.JWT.AccessTokenExpireMinutes = -1 }, wantErr: "jwt.access_token_expire_minutes must be non-negative", }, { name: "csp policy required", mutate: func(c *Config) { c.Security.CSP.Enabled = true; c.Security.CSP.Policy = "" }, wantErr: "security.csp.policy", }, { name: "linuxdo client id required", mutate: func(c *Config) { c.LinuxDo.Enabled = true c.LinuxDo.UsePKCE = true c.LinuxDo.ClientID = "" }, wantErr: "linuxdo_connect.client_id", }, { name: "linuxdo token auth method", mutate: func(c *Config) { c.LinuxDo.Enabled = true c.LinuxDo.UsePKCE = true c.LinuxDo.ClientID = "client" c.LinuxDo.ClientSecret = "secret" c.LinuxDo.AuthorizeURL = "https://example.com/authorize" c.LinuxDo.TokenURL = "https://example.com/token" c.LinuxDo.UserInfoURL = "https://example.com/userinfo" c.LinuxDo.RedirectURL = "https://example.com/callback" c.LinuxDo.FrontendRedirectURL = "/auth/callback" c.LinuxDo.TokenAuthMethod = "invalid" }, wantErr: "linuxdo_connect.token_auth_method", }, { name: "billing circuit breaker threshold", mutate: func(c *Config) { c.Billing.CircuitBreaker.FailureThreshold = 0 }, wantErr: "billing.circuit_breaker.failure_threshold", }, { name: "billing circuit breaker reset", mutate: func(c *Config) { c.Billing.CircuitBreaker.ResetTimeoutSeconds = 0 }, wantErr: "billing.circuit_breaker.reset_timeout_seconds", }, { name: "billing circuit breaker half open", mutate: func(c *Config) { c.Billing.CircuitBreaker.HalfOpenRequests = 0 }, wantErr: "billing.circuit_breaker.half_open_requests", }, { name: "billing minimum balance reserve", mutate: func(c *Config) { c.Billing.MinimumBalanceReserve = -0.01 }, wantErr: "billing.minimum_balance_reserve", }, { name: "database max open conns", mutate: func(c *Config) { c.Database.MaxOpenConns = 0 }, wantErr: "database.max_open_conns", }, { name: "database max lifetime", mutate: func(c *Config) { c.Database.ConnMaxLifetimeMinutes = -1 }, wantErr: "database.conn_max_lifetime_minutes", }, { name: "database idle exceeds open", mutate: func(c *Config) { c.Database.MaxIdleConns = c.Database.MaxOpenConns + 1 }, wantErr: "database.max_idle_conns cannot exceed", }, { name: "redis dial timeout", mutate: func(c *Config) { c.Redis.DialTimeoutSeconds = 0 }, wantErr: "redis.dial_timeout_seconds", }, { name: "redis read timeout", mutate: func(c *Config) { c.Redis.ReadTimeoutSeconds = 0 }, wantErr: "redis.read_timeout_seconds", }, { name: "redis write timeout", mutate: func(c *Config) { c.Redis.WriteTimeoutSeconds = 0 }, wantErr: "redis.write_timeout_seconds", }, { name: "redis pool size", mutate: func(c *Config) { c.Redis.PoolSize = 0 }, wantErr: "redis.pool_size", }, { name: "redis idle exceeds pool", mutate: func(c *Config) { c.Redis.MinIdleConns = c.Redis.PoolSize + 1 }, wantErr: "redis.min_idle_conns cannot exceed", }, { name: "dashboard cache disabled negative", mutate: func(c *Config) { c.Dashboard.Enabled = false; c.Dashboard.StatsTTLSeconds = -1 }, wantErr: "dashboard_cache.stats_ttl_seconds", }, { name: "dashboard cache fresh ttl positive", mutate: func(c *Config) { c.Dashboard.Enabled = true; c.Dashboard.StatsFreshTTLSeconds = 0 }, wantErr: "dashboard_cache.stats_fresh_ttl_seconds", }, { name: "dashboard aggregation enabled interval", mutate: func(c *Config) { c.DashboardAgg.Enabled = true; c.DashboardAgg.IntervalSeconds = 0 }, wantErr: "dashboard_aggregation.interval_seconds", }, { name: "dashboard aggregation backfill positive", mutate: func(c *Config) { c.DashboardAgg.Enabled = true c.DashboardAgg.BackfillEnabled = true c.DashboardAgg.BackfillMaxDays = 0 }, wantErr: "dashboard_aggregation.backfill_max_days", }, { name: "dashboard aggregation retention", mutate: func(c *Config) { c.DashboardAgg.Enabled = true; c.DashboardAgg.Retention.UsageLogsDays = 0 }, wantErr: "dashboard_aggregation.retention.usage_logs_days", }, { name: "dashboard aggregation dedup retention", mutate: func(c *Config) { c.DashboardAgg.Enabled = true c.DashboardAgg.Retention.UsageBillingDedupDays = 0 }, wantErr: "dashboard_aggregation.retention.usage_billing_dedup_days", }, { name: "dashboard aggregation dedup retention smaller than usage logs", mutate: func(c *Config) { c.DashboardAgg.Enabled = true c.DashboardAgg.Retention.UsageLogsDays = 30 c.DashboardAgg.Retention.UsageBillingDedupDays = 29 }, wantErr: "dashboard_aggregation.retention.usage_billing_dedup_days", }, { name: "dashboard aggregation disabled interval", mutate: func(c *Config) { c.DashboardAgg.Enabled = false; c.DashboardAgg.IntervalSeconds = -1 }, wantErr: "dashboard_aggregation.interval_seconds", }, { name: "usage cleanup max range", mutate: func(c *Config) { c.UsageCleanup.Enabled = true; c.UsageCleanup.MaxRangeDays = 0 }, wantErr: "usage_cleanup.max_range_days", }, { name: "usage cleanup worker interval", mutate: func(c *Config) { c.UsageCleanup.Enabled = true; c.UsageCleanup.WorkerIntervalSeconds = 0 }, wantErr: "usage_cleanup.worker_interval_seconds", }, { name: "usage cleanup batch size", mutate: func(c *Config) { c.UsageCleanup.Enabled = true; c.UsageCleanup.BatchSize = 0 }, wantErr: "usage_cleanup.batch_size", }, { name: "usage cleanup disabled negative", mutate: func(c *Config) { c.UsageCleanup.Enabled = false; c.UsageCleanup.BatchSize = -1 }, wantErr: "usage_cleanup.batch_size", }, { name: "gateway max body size", mutate: func(c *Config) { c.Gateway.MaxBodySize = 0 }, wantErr: "gateway.max_body_size", }, { name: "gateway text body exceeds media body", mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 }, wantErr: "gateway.text_max_body_size", }, { name: "gateway response header timeout", mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 }, wantErr: "gateway.response_header_timeout", }, { name: "gateway openai response header timeout", mutate: func(c *Config) { c.Gateway.OpenAIResponseHeaderTimeout = -1 }, wantErr: "gateway.openai_response_header_timeout", }, { name: "gateway openai first output timeout below minimum", mutate: func(c *Config) { c.Gateway.OpenAIFirstOutputTimeoutSeconds = 29 }, wantErr: "gateway.openai_first_output_timeout_seconds", }, { name: "gateway openai high effort first output timeout too large", mutate: func(c *Config) { c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds = 1801 }, wantErr: "gateway.openai_high_effort_first_output_timeout_seconds", }, { name: "gateway max idle conns", mutate: func(c *Config) { c.Gateway.MaxIdleConns = 0 }, wantErr: "gateway.max_idle_conns", }, { name: "gateway max idle conns per host", mutate: func(c *Config) { c.Gateway.MaxIdleConnsPerHost = 0 }, wantErr: "gateway.max_idle_conns_per_host", }, { name: "gateway idle timeout", mutate: func(c *Config) { c.Gateway.IdleConnTimeoutSeconds = 0 }, wantErr: "gateway.idle_conn_timeout_seconds", }, { name: "gateway max upstream clients", mutate: func(c *Config) { c.Gateway.MaxUpstreamClients = 0 }, wantErr: "gateway.max_upstream_clients", }, { name: "gateway client idle ttl", mutate: func(c *Config) { c.Gateway.ClientIdleTTLSeconds = 0 }, wantErr: "gateway.client_idle_ttl_seconds", }, { name: "gateway concurrency slot ttl", mutate: func(c *Config) { c.Gateway.ConcurrencySlotTTLMinutes = 0 }, wantErr: "gateway.concurrency_slot_ttl_minutes", }, { name: "gateway max conns per host", mutate: func(c *Config) { c.Gateway.MaxConnsPerHost = -1 }, wantErr: "gateway.max_conns_per_host", }, { name: "gateway connection isolation", mutate: func(c *Config) { c.Gateway.ConnectionPoolIsolation = "invalid" }, wantErr: "gateway.connection_pool_isolation", }, { name: "gateway stream keepalive range", mutate: func(c *Config) { c.Gateway.StreamKeepaliveInterval = 4 }, wantErr: "gateway.stream_keepalive_interval", }, { name: "gateway openai ws oauth max conns factor", mutate: func(c *Config) { c.Gateway.OpenAIWS.OAuthMaxConnsFactor = 0 }, wantErr: "gateway.openai_ws.oauth_max_conns_factor", }, { name: "gateway openai ws apikey max conns factor", mutate: func(c *Config) { c.Gateway.OpenAIWS.APIKeyMaxConnsFactor = 0 }, wantErr: "gateway.openai_ws.apikey_max_conns_factor", }, { name: "gateway openai http2 fallback threshold", mutate: func(c *Config) { c.Gateway.OpenAIHTTP2.FallbackErrorThreshold = -1 }, wantErr: "gateway.openai_http2.fallback_error_threshold", }, { name: "gateway openai http2 fallback window", mutate: func(c *Config) { c.Gateway.OpenAIHTTP2.FallbackWindowSeconds = -1 }, wantErr: "gateway.openai_http2.fallback_window_seconds", }, { name: "gateway openai http2 fallback ttl", mutate: func(c *Config) { c.Gateway.OpenAIHTTP2.FallbackTTLSeconds = -1 }, wantErr: "gateway.openai_http2.fallback_ttl_seconds", }, { name: "gateway openai proxy stream circuit threshold", mutate: func(c *Config) { c.Gateway.OpenAIProxyStreamCircuit.FailureThreshold = -1 }, wantErr: "gateway.openai_proxy_stream_circuit.failure_threshold", }, { name: "gateway openai proxy stream circuit window", mutate: func(c *Config) { c.Gateway.OpenAIProxyStreamCircuit.WindowSeconds = -1 }, wantErr: "gateway.openai_proxy_stream_circuit.window_seconds", }, { name: "gateway openai proxy stream circuit ttl", mutate: func(c *Config) { c.Gateway.OpenAIProxyStreamCircuit.TTLSeconds = -1 }, wantErr: "gateway.openai_proxy_stream_circuit.ttl_seconds", }, { name: "gateway stream data interval range", mutate: func(c *Config) { c.Gateway.StreamDataIntervalTimeout = 5 }, wantErr: "gateway.stream_data_interval_timeout", }, { name: "gateway stream data interval negative", mutate: func(c *Config) { c.Gateway.StreamDataIntervalTimeout = -1 }, wantErr: "gateway.stream_data_interval_timeout must be non-negative", }, { name: "gateway image stream keepalive range", mutate: func(c *Config) { c.Gateway.ImageStreamKeepaliveInterval = 4 }, wantErr: "gateway.image_stream_keepalive_interval", }, { name: "gateway image stream keepalive negative", mutate: func(c *Config) { c.Gateway.ImageStreamKeepaliveInterval = -1 }, wantErr: "gateway.image_stream_keepalive_interval must be non-negative", }, { name: "gateway image nonstream keepalive range", mutate: func(c *Config) { c.Gateway.ImageNonstreamKeepaliveInterval = 4 }, wantErr: "gateway.image_nonstream_keepalive_interval", }, { name: "gateway image nonstream keepalive negative", mutate: func(c *Config) { c.Gateway.ImageNonstreamKeepaliveInterval = -1 }, wantErr: "gateway.image_nonstream_keepalive_interval must be non-negative", }, { name: "gateway image stream data interval range", mutate: func(c *Config) { c.Gateway.ImageStreamDataIntervalTimeout = 30 }, wantErr: "gateway.image_stream_data_interval_timeout", }, { name: "gateway image stream data interval negative", mutate: func(c *Config) { c.Gateway.ImageStreamDataIntervalTimeout = -1 }, wantErr: "gateway.image_stream_data_interval_timeout must be non-negative", }, { name: "gateway image concurrency max negative", mutate: func(c *Config) { c.Gateway.ImageConcurrency.MaxConcurrentRequests = -1 }, wantErr: "gateway.image_concurrency.max_concurrent_requests must be non-negative", }, { name: "gateway image concurrency overflow mode invalid", mutate: func(c *Config) { c.Gateway.ImageConcurrency.OverflowMode = "queue" }, wantErr: "gateway.image_concurrency.overflow_mode", }, { name: "gateway image concurrency wait timeout negative", mutate: func(c *Config) { c.Gateway.ImageConcurrency.WaitTimeoutSeconds = -1 }, wantErr: "gateway.image_concurrency.wait_timeout_seconds must be non-negative", }, { name: "gateway image concurrency max waiting negative", mutate: func(c *Config) { c.Gateway.ImageConcurrency.MaxWaitingRequests = -1 }, wantErr: "gateway.image_concurrency.max_waiting_requests must be non-negative", }, { name: "gateway max line size", mutate: func(c *Config) { c.Gateway.MaxLineSize = 1024 }, wantErr: "gateway.max_line_size must be at least", }, { name: "gateway max line size negative", mutate: func(c *Config) { c.Gateway.MaxLineSize = -1 }, wantErr: "gateway.max_line_size must be non-negative", }, { name: "gateway usage record worker count", mutate: func(c *Config) { c.Gateway.UsageRecord.WorkerCount = 0 }, wantErr: "gateway.usage_record.worker_count", }, { name: "gateway usage record queue size", mutate: func(c *Config) { c.Gateway.UsageRecord.QueueSize = 0 }, wantErr: "gateway.usage_record.queue_size", }, { name: "gateway usage record timeout", mutate: func(c *Config) { c.Gateway.UsageRecord.TaskTimeoutSeconds = 0 }, wantErr: "gateway.usage_record.task_timeout_seconds", }, { name: "gateway usage record overflow policy", mutate: func(c *Config) { c.Gateway.UsageRecord.OverflowPolicy = "invalid" }, wantErr: "gateway.usage_record.overflow_policy", }, { name: "gateway usage record sample percent range", mutate: func(c *Config) { c.Gateway.UsageRecord.OverflowSamplePercent = 101 }, wantErr: "gateway.usage_record.overflow_sample_percent", }, { name: "gateway usage record sample percent required for sample policy", mutate: func(c *Config) { c.Gateway.UsageRecord.OverflowPolicy = UsageRecordOverflowPolicySample c.Gateway.UsageRecord.OverflowSamplePercent = 0 }, wantErr: "gateway.usage_record.overflow_sample_percent must be positive", }, { name: "gateway usage record auto scale max gte min", mutate: func(c *Config) { c.Gateway.UsageRecord.AutoScaleMinWorkers = 256 c.Gateway.UsageRecord.AutoScaleMaxWorkers = 128 }, wantErr: "gateway.usage_record.auto_scale_max_workers", }, { name: "gateway usage record worker in auto scale range", mutate: func(c *Config) { c.Gateway.UsageRecord.AutoScaleMinWorkers = 200 c.Gateway.UsageRecord.AutoScaleMaxWorkers = 300 c.Gateway.UsageRecord.WorkerCount = 128 }, wantErr: "gateway.usage_record.worker_count must be between auto_scale_min_workers and auto_scale_max_workers", }, { name: "gateway usage record auto scale queue thresholds order", mutate: func(c *Config) { c.Gateway.UsageRecord.AutoScaleUpQueuePercent = 50 c.Gateway.UsageRecord.AutoScaleDownQueuePercent = 50 }, wantErr: "gateway.usage_record.auto_scale_down_queue_percent must be less", }, { name: "gateway usage record auto scale up step", mutate: func(c *Config) { c.Gateway.UsageRecord.AutoScaleUpStep = 0 }, wantErr: "gateway.usage_record.auto_scale_up_step", }, { name: "gateway usage record auto scale interval", mutate: func(c *Config) { c.Gateway.UsageRecord.AutoScaleCheckIntervalSeconds = 0 }, wantErr: "gateway.usage_record.auto_scale_check_interval_seconds", }, { name: "gateway user group rate cache ttl", mutate: func(c *Config) { c.Gateway.UserGroupRateCacheTTLSeconds = 0 }, wantErr: "gateway.user_group_rate_cache_ttl_seconds", }, { name: "gateway models list cache ttl range", mutate: func(c *Config) { c.Gateway.ModelsListCacheTTLSeconds = 31 }, wantErr: "gateway.models_list_cache_ttl_seconds", }, { name: "gateway scheduling sticky waiting", mutate: func(c *Config) { c.Gateway.Scheduling.StickySessionMaxWaiting = 0 }, wantErr: "gateway.scheduling.sticky_session_max_waiting", }, { name: "gateway scheduling load batch cache ttl", mutate: func(c *Config) { c.Gateway.Scheduling.LoadBatchCacheTTLMS = -1 }, wantErr: "gateway.scheduling.load_batch_cache_ttl_ms", }, { name: "gateway scheduling outbox poll", mutate: func(c *Config) { c.Gateway.Scheduling.OutboxPollIntervalSeconds = 0 }, wantErr: "gateway.scheduling.outbox_poll_interval_seconds", }, { name: "gateway scheduling outbox failures", mutate: func(c *Config) { c.Gateway.Scheduling.OutboxLagRebuildFailures = 0 }, wantErr: "gateway.scheduling.outbox_lag_rebuild_failures", }, { name: "gateway outbox lag rebuild", mutate: func(c *Config) { c.Gateway.Scheduling.OutboxLagWarnSeconds = 10 c.Gateway.Scheduling.OutboxLagRebuildSeconds = 5 }, wantErr: "gateway.scheduling.outbox_lag_rebuild_seconds", }, { name: "log level invalid", mutate: func(c *Config) { c.Log.Level = "trace" }, wantErr: "log.level", }, { name: "log format invalid", mutate: func(c *Config) { c.Log.Format = "plain" }, wantErr: "log.format", }, { name: "log output disabled", mutate: func(c *Config) { c.Log.Output.ToStdout = false c.Log.Output.ToFile = false }, wantErr: "log.output.to_stdout and log.output.to_file cannot both be false", }, { name: "log rotation size", mutate: func(c *Config) { c.Log.Rotation.MaxSizeMB = 0 }, wantErr: "log.rotation.max_size_mb", }, { name: "log sampling enabled invalid", mutate: func(c *Config) { c.Log.Sampling.Enabled = true c.Log.Sampling.Initial = 0 }, wantErr: "log.sampling.initial", }, { name: "ops metrics collector ttl", mutate: func(c *Config) { c.Ops.MetricsCollectorCache.TTL = -1 }, wantErr: "ops.metrics_collector_cache.ttl", }, { name: "ops cleanup retention", mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 }, wantErr: "ops.cleanup.error_log_retention_days", }, { name: "ops cleanup minute retention", mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 }, wantErr: "ops.cleanup.minute_metrics_retention_days", }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { cfg := buildValid(t) tt.mutate(cfg) err := cfg.Validate() if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr) } }) } } func TestValidateConfig_OpenAIWSRules(t *testing.T) { buildValid := func(t *testing.T) *Config { t.Helper() resetViperWithJWTSecret(t) cfg, err := Load() require.NoError(t, err) return cfg } t.Run("sticky response id ttl 兼容旧键回填", func(t *testing.T) { cfg := buildValid(t) cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 0 cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds = 7200 require.NoError(t, cfg.Validate()) require.Equal(t, 7200, cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds) }) cases := []struct { name string mutate func(*Config) wantErr string }{ { name: "max_conns_per_account 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.MaxConnsPerAccount = 0 }, wantErr: "gateway.openai_ws.max_conns_per_account", }, { name: "client_first_message_timeout_seconds 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = 0 }, wantErr: "gateway.openai_ws.client_first_message_timeout_seconds", }, { name: "client_first_message_timeout_seconds 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = -1 }, wantErr: "gateway.openai_ws.client_first_message_timeout_seconds", }, { name: "ingress_inter_turn_idle_timeout_seconds 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = -1 }, wantErr: "gateway.openai_ws.ingress_inter_turn_idle_timeout_seconds", }, { name: "max_ingress_connections_per_api_key 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = -1 }, wantErr: "gateway.openai_ws.max_ingress_connections_per_api_key", }, { name: "min_idle_per_account 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.MinIdlePerAccount = -1 }, wantErr: "gateway.openai_ws.min_idle_per_account", }, { name: "max_idle_per_account 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.MaxIdlePerAccount = -1 }, wantErr: "gateway.openai_ws.max_idle_per_account", }, { name: "min_idle_per_account 不能大于 max_idle_per_account", mutate: func(c *Config) { c.Gateway.OpenAIWS.MinIdlePerAccount = 3 c.Gateway.OpenAIWS.MaxIdlePerAccount = 2 }, wantErr: "gateway.openai_ws.min_idle_per_account must be <= max_idle_per_account", }, { name: "max_idle_per_account 不能大于 max_conns_per_account", mutate: func(c *Config) { c.Gateway.OpenAIWS.MaxConnsPerAccount = 2 c.Gateway.OpenAIWS.MinIdlePerAccount = 1 c.Gateway.OpenAIWS.MaxIdlePerAccount = 3 }, wantErr: "gateway.openai_ws.max_idle_per_account must be <= max_conns_per_account", }, { name: "dial_timeout_seconds 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.DialTimeoutSeconds = 0 }, wantErr: "gateway.openai_ws.dial_timeout_seconds", }, { name: "read_timeout_seconds 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.ReadTimeoutSeconds = 0 }, wantErr: "gateway.openai_ws.read_timeout_seconds", }, { name: "write_timeout_seconds 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.WriteTimeoutSeconds = 0 }, wantErr: "gateway.openai_ws.write_timeout_seconds", }, { name: "pool_target_utilization 必须在 (0,1]", mutate: func(c *Config) { c.Gateway.OpenAIWS.PoolTargetUtilization = 0 }, wantErr: "gateway.openai_ws.pool_target_utilization", }, { name: "queue_limit_per_conn 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.QueueLimitPerConn = 0 }, wantErr: "gateway.openai_ws.queue_limit_per_conn", }, { name: "fallback_cooldown_seconds 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.FallbackCooldownSeconds = -1 }, wantErr: "gateway.openai_ws.fallback_cooldown_seconds", }, { name: "store_disabled_conn_mode 必须为 strict|adaptive|off", mutate: func(c *Config) { c.Gateway.OpenAIWS.StoreDisabledConnMode = "invalid" }, wantErr: "gateway.openai_ws.store_disabled_conn_mode", }, { name: "ingress_mode_default 必须为 off|ctx_pool|passthrough|http_bridge", mutate: func(c *Config) { c.Gateway.OpenAIWS.IngressModeDefault = "invalid" }, wantErr: "gateway.openai_ws.ingress_mode_default", }, { name: "payload_log_sample_rate 必须在 [0,1] 范围内", mutate: func(c *Config) { c.Gateway.OpenAIWS.PayloadLogSampleRate = 1.2 }, wantErr: "gateway.openai_ws.payload_log_sample_rate", }, { name: "retry_total_budget_ms 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.RetryTotalBudgetMS = -1 }, wantErr: "gateway.openai_ws.retry_total_budget_ms", }, { name: "lb_top_k 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.LBTopK = 0 }, wantErr: "gateway.openai_ws.lb_top_k", }, { name: "sticky_session_ttl_seconds 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.StickySessionTTLSeconds = 0 }, wantErr: "gateway.openai_ws.sticky_session_ttl_seconds", }, { name: "sticky_response_id_ttl_seconds 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 0 c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds = 0 }, wantErr: "gateway.openai_ws.sticky_response_id_ttl_seconds", }, { name: "sticky_previous_response_ttl_seconds 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds = -1 }, wantErr: "gateway.openai_ws.sticky_previous_response_ttl_seconds", }, { name: "scheduler_score_weights 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = -0.1 }, wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative", }, { name: "scheduler_score_weights quota_headroom 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = -0.1 }, wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative", }, { name: "scheduler_score_weights upstream_cost 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = -0.1 }, wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative", }, { name: "scheduler_score_weights reset 不能为负数", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = -0.1 }, wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative", }, { name: "scheduler_score_weights 不能为 NaN", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.NaN() }, wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite", }, { name: "scheduler_score_weights 不能为 Inf", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = math.Inf(1) }, wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite", }, { name: "scheduler_score_weights 总和不能溢出", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64 c.Gateway.OpenAIWS.SchedulerScoreWeights.Load = math.MaxFloat64 }, wantErr: "gateway.openai_ws.scheduler_score_weights base-weight sum must be finite", }, { name: "scheduler_score_weights 含 sticky 总和不能溢出", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64 c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.MaxFloat64 }, wantErr: "gateway.openai_ws.scheduler_score_weights total-weight sum must be finite", }, { name: "scheduler_score_weights 不能全为 0", mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0 c.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0 c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0 c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0 c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0 }, wantErr: "gateway.openai_ws.scheduler_score_weights must not all be zero", }, { name: "sticky_escape_ttft_ms 必须为正数", mutate: func(c *Config) { c.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 0 }, wantErr: "gateway.openai_scheduler.sticky_escape_ttft_ms", }, { name: "sticky_escape_error_rate 不能小于 0", mutate: func(c *Config) { c.Gateway.OpenAIScheduler.StickyEscapeErrorRate = -0.1 }, wantErr: "gateway.openai_scheduler.sticky_escape_error_rate", }, { name: "sticky_escape_error_rate 不能大于 1", mutate: func(c *Config) { c.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 1.1 }, wantErr: "gateway.openai_scheduler.sticky_escape_error_rate", }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { cfg := buildValid(t) tc.mutate(cfg) err := cfg.Validate() require.Error(t, err) require.Contains(t, err.Error(), tc.wantErr) }) } t.Run("quota_headroom 可作为唯一有效调度权重", func(t *testing.T) { cfg := buildValid(t) cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = 0.1 require.NoError(t, cfg.Validate()) }) t.Run("upstream_cost 可作为唯一有效调度权重", func(t *testing.T) { cfg := buildValid(t) cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = 0.1 require.NoError(t, cfg.Validate()) }) t.Run("reset 可作为唯一有效调度权重", func(t *testing.T) { cfg := buildValid(t) cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0 cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = 0.1 require.NoError(t, cfg.Validate()) }) } func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } cfg.Gateway.UsageRecord.AutoScaleEnabled = false cfg.Gateway.UsageRecord.WorkerCount = 64 // 自动扩缩容关闭时,这些字段应被忽略,不应导致校验失败。 cfg.Gateway.UsageRecord.AutoScaleMinWorkers = 0 cfg.Gateway.UsageRecord.AutoScaleMaxWorkers = 0 cfg.Gateway.UsageRecord.AutoScaleUpQueuePercent = 0 cfg.Gateway.UsageRecord.AutoScaleDownQueuePercent = 100 cfg.Gateway.UsageRecord.AutoScaleUpStep = 0 cfg.Gateway.UsageRecord.AutoScaleDownStep = 0 cfg.Gateway.UsageRecord.AutoScaleCheckIntervalSeconds = 0 cfg.Gateway.UsageRecord.AutoScaleCooldownSeconds = -1 if err := cfg.Validate(); err != nil { t.Fatalf("Validate() should ignore auto scale fields when disabled: %v", err) } } func TestValidateConfig_LogRequiredAndRotationBounds(t *testing.T) { resetViperWithJWTSecret(t) cases := []struct { name string mutate func(*Config) wantErr string }{ { name: "log level required", mutate: func(c *Config) { c.Log.Level = "" }, wantErr: "log.level is required", }, { name: "log format required", mutate: func(c *Config) { c.Log.Format = "" }, wantErr: "log.format is required", }, { name: "log stacktrace required", mutate: func(c *Config) { c.Log.StacktraceLevel = "" }, wantErr: "log.stacktrace_level is required", }, { name: "log max backups non-negative", mutate: func(c *Config) { c.Log.Rotation.MaxBackups = -1 }, wantErr: "log.rotation.max_backups must be non-negative", }, { name: "log max age non-negative", mutate: func(c *Config) { c.Log.Rotation.MaxAgeDays = -1 }, wantErr: "log.rotation.max_age_days must be non-negative", }, { name: "sampling thereafter non-negative when disabled", mutate: func(c *Config) { c.Log.Sampling.Enabled = false c.Log.Sampling.Thereafter = -1 }, wantErr: "log.sampling.thereafter must be non-negative", }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } tt.mutate(cfg) err = cfg.Validate() if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr) } }) } } func TestLoad_DefaultGatewayUsageRecordConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Gateway.UsageRecord.WorkerCount != 128 { t.Fatalf("worker_count = %d, want 128", cfg.Gateway.UsageRecord.WorkerCount) } if cfg.Gateway.UsageRecord.QueueSize != 16384 { t.Fatalf("queue_size = %d, want 16384", cfg.Gateway.UsageRecord.QueueSize) } if cfg.Gateway.UsageRecord.TaskTimeoutSeconds != 5 { t.Fatalf("task_timeout_seconds = %d, want 5", cfg.Gateway.UsageRecord.TaskTimeoutSeconds) } if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySync { t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySync) } if cfg.Gateway.UsageRecord.OverflowSamplePercent != 10 { t.Fatalf("overflow_sample_percent = %d, want 10", cfg.Gateway.UsageRecord.OverflowSamplePercent) } if !cfg.Gateway.UsageRecord.AutoScaleEnabled { t.Fatalf("auto_scale_enabled = false, want true") } if cfg.Gateway.UsageRecord.AutoScaleMinWorkers != 128 { t.Fatalf("auto_scale_min_workers = %d, want 128", cfg.Gateway.UsageRecord.AutoScaleMinWorkers) } if cfg.Gateway.UsageRecord.AutoScaleMaxWorkers != 512 { t.Fatalf("auto_scale_max_workers = %d, want 512", cfg.Gateway.UsageRecord.AutoScaleMaxWorkers) } if cfg.Gateway.UsageRecord.AutoScaleUpQueuePercent != 70 { t.Fatalf("auto_scale_up_queue_percent = %d, want 70", cfg.Gateway.UsageRecord.AutoScaleUpQueuePercent) } if cfg.Gateway.UsageRecord.AutoScaleDownQueuePercent != 15 { t.Fatalf("auto_scale_down_queue_percent = %d, want 15", cfg.Gateway.UsageRecord.AutoScaleDownQueuePercent) } if cfg.Gateway.UsageRecord.AutoScaleUpStep != 32 { t.Fatalf("auto_scale_up_step = %d, want 32", cfg.Gateway.UsageRecord.AutoScaleUpStep) } if cfg.Gateway.UsageRecord.AutoScaleDownStep != 16 { t.Fatalf("auto_scale_down_step = %d, want 16", cfg.Gateway.UsageRecord.AutoScaleDownStep) } if cfg.Gateway.UsageRecord.AutoScaleCheckIntervalSeconds != 3 { t.Fatalf("auto_scale_check_interval_seconds = %d, want 3", cfg.Gateway.UsageRecord.AutoScaleCheckIntervalSeconds) } if cfg.Gateway.UsageRecord.AutoScaleCooldownSeconds != 10 { t.Fatalf("auto_scale_cooldown_seconds = %d, want 10", cfg.Gateway.UsageRecord.AutoScaleCooldownSeconds) } } func TestLoad_DefaultGatewayImageStreamConfig(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) } if cfg.Gateway.StreamDataIntervalTimeout != 180 { t.Fatalf("stream_data_interval_timeout = %d, want 180", cfg.Gateway.StreamDataIntervalTimeout) } if cfg.Gateway.StreamKeepaliveInterval != 10 { t.Fatalf("stream_keepalive_interval = %d, want 10", cfg.Gateway.StreamKeepaliveInterval) } if cfg.Gateway.ImageStreamDataIntervalTimeout != 900 { t.Fatalf("image_stream_data_interval_timeout = %d, want 900", cfg.Gateway.ImageStreamDataIntervalTimeout) } if cfg.Gateway.ImageStreamKeepaliveInterval != 10 { t.Fatalf("image_stream_keepalive_interval = %d, want 10", cfg.Gateway.ImageStreamKeepaliveInterval) } if cfg.Gateway.ImageNonstreamKeepaliveInterval != 0 { t.Fatalf("image_nonstream_keepalive_interval = %d, want 0", cfg.Gateway.ImageNonstreamKeepaliveInterval) } if cfg.Gateway.ImageConcurrency.Enabled { t.Fatalf("image_concurrency.enabled = true, want false") } if cfg.Gateway.ImageConcurrency.MaxConcurrentRequests != 0 { t.Fatalf("image_concurrency.max_concurrent_requests = %d, want 0", cfg.Gateway.ImageConcurrency.MaxConcurrentRequests) } if cfg.Gateway.ImageConcurrency.OverflowMode != ImageConcurrencyOverflowModeReject { t.Fatalf("image_concurrency.overflow_mode = %q, want %q", cfg.Gateway.ImageConcurrency.OverflowMode, ImageConcurrencyOverflowModeReject) } if cfg.Gateway.ImageConcurrency.WaitTimeoutSeconds != 30 { t.Fatalf("image_concurrency.wait_timeout_seconds = %d, want 30", cfg.Gateway.ImageConcurrency.WaitTimeoutSeconds) } if cfg.Gateway.ImageConcurrency.MaxWaitingRequests != 100 { t.Fatalf("image_concurrency.max_waiting_requests = %d, want 100", cfg.Gateway.ImageConcurrency.MaxWaitingRequests) } if cfg.Gateway.ImageStreamDataIntervalTimeout <= cfg.Gateway.StreamDataIntervalTimeout { t.Fatalf("image stream timeout = %d, want greater than ordinary stream timeout %d", cfg.Gateway.ImageStreamDataIntervalTimeout, cfg.Gateway.StreamDataIntervalTimeout) } }