package admin import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) // step-up 开关转换的门控测试。 // 测试环境不注入认证上下文/userService,因此一旦触发校验会以 401/403/500 中止; // 借此区分「触发了转换校验」与「直接放行到常规保存(200)」。 func newStepUpSwitchTestHandler(t *testing.T, stored map[string]string) (*SettingHandler, *settingHandlerRepoStub) { t.Helper() gin.SetMode(gin.TestMode) repo := &settingHandlerRepoStub{values: stored} svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) return NewSettingHandler(svc, nil, nil, nil, nil, nil, nil), repo } func doUpdateSettings(t *testing.T, h *SettingHandler, body map[string]any, prepare func(c *gin.Context)) *httptest.ResponseRecorder { t.Helper() rawBody, err := json.Marshal(body) require.NoError(t, err) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) c.Request.Header.Set("Content-Type", "application/json") if prepare != nil { prepare(c) } h.UpdateSettings(c) return rec } // 开启开关(false→true):无认证上下文时拒绝,且带专用错误标记。 func TestUpdateSettingsEnableStepUpRejectsWithoutSession(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil) require.Equal(t, http.StatusForbidden, rec.Code) require.Contains(t, rec.Body.String(), "STEP_UP_ENABLE_REQUIRES_TOTP") require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled]) } // 开启开关:admin API key(机器凭证)一律拒绝,reason 与门控保持一致便于前端分流。 func TestUpdateSettingsEnableStepUpRejectsAdminAPIKey(t *testing.T) { h, _ := newStepUpSwitchTestHandler(t, map[string]string{}) rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) { c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) }) require.Equal(t, http.StatusForbidden, rec.Code) require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") } // 开启开关:有认证会话但 userService 未注入时 fail-closed(500),不得放行。 func TestUpdateSettingsEnableStepUpFailsClosedWithoutUserService(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) { c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1}) }) require.Equal(t, http.StatusInternalServerError, rec.Code) require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled]) } // 关闭开关(true→false)本身是敏感操作:无认证上下文时被 step-up 门控以 401 拦截。 func TestUpdateSettingsDisableStepUpRequiresStepUp(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{ service.SettingKeyStepUpEnabled: "true", }) rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil) require.Equal(t, http.StatusUnauthorized, rec.Code) require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) } // 关闭开关:admin API key 被 step-up 门控以 403 拦截。 func TestUpdateSettingsDisableStepUpRejectsAdminAPIKey(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{ service.SettingKeyStepUpEnabled: "true", }) rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, func(c *gin.Context) { c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) }) require.Equal(t, http.StatusForbidden, rec.Code) require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) } // 无状态转换(false→false):不触发任何转换校验,常规保存成功且默认持久化为 false。 func TestUpdateSettingsStepUpNoTransitionSkipsGate(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled]) // 会话 IP/UA 绑定默认关闭:未显式提交时持久化 false。 require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled]) } // 保持开启(true→true):不触发转换校验,常规保存不被打断。 func TestUpdateSettingsStepUpKeepEnabledSkipsGate(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{ service.SettingKeyStepUpEnabled: "true", }) rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) } // 省略字段=保持现值:不含 step_up_enabled/session_binding_enabled 的旧客户端全量保存 // 不得把已开启的安全开关静默重置,也不触发任何转换门控。 func TestUpdateSettingsOmittedSecuritySwitchesKeepStoredValues(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{ service.SettingKeyStepUpEnabled: "true", service.SettingKeySessionBindingEnabled: "true", }) rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) require.Equal(t, "true", repo.values[service.SettingKeySessionBindingEnabled]) } // 省略字段在开关本就关闭时同样保持关闭(默认值路径)。 func TestUpdateSettingsOmittedSecuritySwitchesKeepDisabled(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled]) require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled]) } func TestUpdateSettingsForwardedClientIPHeadersOmittedPreservesAndEmptyClears(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{ service.SettingKeyForwardedClientIPHeaders: `["X-Cdn-Ip","True-Client-Ip"]`, }) preserved := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) require.Equal(t, http.StatusOK, preserved.Code) require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) require.Contains(t, preserved.Body.String(), `"forwarded_client_ip_headers":["X-Cdn-Ip","True-Client-Ip"]`) cleared := doUpdateSettings(t, h, map[string]any{"forwarded_client_ip_headers": []string{}}, nil) require.Equal(t, http.StatusOK, cleared.Code) require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) require.Contains(t, cleared.Body.String(), `"forwarded_client_ip_headers":[]`) } func TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted(t *testing.T) { cfg := &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}} repo := &settingHandlerRepoStub{values: map[string]string{ service.SettingKeyAPIKeyACLTrustForwardedIP: "true", service.SettingKeyForwardedClientIPHeaders: `{"not":"an array"}`, }} svc := service.NewSettingService(repo, cfg) require.ErrorContains(t, svc.LoadForwardedClientIPSettings(context.Background()), "load forwarded client ip headers") require.False(t, cfg.ForwardedClientIPSettings().TrustForwardedIP) h := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "false", repo.values[service.SettingKeyAPIKeyACLTrustForwardedIP]) require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) runtimeSettings := cfg.ForwardedClientIPSettings() require.False(t, runtimeSettings.TrustForwardedIP) require.Empty(t, runtimeSettings.Headers) require.Contains(t, rec.Body.String(), `"api_key_acl_trust_forwarded_ip":false`) require.Contains(t, rec.Body.String(), `"forwarded_client_ip_headers":[]`) } func TestUpdateSettingsRejectsInvalidForwardedClientIPHeader(t *testing.T) { h, repo := newStepUpSwitchTestHandler(t, map[string]string{ service.SettingKeyForwardedClientIPHeaders: `["X-Existing-IP"]`, }) rec := doUpdateSettings(t, h, map[string]any{ "forwarded_client_ip_headers": []string{"X Invalid"}, }, nil) require.Equal(t, http.StatusBadRequest, rec.Code) require.JSONEq(t, `["X-Existing-IP"]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) }