auth_dingtalk_oauth_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // TestDingTalkOAuthStart_Disabled は sentinel テスト。
  16. // TODO(task-1.10): newTestAuthHandlerWithDingTalk helper が追加されたら t.Skip を外す。
  17. func TestDingTalkOAuthStart_Disabled(t *testing.T) {
  18. t.Skip("helper newTestAuthHandlerWithDingTalk added in Task 1.10; sentinel only")
  19. }
  20. // TestBuildDingTalkSyntheticEmail_UsesUnionID 验证合成邮箱种子使用 unionID。
  21. func TestBuildDingTalkSyntheticEmail_UsesUnionID(t *testing.T) {
  22. unionID := "union_AbCdEf123"
  23. email := buildDingTalkSyntheticEmail(unionID)
  24. want := "dingtalk-union_abcdef123@dingtalk-connect.invalid"
  25. require.Equal(t, want, email)
  26. // 确保结果都是小写(邮箱大小写不敏感,统一小写)
  27. require.True(t, strings.ToLower(email) == email, "synthetic email should be all lowercase")
  28. // 确保前缀正确
  29. require.True(t, strings.HasPrefix(email, "dingtalk-"), "should have dingtalk- prefix")
  30. // 确保后缀是合成邮箱域名
  31. require.True(t, strings.HasSuffix(email, "@dingtalk-connect.invalid"), "should have reserved domain suffix")
  32. }
  33. // TestBuildDingTalkSyntheticEmail_TrimsSpace 验证 unionID 空白被修剪。
  34. func TestBuildDingTalkSyntheticEmail_TrimsSpace(t *testing.T) {
  35. email := buildDingTalkSyntheticEmail(" UID_XYZ ")
  36. require.Equal(t, "dingtalk-uid_xyz@dingtalk-connect.invalid", email)
  37. }
  38. // TestBuildDingTalkUpstreamClaims_EmptyStaff 验证 staff 为空 struct(跨组织降级路径)时:
  39. // - subject 等于 unionID(与 identityKey.ProviderSubject 一致)
  40. // - corp_user_id 为空字符串(跨组织时拿不到企业 userid)
  41. // - email/username 为空字符串
  42. // B/C: Step 3/4 失败降级时 staff = &DingTalkStaffInfo{},claims 不应有 nil。
  43. func TestBuildDingTalkUpstreamClaims_EmptyStaff(t *testing.T) {
  44. staff := &DingTalkStaffInfo{}
  45. claims := buildDingTalkUpstreamClaims(staff, "UNION_AAA", "CORP_X")
  46. require.Equal(t, "", claims["email"])
  47. require.Equal(t, "", claims["username"])
  48. // 重构后 subject = unionID(与 identityKey.ProviderSubject 保持一致)
  49. require.Equal(t, "UNION_AAA", claims["subject"])
  50. require.Equal(t, "", claims["corp_user_id"]) // 企业 userid 跨组织时为空
  51. require.Equal(t, "UNION_AAA", claims["union_id"])
  52. require.Equal(t, "CORP_X", claims["corp_id"])
  53. }
  54. // TestCheckDingTalkCorpAllowed_CrossOrgPolicy 验证 policy=none 时允许任意 corp。
  55. // D: corp 校验提前后逻辑不变。
  56. func TestCheckDingTalkCorpAllowed_CrossOrgPolicy(t *testing.T) {
  57. cfg := config.DingTalkConnectConfig{CorpRestrictionPolicy: "none"}
  58. assert.True(t, checkDingTalkCorpAllowed(cfg, "dingABC"), "policy=none should allow any corp")
  59. assert.True(t, checkDingTalkCorpAllowed(cfg, ""), "policy=none should allow empty corp")
  60. assert.True(t, checkDingTalkCorpAllowed(cfg, "foreign_corp"), "policy=none should allow foreign corp")
  61. }
  62. // TestCheckDingTalkCorpAllowed_InternalOnly 验证 policy=internal_only 时的 corp 校验语义(方案 A 修订)。
  63. // 钉钉 userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)不返回 corpId 字段,
  64. // 因此 checkDingTalkCorpAllowed 完全不校验 corpID,由 step 3 GetUserIdByUnionId 做真实判定
  65. // (跨企业用户会被钉钉错误码 60011/60121 拒绝,mapDingTalkErrorCode 映射回 corp_rejected)。
  66. func TestCheckDingTalkCorpAllowed_InternalOnly(t *testing.T) {
  67. cfgWithCorpID := config.DingTalkConnectConfig{
  68. CorpRestrictionPolicy: "internal_only",
  69. InternalCorpID: "dingInternal",
  70. }
  71. assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "dingInternal"), "internal_only: matching corpID allowed")
  72. assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "foreign_corp"), "internal_only: corpID 字段不再用于决策,step 3 兜底")
  73. assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, ""), "internal_only: 空 corpID 也通过(钉钉部分授权场景不返回 corpId)")
  74. cfgNoCorpID := config.DingTalkConnectConfig{
  75. CorpRestrictionPolicy: "internal_only",
  76. InternalCorpID: "",
  77. }
  78. assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, "dingAnyNonEmpty"), "internal_only + no InternalCorpID: 非空 corpID 通过")
  79. assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, ""), "internal_only + no InternalCorpID: 空 corpID 也通过")
  80. }
  81. // TestDecideDingTalkStep34Strategy_PolicyNone 验证 policy=none 时
  82. // Step 3/4 失败应降级(shouldFallback=true, isFatal=false)。
  83. func TestDecideDingTalkStep34Strategy_PolicyNone(t *testing.T) {
  84. step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
  85. shouldFallback, isFatal := decideDingTalkStep34Strategy("none", step3Err)
  86. require.True(t, shouldFallback, "policy=none: step3 failure should trigger fallback")
  87. require.False(t, isFatal, "policy=none: step3 failure should NOT be fatal")
  88. }
  89. // TestDecideDingTalkStep34Strategy_PolicyNoneEmpty 验证 policy="" 时行为与 "none" 相同。
  90. func TestDecideDingTalkStep34Strategy_PolicyNoneEmpty(t *testing.T) {
  91. stepErr := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
  92. shouldFallback, isFatal := decideDingTalkStep34Strategy("", stepErr)
  93. require.True(t, shouldFallback, "policy='': step failure should trigger fallback")
  94. require.False(t, isFatal, "policy='': step failure should NOT be fatal")
  95. }
  96. // TestDecideDingTalkStep34Strategy_PolicyInternalOnly 验证 policy=internal_only 时
  97. // Step 3/4 失败应 hard fail(isFatal=true)。
  98. func TestDecideDingTalkStep34Strategy_PolicyInternalOnly(t *testing.T) {
  99. step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
  100. shouldFallback, isFatal := decideDingTalkStep34Strategy("internal_only", step3Err)
  101. require.False(t, shouldFallback, "policy=internal_only: should NOT fallback on step3 error")
  102. require.True(t, isFatal, "policy=internal_only: step3 failure should be fatal")
  103. }
  104. // TestDecideDingTalkStep34Strategy_NoError 验证 stepErr=nil 时两个返回值均为 false。
  105. func TestDecideDingTalkStep34Strategy_NoError(t *testing.T) {
  106. for _, policy := range []string{"none", "internal_only", ""} {
  107. shouldFallback, isFatal := decideDingTalkStep34Strategy(policy, nil)
  108. require.False(t, shouldFallback, "no error should not trigger fallback (policy=%q)", policy)
  109. require.False(t, isFatal, "no error should not be fatal (policy=%q)", policy)
  110. }
  111. }
  112. // TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart 验证 username 为空时
  113. // 退到 email local part(@ 之前的部分)。
  114. // E: CompleteDingTalkOAuthRegistration username fallback。
  115. func TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart(t *testing.T) {
  116. tests := []struct {
  117. name string
  118. email string
  119. username string
  120. wantUser string
  121. wantValid bool
  122. }{
  123. {
  124. name: "username empty, normal email → local part",
  125. email: "dingtalk-uid123@dingtalk-connect.invalid",
  126. username: "",
  127. wantUser: "dingtalk-uid123",
  128. wantValid: true,
  129. },
  130. {
  131. name: "username already set → keep original",
  132. email: "user@example.com",
  133. username: "张三",
  134. wantUser: "张三",
  135. wantValid: true,
  136. },
  137. {
  138. name: "username empty, no @ in email → use whole email",
  139. email: "noemail",
  140. username: "",
  141. wantUser: "noemail",
  142. wantValid: true,
  143. },
  144. {
  145. name: "both empty → invalid",
  146. email: "",
  147. username: "",
  148. wantUser: "",
  149. wantValid: false,
  150. },
  151. }
  152. for _, tc := range tests {
  153. t.Run(tc.name, func(t *testing.T) {
  154. username := tc.username
  155. email := tc.email
  156. // 模拟 CompleteDingTalkOAuthRegistration 中的 fallback 逻辑
  157. if username == "" {
  158. if at := strings.Index(email, "@"); at > 0 {
  159. username = email[:at]
  160. } else {
  161. username = email
  162. }
  163. }
  164. isValid := email != "" && username != ""
  165. require.Equal(t, tc.wantUser, username, fmt.Sprintf("username for email=%q", tc.email))
  166. require.Equal(t, tc.wantValid, isValid, "validity check")
  167. })
  168. }
  169. }
  170. // TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID 验证重构后 subject = unionID
  171. // 而非 staff.UserID,与 identityKey.ProviderSubject 保持一致。
  172. // §4.2: buildDingTalkUpstreamClaims subject 字段修正。
  173. func TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID(t *testing.T) {
  174. staff := &DingTalkStaffInfo{UserID: "user123", Name: "张三", Email: "zhangsan@corp.com"}
  175. claims := buildDingTalkUpstreamClaims(staff, "union456", "dingcorp789")
  176. // 重构后 subject = unionID(全局唯一,与 identityKey.ProviderSubject 一致)
  177. require.Equal(t, "union456", claims["subject"], "subject should equal unionID after refactor")
  178. // 企业 userid 保留为独立字段,供 audit/debug 使用
  179. require.Equal(t, "user123", claims["corp_user_id"], "corp_user_id should be staff.UserID")
  180. // union_id 字段与 subject 相同(冗余保留,便于读取)
  181. require.Equal(t, "union456", claims["union_id"])
  182. require.Equal(t, "dingcorp789", claims["corp_id"])
  183. require.Equal(t, "张三", claims["username"])
  184. require.Equal(t, "zhangsan@corp.com", claims["email"])
  185. }
  186. // TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID 验证跨组织降级时
  187. // corp_user_id 为空字符串(跨组织拿不到企业 userid),subject 仍为 unionID。
  188. func TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID(t *testing.T) {
  189. // 跨组织降级路径:staff = &DingTalkStaffInfo{}(所有字段为零值)
  190. staff := &DingTalkStaffInfo{}
  191. claims := buildDingTalkUpstreamClaims(staff, "union_cross_org", "foreign_corp")
  192. require.Equal(t, "union_cross_org", claims["subject"], "subject should still be unionID for cross-org users")
  193. require.Equal(t, "", claims["corp_user_id"], "corp_user_id should be empty for cross-org fallback")
  194. require.Equal(t, "", claims["email"])
  195. require.Equal(t, "", claims["username"])
  196. }
  197. // TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims 验证首个 dept_id 被存入 claims。
  198. func TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims(t *testing.T) {
  199. staff := &DingTalkStaffInfo{UserID: "u1", Name: "张三", Email: "a@b.com", DeptIDs: []int64{42, 99}}
  200. claims := buildDingTalkUpstreamClaims(staff, "uid1", "corpX")
  201. // 只取首个 dept_id
  202. require.Equal(t, int64(42), claims["primary_dept_id"], "primary_dept_id should be the first dept_id")
  203. }
  204. // TestBuildDingTalkUpstreamClaims_NoDeptIDs 验证无部门时 primary_dept_id=0。
  205. func TestBuildDingTalkUpstreamClaims_NoDeptIDs(t *testing.T) {
  206. staff := &DingTalkStaffInfo{UserID: "u2", Name: "李四"}
  207. claims := buildDingTalkUpstreamClaims(staff, "uid2", "corpY")
  208. require.Equal(t, int64(0), claims["primary_dept_id"], "primary_dept_id should be 0 when no depts")
  209. }
  210. // TestDingTalkStaffFromClaims_RoundTrip 验证 dingTalkStaffFromClaims 能从 claims 恢复 staff 信息。
  211. func TestDingTalkStaffFromClaims_RoundTrip(t *testing.T) {
  212. staff := &DingTalkStaffInfo{UserID: "u3", Name: "王五", Email: "ww@corp.com", DeptIDs: []int64{55}}
  213. claims := buildDingTalkUpstreamClaims(staff, "uid3", "corpZ")
  214. recovered := dingTalkStaffFromClaims(claims)
  215. require.Equal(t, "王五", recovered.Name)
  216. require.Equal(t, "ww@corp.com", recovered.Email)
  217. require.Equal(t, "u3", recovered.UserID)
  218. require.Equal(t, []int64{55}, recovered.DeptIDs)
  219. }
  220. // TestResolveDingTalkDeptPath_SingleLevel 验证单层部门(parent_id=1)返回部门名。
  221. func TestResolveDingTalkDeptPath_SingleLevel(t *testing.T) {
  222. handler := &AuthHandler{}
  223. callCount := 0
  224. responses := map[string]string{
  225. "42": `{"errcode":0,"result":{"dept_id":42,"name":"研发部","parent_id":1}}`,
  226. "1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`,
  227. }
  228. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  229. callCount++
  230. var req struct {
  231. DeptID int64 `json:"dept_id"`
  232. }
  233. _ = json.NewDecoder(r.Body).Decode(&req)
  234. w.Header().Set("Content-Type", "application/json")
  235. if resp, ok := responses[fmt.Sprintf("%d", req.DeptID)]; ok {
  236. _, _ = w.Write([]byte(resp))
  237. } else {
  238. _, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`))
  239. }
  240. }))
  241. defer server.Close()
  242. cli := &DingTalkClient{
  243. cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"},
  244. httpClient: server.Client(),
  245. }
  246. cli.appToken = "tok"
  247. cli.appTokenExp = time.Now().Add(time.Hour)
  248. path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42)
  249. require.NoError(t, err)
  250. require.Equal(t, "研发部", path)
  251. require.Equal(t, 2, callCount)
  252. }
  253. // TestSyncDingTalkIdentity_UsesCfgAttrKeys 验证 syncDingTalkIdentity 使用 cfg 中配置的 attr key
  254. // 而不是硬编码值。通过 userAttributeService=nil 使同步路径走 warn 跳过,但在此之前先验证
  255. // syncField 构建逻辑(即 attr key 从 cfg 读取)。
  256. // 间接验证:通过构造定制 cfg,确认不同 attr key 可以正确传入(编译时保证类型正确,运行时不 panic)。
  257. func TestSyncDingTalkIdentity_UsesCfgAttrKeys_NoopWithNilService(t *testing.T) {
  258. handler := &AuthHandler{
  259. userAttributeService: nil, // nil → 触发 warn 跳过,但不 panic
  260. }
  261. cfg := config.DingTalkConnectConfig{
  262. CorpRestrictionPolicy: "internal_only",
  263. SyncCorpEmail: true,
  264. SyncDisplayName: true,
  265. SyncDept: true,
  266. // 自定义 attr key(非默认值)
  267. SyncCorpEmailAttrKey: "custom_email_key",
  268. SyncDisplayNameAttrKey: "custom_name_key",
  269. SyncDeptAttrKey: "custom_dept_key",
  270. }
  271. staff := &DingTalkStaffInfo{
  272. Name: "张三",
  273. Email: "zhangsan@example.com",
  274. }
  275. // 调用不应 panic(userAttributeService 为 nil 时走 warn 跳过路径)
  276. require.NotPanics(t, func() {
  277. handler.syncDingTalkIdentity(context.Background(), cfg, nil, 42, staff, false)
  278. })
  279. }
  280. // TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService 验证 cfg 默认 attr key 为空时
  281. // 使用 fallback 默认值(dingtalk_email / dingtalk_name / dingtalk_department)。
  282. // 此测试主要验证调用路径不 panic;实际 key 赋值默认值的逻辑在 GetDingTalkConnectOAuthConfig 层。
  283. func TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService(t *testing.T) {
  284. handler := &AuthHandler{
  285. userAttributeService: nil,
  286. }
  287. cfg := config.DingTalkConnectConfig{
  288. CorpRestrictionPolicy: "internal_only",
  289. SyncCorpEmail: true,
  290. SyncDisplayName: true,
  291. SyncDept: false,
  292. // 不设置 attr key(等同于 GetDingTalkConnectOAuthConfig 未设置时 fallback 后的默认值已在调用前填充)
  293. SyncCorpEmailAttrKey: "dingtalk_email",
  294. SyncDisplayNameAttrKey: "dingtalk_name",
  295. SyncDeptAttrKey: "dingtalk_department",
  296. }
  297. staff := &DingTalkStaffInfo{
  298. Name: "李四",
  299. Email: "lisi@corp.com",
  300. }
  301. require.NotPanics(t, func() {
  302. handler.syncDingTalkIdentity(context.Background(), cfg, nil, 99, staff, false)
  303. })
  304. }
  305. // TestResolveDingTalkDeptPath_MultiLevel 验证多层部门路径拼接。
  306. func TestResolveDingTalkDeptPath_MultiLevel(t *testing.T) {
  307. handler := &AuthHandler{}
  308. // 模拟:42(AI研发) → parent=10(研发部) → parent=1(根)
  309. responses := map[string]string{
  310. "42": `{"errcode":0,"result":{"dept_id":42,"name":"AI研发","parent_id":10}}`,
  311. "10": `{"errcode":0,"result":{"dept_id":10,"name":"研发部","parent_id":1}}`,
  312. "1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`,
  313. }
  314. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  315. // 解析请求 body 拿到 dept_id
  316. var req struct {
  317. DeptID int64 `json:"dept_id"`
  318. }
  319. _ = json.NewDecoder(r.Body).Decode(&req)
  320. key := fmt.Sprintf("%d", req.DeptID)
  321. w.Header().Set("Content-Type", "application/json")
  322. if resp, ok := responses[key]; ok {
  323. _, _ = w.Write([]byte(resp))
  324. } else {
  325. _, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`))
  326. }
  327. }))
  328. defer server.Close()
  329. cli := &DingTalkClient{
  330. cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"},
  331. httpClient: server.Client(),
  332. }
  333. cli.appToken = "tok"
  334. cli.appTokenExp = time.Now().Add(time.Hour)
  335. path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42)
  336. require.NoError(t, err)
  337. require.Equal(t, "研发部/AI研发", path)
  338. }