setting_handler_stepup_switch_test.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. package admin
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // step-up 开关转换的门控测试。
  16. // 测试环境不注入认证上下文/userService,因此一旦触发校验会以 401/403/500 中止;
  17. // 借此区分「触发了转换校验」与「直接放行到常规保存(200)」。
  18. func newStepUpSwitchTestHandler(t *testing.T, stored map[string]string) (*SettingHandler, *settingHandlerRepoStub) {
  19. t.Helper()
  20. gin.SetMode(gin.TestMode)
  21. repo := &settingHandlerRepoStub{values: stored}
  22. svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
  23. return NewSettingHandler(svc, nil, nil, nil, nil, nil, nil), repo
  24. }
  25. func doUpdateSettings(t *testing.T, h *SettingHandler, body map[string]any, prepare func(c *gin.Context)) *httptest.ResponseRecorder {
  26. t.Helper()
  27. rawBody, err := json.Marshal(body)
  28. require.NoError(t, err)
  29. rec := httptest.NewRecorder()
  30. c, _ := gin.CreateTestContext(rec)
  31. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  32. c.Request.Header.Set("Content-Type", "application/json")
  33. if prepare != nil {
  34. prepare(c)
  35. }
  36. h.UpdateSettings(c)
  37. return rec
  38. }
  39. // 开启开关(false→true):无认证上下文时拒绝,且带专用错误标记。
  40. func TestUpdateSettingsEnableStepUpRejectsWithoutSession(t *testing.T) {
  41. h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
  42. rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil)
  43. require.Equal(t, http.StatusForbidden, rec.Code)
  44. require.Contains(t, rec.Body.String(), "STEP_UP_ENABLE_REQUIRES_TOTP")
  45. require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled])
  46. }
  47. // 开启开关:admin API key(机器凭证)一律拒绝,reason 与门控保持一致便于前端分流。
  48. func TestUpdateSettingsEnableStepUpRejectsAdminAPIKey(t *testing.T) {
  49. h, _ := newStepUpSwitchTestHandler(t, map[string]string{})
  50. rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) {
  51. c.Set("auth_method", service.AuditAuthMethodAdminAPIKey)
  52. })
  53. require.Equal(t, http.StatusForbidden, rec.Code)
  54. require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
  55. }
  56. // 开启开关:有认证会话但 userService 未注入时 fail-closed(500),不得放行。
  57. func TestUpdateSettingsEnableStepUpFailsClosedWithoutUserService(t *testing.T) {
  58. h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
  59. rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) {
  60. c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1})
  61. })
  62. require.Equal(t, http.StatusInternalServerError, rec.Code)
  63. require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled])
  64. }
  65. // 关闭开关(true→false)本身是敏感操作:无认证上下文时被 step-up 门控以 401 拦截。
  66. func TestUpdateSettingsDisableStepUpRequiresStepUp(t *testing.T) {
  67. h, repo := newStepUpSwitchTestHandler(t, map[string]string{
  68. service.SettingKeyStepUpEnabled: "true",
  69. })
  70. rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil)
  71. require.Equal(t, http.StatusUnauthorized, rec.Code)
  72. require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
  73. }
  74. // 关闭开关:admin API key 被 step-up 门控以 403 拦截。
  75. func TestUpdateSettingsDisableStepUpRejectsAdminAPIKey(t *testing.T) {
  76. h, repo := newStepUpSwitchTestHandler(t, map[string]string{
  77. service.SettingKeyStepUpEnabled: "true",
  78. })
  79. rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, func(c *gin.Context) {
  80. c.Set("auth_method", service.AuditAuthMethodAdminAPIKey)
  81. })
  82. require.Equal(t, http.StatusForbidden, rec.Code)
  83. require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
  84. require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
  85. }
  86. // 无状态转换(false→false):不触发任何转换校验,常规保存成功且默认持久化为 false。
  87. func TestUpdateSettingsStepUpNoTransitionSkipsGate(t *testing.T) {
  88. h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
  89. rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil)
  90. require.Equal(t, http.StatusOK, rec.Code)
  91. require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled])
  92. // 会话 IP/UA 绑定默认关闭:未显式提交时持久化 false。
  93. require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled])
  94. }
  95. // 保持开启(true→true):不触发转换校验,常规保存不被打断。
  96. func TestUpdateSettingsStepUpKeepEnabledSkipsGate(t *testing.T) {
  97. h, repo := newStepUpSwitchTestHandler(t, map[string]string{
  98. service.SettingKeyStepUpEnabled: "true",
  99. })
  100. rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil)
  101. require.Equal(t, http.StatusOK, rec.Code)
  102. require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
  103. }
  104. // 省略字段=保持现值:不含 step_up_enabled/session_binding_enabled 的旧客户端全量保存
  105. // 不得把已开启的安全开关静默重置,也不触发任何转换门控。
  106. func TestUpdateSettingsOmittedSecuritySwitchesKeepStoredValues(t *testing.T) {
  107. h, repo := newStepUpSwitchTestHandler(t, map[string]string{
  108. service.SettingKeyStepUpEnabled: "true",
  109. service.SettingKeySessionBindingEnabled: "true",
  110. })
  111. rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
  112. require.Equal(t, http.StatusOK, rec.Code)
  113. require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled])
  114. require.Equal(t, "true", repo.values[service.SettingKeySessionBindingEnabled])
  115. }
  116. // 省略字段在开关本就关闭时同样保持关闭(默认值路径)。
  117. func TestUpdateSettingsOmittedSecuritySwitchesKeepDisabled(t *testing.T) {
  118. h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
  119. rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
  120. require.Equal(t, http.StatusOK, rec.Code)
  121. require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled])
  122. require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled])
  123. }
  124. func TestUpdateSettingsForwardedClientIPHeadersOmittedPreservesAndEmptyClears(t *testing.T) {
  125. h, repo := newStepUpSwitchTestHandler(t, map[string]string{
  126. service.SettingKeyForwardedClientIPHeaders: `["X-Cdn-Ip","True-Client-Ip"]`,
  127. })
  128. preserved := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
  129. require.Equal(t, http.StatusOK, preserved.Code)
  130. require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
  131. require.Contains(t, preserved.Body.String(), `"forwarded_client_ip_headers":["X-Cdn-Ip","True-Client-Ip"]`)
  132. cleared := doUpdateSettings(t, h, map[string]any{"forwarded_client_ip_headers": []string{}}, nil)
  133. require.Equal(t, http.StatusOK, cleared.Code)
  134. require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
  135. require.Contains(t, cleared.Body.String(), `"forwarded_client_ip_headers":[]`)
  136. }
  137. func TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted(t *testing.T) {
  138. cfg := &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}
  139. repo := &settingHandlerRepoStub{values: map[string]string{
  140. service.SettingKeyAPIKeyACLTrustForwardedIP: "true",
  141. service.SettingKeyForwardedClientIPHeaders: `{"not":"an array"}`,
  142. }}
  143. svc := service.NewSettingService(repo, cfg)
  144. require.ErrorContains(t, svc.LoadForwardedClientIPSettings(context.Background()), "load forwarded client ip headers")
  145. require.False(t, cfg.ForwardedClientIPSettings().TrustForwardedIP)
  146. h := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
  147. rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil)
  148. require.Equal(t, http.StatusOK, rec.Code)
  149. require.Equal(t, "false", repo.values[service.SettingKeyAPIKeyACLTrustForwardedIP])
  150. require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
  151. runtimeSettings := cfg.ForwardedClientIPSettings()
  152. require.False(t, runtimeSettings.TrustForwardedIP)
  153. require.Empty(t, runtimeSettings.Headers)
  154. require.Contains(t, rec.Body.String(), `"api_key_acl_trust_forwarded_ip":false`)
  155. require.Contains(t, rec.Body.String(), `"forwarded_client_ip_headers":[]`)
  156. }
  157. func TestUpdateSettingsRejectsInvalidForwardedClientIPHeader(t *testing.T) {
  158. h, repo := newStepUpSwitchTestHandler(t, map[string]string{
  159. service.SettingKeyForwardedClientIPHeaders: `["X-Existing-IP"]`,
  160. })
  161. rec := doUpdateSettings(t, h, map[string]any{
  162. "forwarded_client_ip_headers": []string{"X Invalid"},
  163. }, nil)
  164. require.Equal(t, http.StatusBadRequest, rec.Code)
  165. require.JSONEq(t, `["X-Existing-IP"]`, repo.values[service.SettingKeyForwardedClientIPHeaders])
  166. }