setting_handler_dingtalk_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. package admin
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. // dingtalkSettingsRepoStub 复用 settingHandlerRepoStub(已在 setting_handler_auth_source_defaults_test.go 定义)
  15. func newDingTalkSettingsHandler() (*SettingHandler, *settingHandlerRepoStub) {
  16. repo := &settingHandlerRepoStub{values: map[string]string{}}
  17. svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
  18. handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
  19. return handler, repo
  20. }
  21. // baseValidDingTalkBody 返回一个可以通过所有校验的最小合法 body。
  22. func baseValidDingTalkBody() map[string]any {
  23. return map[string]any{
  24. "dingtalk_connect_enabled": true,
  25. "dingtalk_connect_client_id": "test-client-id",
  26. "dingtalk_connect_client_secret": "test-client-secret",
  27. "dingtalk_connect_redirect_url": "https://example.com/auth/dingtalk/callback",
  28. "dingtalk_connect_corp_restriction_policy": "none",
  29. }
  30. }
  31. // TestSettingsPUT_DingTalk_V3_InternalOnlyAllowsEmptyCorpID 验证方案 A:
  32. // internal_only + internal_corp_id="" 应通过校验(→ 200),不再是 400。
  33. func TestSettingsPUT_DingTalk_V3_InternalOnlyAllowsEmptyCorpID(t *testing.T) {
  34. gin.SetMode(gin.TestMode)
  35. handler, _ := newDingTalkSettingsHandler()
  36. body := baseValidDingTalkBody()
  37. body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
  38. body["dingtalk_connect_internal_corp_id"] = "" // 空值现在合法
  39. rawBody, err := json.Marshal(body)
  40. require.NoError(t, err)
  41. rec := httptest.NewRecorder()
  42. c, _ := gin.CreateTestContext(rec)
  43. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  44. c.Request.Header.Set("Content-Type", "application/json")
  45. handler.UpdateSettings(c)
  46. require.Equal(t, http.StatusOK, rec.Code)
  47. }
  48. // TestSettingsPUT_DingTalk_HappyPath_None 验证 none policy → 200
  49. func TestSettingsPUT_DingTalk_HappyPath_None(t *testing.T) {
  50. gin.SetMode(gin.TestMode)
  51. handler, _ := newDingTalkSettingsHandler()
  52. body := baseValidDingTalkBody()
  53. body["dingtalk_connect_corp_restriction_policy"] = "none"
  54. rawBody, err := json.Marshal(body)
  55. require.NoError(t, err)
  56. rec := httptest.NewRecorder()
  57. c, _ := gin.CreateTestContext(rec)
  58. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  59. c.Request.Header.Set("Content-Type", "application/json")
  60. handler.UpdateSettings(c)
  61. require.Equal(t, http.StatusOK, rec.Code)
  62. var resp response.Response
  63. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  64. data, ok := resp.Data.(map[string]any)
  65. require.True(t, ok)
  66. require.Equal(t, true, data["dingtalk_connect_enabled"])
  67. }
  68. // TestSettingsPUT_DingTalk_HappyPath_InternalOnly_WithCorpID 验证 internal_only + corp_id → 200
  69. func TestSettingsPUT_DingTalk_HappyPath_InternalOnly_WithCorpID(t *testing.T) {
  70. gin.SetMode(gin.TestMode)
  71. handler, _ := newDingTalkSettingsHandler()
  72. body := baseValidDingTalkBody()
  73. body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
  74. body["dingtalk_connect_internal_corp_id"] = "ding-corp-123"
  75. rawBody, err := json.Marshal(body)
  76. require.NoError(t, err)
  77. rec := httptest.NewRecorder()
  78. c, _ := gin.CreateTestContext(rec)
  79. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  80. c.Request.Header.Set("Content-Type", "application/json")
  81. handler.UpdateSettings(c)
  82. require.Equal(t, http.StatusOK, rec.Code)
  83. }
  84. // TestSettingsPUT_DingTalk_BypassRegistration_RoundTrip 验证 bypass_registration 字段 save+load。
  85. // 必须用 policy=internal_only:bypass 仅在该 policy 下生效,其它 policy 写入层会 coerce 为 false。
  86. func TestSettingsPUT_DingTalk_BypassRegistration_RoundTrip(t *testing.T) {
  87. gin.SetMode(gin.TestMode)
  88. handler, _ := newDingTalkSettingsHandler()
  89. body := baseValidDingTalkBody()
  90. body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
  91. body["dingtalk_connect_bypass_registration"] = true
  92. rawBody, err := json.Marshal(body)
  93. require.NoError(t, err)
  94. rec := httptest.NewRecorder()
  95. c, _ := gin.CreateTestContext(rec)
  96. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  97. c.Request.Header.Set("Content-Type", "application/json")
  98. handler.UpdateSettings(c)
  99. require.Equal(t, http.StatusOK, rec.Code)
  100. var resp response.Response
  101. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  102. data, ok := resp.Data.(map[string]any)
  103. require.True(t, ok)
  104. require.Equal(t, true, data["dingtalk_connect_bypass_registration"])
  105. }
  106. // TestSettingsPUT_DingTalk_Disabled_SkipsValidation 验证 disabled 时跳过 corp 校验 → 200。
  107. // 用 enabled=true 时必然触发"Client ID is required when enabled"的空 client_id 作为
  108. // 哨兵——只要 enabled=false 仍能 200 就证明跳过了。
  109. func TestSettingsPUT_DingTalk_Disabled_SkipsValidation(t *testing.T) {
  110. gin.SetMode(gin.TestMode)
  111. handler, _ := newDingTalkSettingsHandler()
  112. body := map[string]any{
  113. "dingtalk_connect_enabled": false,
  114. "dingtalk_connect_client_id": "", // 这种空值在 enabled=true 时会被 400 拒绝
  115. "dingtalk_connect_corp_restriction_policy": "internal_only",
  116. }
  117. rawBody, err := json.Marshal(body)
  118. require.NoError(t, err)
  119. rec := httptest.NewRecorder()
  120. c, _ := gin.CreateTestContext(rec)
  121. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  122. c.Request.Header.Set("Content-Type", "application/json")
  123. handler.UpdateSettings(c)
  124. require.Equal(t, http.StatusOK, rec.Code)
  125. }
  126. // TestSettingsPUT_DingTalk_SyncFlags_InternalOnly_RoundTrip 验证三个 sync 开关在 internal_only 下可正常 save+load。
  127. func TestSettingsPUT_DingTalk_SyncFlags_InternalOnly_RoundTrip(t *testing.T) {
  128. gin.SetMode(gin.TestMode)
  129. handler, _ := newDingTalkSettingsHandler()
  130. body := baseValidDingTalkBody()
  131. body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
  132. body["dingtalk_connect_sync_corp_email"] = true
  133. body["dingtalk_connect_sync_display_name"] = true
  134. body["dingtalk_connect_sync_dept"] = true
  135. rawBody, err := json.Marshal(body)
  136. require.NoError(t, err)
  137. rec := httptest.NewRecorder()
  138. c, _ := gin.CreateTestContext(rec)
  139. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  140. c.Request.Header.Set("Content-Type", "application/json")
  141. handler.UpdateSettings(c)
  142. require.Equal(t, http.StatusOK, rec.Code)
  143. var resp response.Response
  144. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  145. data, ok := resp.Data.(map[string]any)
  146. require.True(t, ok)
  147. require.Equal(t, true, data["dingtalk_connect_sync_corp_email"], "sync_corp_email should be true for internal_only")
  148. require.Equal(t, true, data["dingtalk_connect_sync_display_name"], "sync_display_name should be true for internal_only")
  149. require.Equal(t, true, data["dingtalk_connect_sync_dept"], "sync_dept should be true for internal_only")
  150. }
  151. // TestSettingsPUT_DingTalk_SyncFlags_PolicyNone_CoercedToFalse 验证 policy=none 时三个 sync 开关被 coerce 为 false。
  152. func TestSettingsPUT_DingTalk_SyncFlags_PolicyNone_CoercedToFalse(t *testing.T) {
  153. gin.SetMode(gin.TestMode)
  154. handler, _ := newDingTalkSettingsHandler()
  155. body := baseValidDingTalkBody()
  156. body["dingtalk_connect_corp_restriction_policy"] = "none"
  157. body["dingtalk_connect_sync_corp_email"] = true
  158. body["dingtalk_connect_sync_display_name"] = true
  159. body["dingtalk_connect_sync_dept"] = true
  160. rawBody, err := json.Marshal(body)
  161. require.NoError(t, err)
  162. rec := httptest.NewRecorder()
  163. c, _ := gin.CreateTestContext(rec)
  164. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  165. c.Request.Header.Set("Content-Type", "application/json")
  166. handler.UpdateSettings(c)
  167. require.Equal(t, http.StatusOK, rec.Code)
  168. var resp response.Response
  169. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  170. data, ok := resp.Data.(map[string]any)
  171. require.True(t, ok)
  172. require.Equal(t, false, data["dingtalk_connect_sync_corp_email"], "sync_corp_email must be coerced to false when policy=none")
  173. require.Equal(t, false, data["dingtalk_connect_sync_display_name"], "sync_display_name must be coerced to false when policy=none")
  174. require.Equal(t, false, data["dingtalk_connect_sync_dept"], "sync_dept must be coerced to false when policy=none")
  175. }
  176. // TestSettingsPUT_DingTalk_StaleWhitelist_CoercedToNone 验证升级兼容:
  177. // admin 直接把 corp_restriction_policy=whitelist 提交(前端 UI 已无此选项,但 API 仍可命中)
  178. // 不应导致 400 失败,应该被静默 coerce 为 none 后通过校验。
  179. func TestSettingsPUT_DingTalk_StaleWhitelist_CoercedToNone(t *testing.T) {
  180. gin.SetMode(gin.TestMode)
  181. handler, repo := newDingTalkSettingsHandler()
  182. body := baseValidDingTalkBody()
  183. body["dingtalk_connect_corp_restriction_policy"] = "whitelist"
  184. rawBody, err := json.Marshal(body)
  185. require.NoError(t, err)
  186. rec := httptest.NewRecorder()
  187. c, _ := gin.CreateTestContext(rec)
  188. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  189. c.Request.Header.Set("Content-Type", "application/json")
  190. handler.UpdateSettings(c)
  191. require.Equal(t, http.StatusOK, rec.Code)
  192. require.Equal(t, "none", repo.values[service.SettingKeyDingTalkConnectCorpRestrictionPolicy],
  193. "stale whitelist 应在写入路径被 coerce 为 none")
  194. }
  195. // TestSettingsPUT_DingTalk_SyncAttrKey_RoundTrip 验证 3 个 attr key 字段 save+load + 空值 fallback 到默认值。
  196. func TestSettingsPUT_DingTalk_SyncAttrKey_RoundTrip(t *testing.T) {
  197. gin.SetMode(gin.TestMode)
  198. t.Run("custom_attr_keys_saved", func(t *testing.T) {
  199. handler, repo := newDingTalkSettingsHandler()
  200. body := baseValidDingTalkBody()
  201. body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
  202. body["dingtalk_connect_sync_corp_email"] = true
  203. body["dingtalk_connect_sync_display_name"] = true
  204. body["dingtalk_connect_sync_dept"] = true
  205. body["dingtalk_connect_sync_corp_email_attr_key"] = "my_email_attr"
  206. body["dingtalk_connect_sync_display_name_attr_key"] = "my_name_attr"
  207. body["dingtalk_connect_sync_dept_attr_key"] = "my_dept_attr"
  208. rawBody, err := json.Marshal(body)
  209. require.NoError(t, err)
  210. rec := httptest.NewRecorder()
  211. c, _ := gin.CreateTestContext(rec)
  212. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  213. c.Request.Header.Set("Content-Type", "application/json")
  214. handler.UpdateSettings(c)
  215. require.Equal(t, http.StatusOK, rec.Code)
  216. // 验证写入 DB 的 key
  217. require.Equal(t, "my_email_attr", repo.values[service.SettingKeyDingTalkConnectSyncCorpEmailAttrKey])
  218. require.Equal(t, "my_name_attr", repo.values[service.SettingKeyDingTalkConnectSyncDisplayNameAttrKey])
  219. require.Equal(t, "my_dept_attr", repo.values[service.SettingKeyDingTalkConnectSyncDeptAttrKey])
  220. // 验证响应中的 attr key
  221. var resp response.Response
  222. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  223. data, ok := resp.Data.(map[string]any)
  224. require.True(t, ok)
  225. require.Equal(t, "my_email_attr", data["dingtalk_connect_sync_corp_email_attr_key"])
  226. require.Equal(t, "my_name_attr", data["dingtalk_connect_sync_display_name_attr_key"])
  227. require.Equal(t, "my_dept_attr", data["dingtalk_connect_sync_dept_attr_key"])
  228. })
  229. t.Run("empty_attr_keys_fallback_to_defaults", func(t *testing.T) {
  230. handler, repo := newDingTalkSettingsHandler()
  231. body := baseValidDingTalkBody()
  232. body["dingtalk_connect_corp_restriction_policy"] = "internal_only"
  233. // 不传 attr key → 写入层 fallback 到默认值
  234. body["dingtalk_connect_sync_corp_email_attr_key"] = ""
  235. body["dingtalk_connect_sync_display_name_attr_key"] = ""
  236. body["dingtalk_connect_sync_dept_attr_key"] = ""
  237. rawBody, err := json.Marshal(body)
  238. require.NoError(t, err)
  239. rec := httptest.NewRecorder()
  240. c, _ := gin.CreateTestContext(rec)
  241. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
  242. c.Request.Header.Set("Content-Type", "application/json")
  243. handler.UpdateSettings(c)
  244. require.Equal(t, http.StatusOK, rec.Code)
  245. // 空值应 fallback 到默认值并持久化
  246. require.Equal(t, "dingtalk_email", repo.values[service.SettingKeyDingTalkConnectSyncCorpEmailAttrKey])
  247. require.Equal(t, "dingtalk_name", repo.values[service.SettingKeyDingTalkConnectSyncDisplayNameAttrKey])
  248. require.Equal(t, "dingtalk_department", repo.values[service.SettingKeyDingTalkConnectSyncDeptAttrKey])
  249. })
  250. }