//go:build unit package service import ( "context" "encoding/json" "errors" "math" "strconv" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) type settingUpdateRepoStub struct { updates map[string]string setMultipleErr error } func (s *settingUpdateRepoStub) Get(ctx context.Context, key string) (*Setting, error) { panic("unexpected Get call") } func (s *settingUpdateRepoStub) GetValue(ctx context.Context, key string) (string, error) { panic("unexpected GetValue call") } func (s *settingUpdateRepoStub) Set(ctx context.Context, key, value string) error { panic("unexpected Set call") } func (s *settingUpdateRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { panic("unexpected GetMultiple call") } func (s *settingUpdateRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { s.updates = make(map[string]string, len(settings)) for k, v := range settings { s.updates[k] = v } return s.setMultipleErr } func (s *settingUpdateRepoStub) GetAll(ctx context.Context) (map[string]string, error) { panic("unexpected GetAll call") } func (s *settingUpdateRepoStub) Delete(ctx context.Context, key string) error { panic("unexpected Delete call") } type settingGetAllRepoStub struct { values map[string]string } func (s *settingGetAllRepoStub) Get(ctx context.Context, key string) (*Setting, error) { panic("unexpected Get call") } func (s *settingGetAllRepoStub) GetValue(ctx context.Context, key string) (string, error) { panic("unexpected GetValue call") } func (s *settingGetAllRepoStub) Set(ctx context.Context, key, value string) error { panic("unexpected Set call") } func (s *settingGetAllRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { panic("unexpected GetMultiple call") } func (s *settingGetAllRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { panic("unexpected SetMultiple call") } func (s *settingGetAllRepoStub) GetAll(ctx context.Context) (map[string]string, error) { out := make(map[string]string, len(s.values)) for key, value := range s.values { out[key] = value } return out, nil } func (s *settingGetAllRepoStub) Delete(ctx context.Context, key string) error { panic("unexpected Delete call") } type forwardedIPMigrationRepoStub struct { values map[string]string updates map[string]string getMultipleErr error setMultipleErr error } func (s *forwardedIPMigrationRepoStub) Get(context.Context, string) (*Setting, error) { panic("unexpected Get call") } func (s *forwardedIPMigrationRepoStub) GetValue(_ context.Context, key string) (string, error) { value, ok := s.values[key] if !ok { return "", ErrSettingNotFound } return value, nil } func (s *forwardedIPMigrationRepoStub) Set(context.Context, string, string) error { panic("unexpected Set call") } func (s *forwardedIPMigrationRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { if s.getMultipleErr != nil { return nil, s.getMultipleErr } result := make(map[string]string, len(keys)) for _, key := range keys { if value, ok := s.values[key]; ok { result[key] = value } } return result, nil } func (s *forwardedIPMigrationRepoStub) SetMultiple(_ context.Context, values map[string]string) error { if s.setMultipleErr != nil { return s.setMultipleErr } s.updates = make(map[string]string, len(values)) for key, value := range values { s.values[key] = value s.updates[key] = value } return nil } func (s *forwardedIPMigrationRepoStub) GetAll(context.Context) (map[string]string, error) { panic("unexpected GetAll call") } func (s *forwardedIPMigrationRepoStub) Delete(context.Context, string) error { panic("unexpected Delete call") } type settingAntigravityUARepoStub struct { values map[string]string } func (s *settingAntigravityUARepoStub) Get(ctx context.Context, key string) (*Setting, error) { panic("unexpected Get call") } func (s *settingAntigravityUARepoStub) GetValue(ctx context.Context, key string) (string, error) { if value, ok := s.values[key]; ok { return value, nil } return "", ErrSettingNotFound } func (s *settingAntigravityUARepoStub) Set(ctx context.Context, key, value string) error { panic("unexpected Set call") } func (s *settingAntigravityUARepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { panic("unexpected GetMultiple call") } func (s *settingAntigravityUARepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { panic("unexpected SetMultiple call") } func (s *settingAntigravityUARepoStub) GetAll(ctx context.Context) (map[string]string, error) { panic("unexpected GetAll call") } func (s *settingAntigravityUARepoStub) Delete(ctx context.Context, key string) error { panic("unexpected Delete call") } type defaultSubGroupReaderStub struct { byID map[int64]*Group errBy map[int64]error calls []int64 } func TestSettingService_AffiliateAdminRechargeSetting(t *testing.T) { t.Run("missing value defaults to disabled", func(t *testing.T) { svc := NewSettingService(&settingGetAllRepoStub{values: map[string]string{}}, &config.Config{}) settings, err := svc.GetAllSettings(context.Background()) require.NoError(t, err) require.False(t, settings.AdminRechargeRebateEnabled) }) t.Run("explicit value is parsed", func(t *testing.T) { svc := NewSettingService(&settingGetAllRepoStub{values: map[string]string{ SettingKeyAffiliateAdminRechargeEnabled: "true", }}, &config.Config{}) settings, err := svc.GetAllSettings(context.Background()) require.NoError(t, err) require.True(t, settings.AdminRechargeRebateEnabled) }) t.Run("value is persisted", func(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ AdminRechargeRebateEnabled: true, }) require.NoError(t, err) require.Equal(t, "true", repo.updates[SettingKeyAffiliateAdminRechargeEnabled]) }) } func (s *defaultSubGroupReaderStub) GetByID(ctx context.Context, id int64) (*Group, error) { s.calls = append(s.calls, id) if err, ok := s.errBy[id]; ok { return nil, err } if g, ok := s.byID[id]; ok { return g, nil } return nil, ErrGroupNotFound } func TestSettingService_UpdateSettings_PersistsCompactHomeEnabled(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{CompactHomeEnabled: true}) require.NoError(t, err) require.Equal(t, "true", repo.updates[SettingKeyCompactHomeEnabled]) } func TestSettingService_UpdateSettings_DefaultSubscriptions_ValidGroup(t *testing.T) { repo := &settingUpdateRepoStub{} groupReader := &defaultSubGroupReaderStub{ byID: map[int64]*Group{ 11: {ID: 11, SubscriptionType: SubscriptionTypeSubscription}, }, } svc := NewSettingService(repo, &config.Config{}) svc.SetDefaultSubscriptionGroupReader(groupReader) err := svc.UpdateSettings(context.Background(), &SystemSettings{ DefaultSubscriptions: []DefaultSubscriptionSetting{ {GroupID: 11, ValidityDays: 30}, }, }) require.NoError(t, err) require.Equal(t, []int64{11}, groupReader.calls) raw, ok := repo.updates[SettingKeyDefaultSubscriptions] require.True(t, ok) var got []DefaultSubscriptionSetting require.NoError(t, json.Unmarshal([]byte(raw), &got)) require.Equal(t, []DefaultSubscriptionSetting{ {GroupID: 11, ValidityDays: 30}, }, got) } func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsNonSubscriptionGroup(t *testing.T) { repo := &settingUpdateRepoStub{} groupReader := &defaultSubGroupReaderStub{ byID: map[int64]*Group{ 12: {ID: 12, SubscriptionType: SubscriptionTypeStandard}, }, } svc := NewSettingService(repo, &config.Config{}) svc.SetDefaultSubscriptionGroupReader(groupReader) err := svc.UpdateSettings(context.Background(), &SystemSettings{ DefaultSubscriptions: []DefaultSubscriptionSetting{ {GroupID: 12, ValidityDays: 7}, }, }) require.Error(t, err) require.Equal(t, "DEFAULT_SUBSCRIPTION_GROUP_INVALID", infraerrors.Reason(err)) require.Nil(t, repo.updates) } func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsNotFoundGroup(t *testing.T) { repo := &settingUpdateRepoStub{} groupReader := &defaultSubGroupReaderStub{ errBy: map[int64]error{ 13: ErrGroupNotFound, }, } svc := NewSettingService(repo, &config.Config{}) svc.SetDefaultSubscriptionGroupReader(groupReader) err := svc.UpdateSettings(context.Background(), &SystemSettings{ DefaultSubscriptions: []DefaultSubscriptionSetting{ {GroupID: 13, ValidityDays: 7}, }, }) require.Error(t, err) require.Equal(t, "DEFAULT_SUBSCRIPTION_GROUP_INVALID", infraerrors.Reason(err)) require.Equal(t, "13", infraerrors.FromError(err).Metadata["group_id"]) require.Nil(t, repo.updates) } func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGroup(t *testing.T) { repo := &settingUpdateRepoStub{} groupReader := &defaultSubGroupReaderStub{ byID: map[int64]*Group{ 11: {ID: 11, SubscriptionType: SubscriptionTypeSubscription}, }, } svc := NewSettingService(repo, &config.Config{}) svc.SetDefaultSubscriptionGroupReader(groupReader) err := svc.UpdateSettings(context.Background(), &SystemSettings{ DefaultSubscriptions: []DefaultSubscriptionSetting{ {GroupID: 11, ValidityDays: 30}, {GroupID: 11, ValidityDays: 60}, }, }) require.Error(t, err) require.Equal(t, "DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE", infraerrors.Reason(err)) require.Equal(t, "11", infraerrors.FromError(err).Metadata["group_id"]) require.Nil(t, repo.updates) } func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGroupWithoutGroupReader(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ DefaultSubscriptions: []DefaultSubscriptionSetting{ {GroupID: 11, ValidityDays: 30}, {GroupID: 11, ValidityDays: 60}, }, }) require.Error(t, err) require.Equal(t, "DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE", infraerrors.Reason(err)) require.Equal(t, "11", infraerrors.FromError(err).Metadata["group_id"]) require.Nil(t, repo.updates) } func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Normalized(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ RegistrationEmailSuffixWhitelist: []string{"example.com", "@EXAMPLE.com", " @foo.bar ", "*.EDU.CN"}, }) require.NoError(t, err) require.Equal(t, `["@example.com","@foo.bar","*.edu.cn"]`, repo.updates[SettingKeyRegistrationEmailSuffixWhitelist]) } func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ RegistrationEmailSuffixWhitelist: []string{"@invalid_domain"}, }) require.Error(t, err) require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err)) } func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) { got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`) require.Equal(t, []DefaultSubscriptionSetting{ {GroupID: 11, ValidityDays: 30}, {GroupID: 11, ValidityDays: 60}, {GroupID: 12, ValidityDays: MaxValidityDays}, }, got) } func TestSettingService_UpdateSettings_TablePreferences(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ TableDefaultPageSize: 50, TablePageSizeOptions: []int{20, 50, 100}, }) require.NoError(t, err) require.Equal(t, "50", repo.updates[SettingKeyTableDefaultPageSize]) require.Equal(t, "[20,50,100]", repo.updates[SettingKeyTablePageSizeOptions]) err = svc.UpdateSettings(context.Background(), &SystemSettings{ TableDefaultPageSize: 1000, TablePageSizeOptions: []int{20, 100}, }) require.NoError(t, err) require.Equal(t, "1000", repo.updates[SettingKeyTableDefaultPageSize]) require.Equal(t, "[20,100]", repo.updates[SettingKeyTablePageSizeOptions]) } func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() defer resetOpenAIAdvancedSchedulerSettingCacheForTest() repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ PaymentVisibleMethodAlipaySource: "alipay", PaymentVisibleMethodWxpaySource: "easypay", PaymentVisibleMethodAlipayEnabled: true, PaymentVisibleMethodWxpayEnabled: false, OpenAILowUpstreamRatePriorityEnabled: true, OpenAIOAuthSchedulingRateMultiplier: 0.05, OpenAIAdvancedSchedulerEnabled: true, OpenAIAdvancedSchedulerStickyWeightedEnabled: true, OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: true, OpenAIAdvancedSchedulerLBTopK: " 3 ", OpenAIAdvancedSchedulerWeightPriority: "2.50", OpenAIAdvancedSchedulerWeightLoad: "0", OpenAIAdvancedSchedulerWeightQueue: "0.75", OpenAIAdvancedSchedulerWeightErrorRate: "1.25", OpenAIAdvancedSchedulerWeightTTFT: "0.5", OpenAIAdvancedSchedulerWeightReset: "", OpenAIAdvancedSchedulerWeightQuotaHeadroom: "0.2", OpenAIAdvancedSchedulerWeightUpstreamCost: "1.5", OpenAIAdvancedSchedulerWeightPreviousResponse: "8", OpenAIAdvancedSchedulerWeightSessionSticky: "4", }) require.NoError(t, err) require.Equal(t, VisibleMethodSourceOfficialAlipay, repo.updates[SettingPaymentVisibleMethodAlipaySource]) require.Equal(t, VisibleMethodSourceEasyPayWechat, repo.updates[SettingPaymentVisibleMethodWxpaySource]) require.Equal(t, "true", repo.updates[SettingPaymentVisibleMethodAlipayEnabled]) require.Equal(t, "false", repo.updates[SettingPaymentVisibleMethodWxpayEnabled]) require.Equal(t, "true", repo.updates[SettingKeyOpenAILowUpstreamRatePriorityEnabled]) require.Equal(t, "0.05", repo.updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) require.Equal(t, "true", repo.updates[openAIAdvancedSchedulerSettingKey]) require.Equal(t, "true", repo.updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]) require.Equal(t, "true", repo.updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]) require.Equal(t, "3", repo.updates[SettingKeyOpenAIAdvancedSchedulerLBTopK]) require.Equal(t, "2.5", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) require.Equal(t, "0", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) require.Equal(t, "0.75", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) require.Equal(t, "1.25", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) require.Equal(t, "0.5", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) require.Equal(t, "", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightReset]) require.Equal(t, "0.2", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) require.Equal(t, "1.5", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightUpstreamCost]) require.Equal(t, "8", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) require.Equal(t, "4", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) } func TestSettingService_UpdateSettingsRejectsInvalidOpenAIOAuthSchedulingRateMultiplier(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) for _, rate := range []float64{-0.01, math.NaN(), math.Inf(1)} { err := svc.UpdateSettings(context.Background(), &SystemSettings{OpenAIOAuthSchedulingRateMultiplier: rate}) require.Error(t, err) } } func TestSettingService_UpdateSettings_OpenAIAdvancedSchedulerWeightSums(t *testing.T) { maxFloat := strconv.FormatFloat(math.MaxFloat64, 'g', -1, 64) tests := []struct { name string weights SystemSettings wantErr bool }{ { name: "reset only base is valid", weights: SystemSettings{ OpenAIAdvancedSchedulerWeightPriority: "0", OpenAIAdvancedSchedulerWeightLoad: "0", OpenAIAdvancedSchedulerWeightQueue: "0", OpenAIAdvancedSchedulerWeightErrorRate: "0", OpenAIAdvancedSchedulerWeightTTFT: "0", OpenAIAdvancedSchedulerWeightReset: "1", OpenAIAdvancedSchedulerWeightQuotaHeadroom: "0", OpenAIAdvancedSchedulerWeightUpstreamCost: "0", OpenAIAdvancedSchedulerWeightPreviousResponse: "0", OpenAIAdvancedSchedulerWeightSessionSticky: "0", }, }, { name: "base sum overflow is rejected", weights: SystemSettings{ OpenAIAdvancedSchedulerWeightPriority: maxFloat, OpenAIAdvancedSchedulerWeightLoad: maxFloat, }, wantErr: true, }, { name: "sticky total sum overflow is rejected", weights: SystemSettings{ OpenAIAdvancedSchedulerWeightPriority: maxFloat, OpenAIAdvancedSchedulerWeightPreviousResponse: maxFloat, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc := NewSettingService(&settingUpdateRepoStub{}, &config.Config{}) err := svc.UpdateSettings(context.Background(), &tt.weights) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) }) } } func TestSettingService_ParseSettingsDefaultsOpenAIOAuthSchedulingRateMultiplier(t *testing.T) { svc := NewSettingService(&settingUpdateRepoStub{}, &config.Config{}) require.Equal(t, 1.0, svc.parseSettings(map[string]string{}).OpenAIOAuthSchedulingRateMultiplier) require.Equal(t, 0.05, svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.05"}).OpenAIOAuthSchedulingRateMultiplier) } func TestSettingService_GetAllSettings_OpenAIAdvancedSchedulerEffectiveValuesUseConfig(t *testing.T) { cfg := &config.Config{} cfg.Gateway.OpenAIWS.LBTopK = 13 cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{ Priority: 2, Load: 3, Queue: 4, ErrorRate: 5, TTFT: 6, Reset: 7, QuotaHeadroom: 8, UpstreamCost: 9, PreviousResponse: 10, SessionSticky: 11, } svc := NewSettingService(&settingGetAllRepoStub{values: map[string]string{ SettingKeyOpenAIAdvancedSchedulerLBTopK: "3", SettingKeyOpenAIAdvancedSchedulerWeightPriority: "99", SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "88", }}, cfg) settings, err := svc.GetAllSettings(context.Background()) require.NoError(t, err) require.Equal(t, "3", settings.OpenAIAdvancedSchedulerLBTopK) require.Equal(t, "99", settings.OpenAIAdvancedSchedulerWeightPriority) require.Equal(t, "88", settings.OpenAIAdvancedSchedulerWeightSessionSticky) require.Equal(t, "13", settings.OpenAIAdvancedSchedulerEffectiveLBTopK) require.Equal(t, "2", settings.OpenAIAdvancedSchedulerEffectiveWeightPriority) require.Equal(t, "3", settings.OpenAIAdvancedSchedulerEffectiveWeightLoad) require.Equal(t, "9", settings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost) require.Equal(t, "11", settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky) } func TestSettingService_UpdateSettings_AntigravityUserAgentVersion(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ AntigravityUserAgentVersion: "1.23.2", }) require.NoError(t, err) require.Equal(t, "1.23.2", repo.updates[SettingKeyAntigravityUserAgentVersion]) } func TestSettingService_InitializeDefaultSettingsPersistsConfiguredForwardedClientIPHeaders(t *testing.T) { repo := &forwardedIPMigrationRepoStub{values: map[string]string{}} cfg := &config.Config{} cfg.SetForwardedClientIPSettings(true, []string{"X-Cdn-Ip", "True-Client-Ip"}) svc := NewSettingService(repo, cfg) require.NoError(t, svc.InitializeDefaultSettings(context.Background())) require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.values[SettingKeyForwardedClientIPHeaders]) } func TestSettingService_UpdateSettings_APIKeyACLTrustForwardedIPRefreshesConfig(t *testing.T) { repo := &settingUpdateRepoStub{} cfg := &config.Config{} svc := NewSettingService(repo, cfg) err := svc.UpdateSettings(context.Background(), &SystemSettings{ APIKeyACLTrustForwardedIP: true, ForwardedClientIPHeaders: []string{" x-cdn-ip ", "X-CDN-IP", "true-client-ip"}, }) require.NoError(t, err) require.Equal(t, "true", repo.updates[SettingKeyAPIKeyACLTrustForwardedIP]) require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.updates[SettingKeyForwardedClientIPHeaders]) runtimeSettings := cfg.ForwardedClientIPSettings() require.True(t, runtimeSettings.TrustForwardedIP) require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, runtimeSettings.Headers) runtimeSettings.Headers[0] = "X-Mutated" require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers) } func TestSettingService_UpdateSettings_RejectsInvalidForwardedClientIPHeadersWithoutRefreshing(t *testing.T) { repo := &settingUpdateRepoStub{} cfg := &config.Config{} cfg.SetForwardedClientIPSettings(true, []string{"X-Existing-IP"}) svc := NewSettingService(repo, cfg) err := svc.UpdateSettings(context.Background(), &SystemSettings{ ForwardedClientIPHeaders: []string{"X Invalid"}, }) require.Error(t, err) require.Nil(t, repo.updates) runtimeSettings := cfg.ForwardedClientIPSettings() require.True(t, runtimeSettings.TrustForwardedIP) require.Equal(t, []string{"X-Existing-IP"}, runtimeSettings.Headers) } func TestSettingService_UpdateSettings_WriteFailureDoesNotRefreshForwardedIPRuntime(t *testing.T) { repo := &settingUpdateRepoStub{setMultipleErr: errors.New("database unavailable")} cfg := &config.Config{} cfg.SetForwardedClientIPSettings(false, []string{"X-Existing-IP"}) svc := NewSettingService(repo, cfg) err := svc.UpdateSettings(context.Background(), &SystemSettings{ APIKeyACLTrustForwardedIP: true, ForwardedClientIPHeaders: []string{"X-New-IP"}, }) require.ErrorContains(t, err, "database unavailable") runtimeSettings := cfg.ForwardedClientIPSettings() require.False(t, runtimeSettings.TrustForwardedIP) require.Equal(t, []string{"X-Existing-IP"}, runtimeSettings.Headers) } func TestSettingService_ParseSettings_APIKeyACLTrustForwardedIPFallsBackToConfigWhenMissing(t *testing.T) { cfg := &config.Config{} cfg.Security.TrustForwardedIPForAPIKeyACL = true svc := NewSettingService(&settingUpdateRepoStub{}, cfg) got := svc.parseSettings(map[string]string{}) require.True(t, got.APIKeyACLTrustForwardedIP) } func TestSettingService_ParseSettings_APIKeyACLTrustForwardedIPUsesStoredValue(t *testing.T) { cfg := &config.Config{} cfg.SetTrustForwardedIPForAPIKeyACL(true) svc := NewSettingService(&settingUpdateRepoStub{}, cfg) got := svc.parseSettings(map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}) require.False(t, got.APIKeyACLTrustForwardedIP) } func TestSettingService_ParseSettings_ForwardedClientIPHeaders(t *testing.T) { cfg := &config.Config{} cfg.SetForwardedClientIPSettings(true, []string{"X-Config-IP"}) svc := NewSettingService(&settingUpdateRepoStub{}, cfg) t.Run("stored value is normalized", func(t *testing.T) { got := svc.parseSettings(map[string]string{ SettingKeyForwardedClientIPHeaders: `[" x-cdn-ip ","X-CDN-IP","true-client-ip"]`, }) require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, got.ForwardedClientIPHeaders) }) t.Run("missing value falls back to config", func(t *testing.T) { got := svc.parseSettings(map[string]string{}) require.Equal(t, []string{"X-Config-IP"}, got.ForwardedClientIPHeaders) }) t.Run("malformed value disables forwarded trust", func(t *testing.T) { got := svc.parseSettings(map[string]string{ SettingKeyAPIKeyACLTrustForwardedIP: "true", SettingKeyForwardedClientIPHeaders: `{"not":"an array"}`, }) require.False(t, got.APIKeyACLTrustForwardedIP) require.Empty(t, got.ForwardedClientIPHeaders) }) } func TestSettingService_LoadForwardedClientIPSettingsMigration(t *testing.T) { tests := []struct { name string values map[string]string trustedProxiesSet bool configDefault bool wantEnabled bool wantForwardedIPUpdate string wantMigrationMarkerSet bool }{ { name: "missing setting follows configured default", values: map[string]string{}, configDefault: true, wantEnabled: true, wantMigrationMarkerSet: true, }, { name: "legacy false without proxy config migrates to compatibility", values: map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}, wantEnabled: true, wantForwardedIPUpdate: "true", wantMigrationMarkerSet: true, }, { name: "legacy false with explicit proxy config stays secure", values: map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}, trustedProxiesSet: true, wantEnabled: false, wantMigrationMarkerSet: true, }, { name: "completed migration preserves later false choice", values: map[string]string{ SettingKeyAPIKeyACLTrustForwardedIP: "false", settingKeyForwardedClientIPModeV2: "true", }, wantEnabled: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repo := &forwardedIPMigrationRepoStub{values: test.values} cfg := &config.Config{Server: config.ServerConfig{TrustedProxiesConfigured: test.trustedProxiesSet}} cfg.Security.TrustForwardedIPForAPIKeyACL = test.configDefault svc := NewSettingService(repo, cfg) require.NoError(t, svc.LoadForwardedClientIPSettings(context.Background())) require.Equal(t, test.wantEnabled, cfg.TrustForwardedIPForAPIKeyACL()) require.Equal(t, test.wantForwardedIPUpdate, repo.updates[SettingKeyAPIKeyACLTrustForwardedIP]) require.JSONEq(t, `[]`, repo.updates[SettingKeyForwardedClientIPHeaders]) if test.wantMigrationMarkerSet { require.Equal(t, "true", repo.updates[settingKeyForwardedClientIPModeV2]) } else { require.NotContains(t, repo.updates, settingKeyForwardedClientIPModeV2) } }) } } func TestSettingService_LoadForwardedClientIPSettingsLoadsHeaders(t *testing.T) { repo := &forwardedIPMigrationRepoStub{values: map[string]string{ SettingKeyAPIKeyACLTrustForwardedIP: "true", SettingKeyForwardedClientIPHeaders: `[" x-cdn-ip ","true-client-ip"]`, settingKeyForwardedClientIPModeV2: "true", }} cfg := &config.Config{} svc := NewSettingService(repo, cfg) require.NoError(t, svc.LoadForwardedClientIPSettings(context.Background())) runtimeSettings := cfg.ForwardedClientIPSettings() require.True(t, runtimeSettings.TrustForwardedIP) require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, runtimeSettings.Headers) require.Nil(t, repo.updates) } func TestSettingService_LoadForwardedClientIPSettingsMalformedHeadersDisablesCustomTrust(t *testing.T) { repo := &forwardedIPMigrationRepoStub{values: map[string]string{ SettingKeyAPIKeyACLTrustForwardedIP: "true", SettingKeyForwardedClientIPHeaders: `["X Invalid"]`, }} cfg := &config.Config{} svc := NewSettingService(repo, cfg) err := svc.LoadForwardedClientIPSettings(context.Background()) require.ErrorContains(t, err, "load forwarded client ip headers") runtimeSettings := cfg.ForwardedClientIPSettings() require.False(t, runtimeSettings.TrustForwardedIP) require.Empty(t, runtimeSettings.Headers) require.Equal(t, "true", repo.updates[settingKeyForwardedClientIPModeV2]) require.NotContains(t, repo.updates, SettingKeyAPIKeyACLTrustForwardedIP) } func TestSettingService_LoadForwardedClientIPSettingsBackfillsConfigHeaders(t *testing.T) { repo := &forwardedIPMigrationRepoStub{values: map[string]string{ settingKeyForwardedClientIPModeV2: "true", }} cfg := &config.Config{} cfg.SetForwardedClientIPSettings(false, []string{"X-Config-IP"}) svc := NewSettingService(repo, cfg) require.NoError(t, svc.LoadForwardedClientIPSettings(context.Background())) require.JSONEq(t, `["X-Config-IP"]`, repo.updates[SettingKeyForwardedClientIPHeaders]) require.Equal(t, []string{"X-Config-IP"}, cfg.ForwardedClientIPSettings().Headers) } func TestSettingService_LoadForwardedClientIPSettingsReadFailureFailsClosed(t *testing.T) { repo := &forwardedIPMigrationRepoStub{ getMultipleErr: errors.New("database unavailable"), } cfg := &config.Config{} cfg.SetTrustForwardedIPForAPIKeyACL(true) svc := NewSettingService(repo, cfg) err := svc.LoadForwardedClientIPSettings(context.Background()) require.ErrorContains(t, err, "get forwarded client ip settings") runtimeSettings := cfg.ForwardedClientIPSettings() require.False(t, runtimeSettings.TrustForwardedIP) require.Empty(t, runtimeSettings.Headers) } func TestSettingService_LoadForwardedClientIPSettingsWriteFailureUsesComputedMode(t *testing.T) { tests := []struct { name string trustedProxiesSet bool wantEnabled bool }{ {name: "compatibility migration remains effective", wantEnabled: true}, {name: "explicit proxy policy remains secure", trustedProxiesSet: true, wantEnabled: false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repo := &forwardedIPMigrationRepoStub{ values: map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}, setMultipleErr: errors.New("database unavailable"), } cfg := &config.Config{Server: config.ServerConfig{TrustedProxiesConfigured: test.trustedProxiesSet}} svc := NewSettingService(repo, cfg) err := svc.LoadForwardedClientIPSettings(context.Background()) require.ErrorContains(t, err, "migrate forwarded client ip setting") require.Equal(t, test.wantEnabled, cfg.TrustForwardedIPForAPIKeyACL()) }) } } func TestSettingService_GetAntigravityUserAgentVersion_Precedence(t *testing.T) { t.Run("后台设置优先", func(t *testing.T) { svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{ SettingKeyAntigravityUserAgentVersion: "1.24.0", }}, &config.Config{}) require.Equal(t, "1.24.0", svc.GetAntigravityUserAgentVersion(context.Background())) }) t.Run("空值回退配置默认值", func(t *testing.T) { svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{ SettingKeyAntigravityUserAgentVersion: "", }}, &config.Config{}) require.Equal(t, antigravity.GetDefaultUserAgentVersion(), svc.GetAntigravityUserAgentVersion(context.Background())) }) t.Run("缺失回退配置默认值", func(t *testing.T) { svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{}}, &config.Config{}) require.Equal(t, antigravity.GetDefaultUserAgentVersion(), svc.GetAntigravityUserAgentVersion(context.Background())) }) } func TestSettingService_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ PaymentVisibleMethodAlipaySource: "not-a-provider", }) require.Error(t, err) require.Equal(t, "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", infraerrors.Reason(err)) require.Nil(t, repo.updates) } func TestSettingService_PasskeySwitchPersistsAndDefaultsToConfigured(t *testing.T) { cfg := &config.Config{WebAuthn: config.WebAuthnConfig{ Enabled: true, RPID: "sub3.nebula-spaces.com", RPOrigins: []string{"https://sub3.nebula-spaces.com"}, }} runtimeRepo := &forwardedIPMigrationRepoStub{values: map[string]string{}} runtimeService := NewSettingService(runtimeRepo, cfg) enabled, err := runtimeService.PasskeyEnabled(context.Background()) require.NoError(t, err) require.True(t, enabled) updateRepo := &settingUpdateRepoStub{} updateService := NewSettingService(updateRepo, cfg) require.NoError(t, updateService.UpdateSettings(context.Background(), &SystemSettings{ PasskeyEnabled: false, })) require.Equal(t, "false", updateRepo.updates[SettingKeyPasskeyEnabled]) runtimeRepo.values[SettingKeyPasskeyEnabled] = "false" enabled, err = runtimeService.PasskeyEnabled(context.Background()) require.NoError(t, err) require.False(t, enabled) publicSettings, err := runtimeService.GetPublicSettings(context.Background()) require.NoError(t, err) require.False(t, publicSettings.PasskeyEnabled) } // 移除 WebAuthn 配置后,残留的 passkey_enabled="true" 不得再让 GetAllSettings // 报告开关开启:admin 更新门控以此为准,一旦误报为 true 会拒绝所有设置保存, // 而此时前端开关处于禁用态,管理员无法在 UI 里自救。 func TestSettingService_StalePasskeyTrueWithoutConfigReportsDisabled(t *testing.T) { repo := &settingGetAllRepoStub{values: map[string]string{ SettingKeyPasskeyEnabled: "true", }} service := NewSettingService(repo, &config.Config{}) settings, err := service.GetAllSettings(context.Background()) require.NoError(t, err) require.False(t, settings.PasskeyEnabled) }