user_handler_test.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  1. //go:build unit
  2. package handler
  3. import (
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "net/http"
  8. "net/http/httptest"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  13. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. "github.com/gin-gonic/gin"
  16. "github.com/stretchr/testify/require"
  17. )
  18. type userHandlerRepoStub struct {
  19. user *service.User
  20. identities []service.UserAuthIdentityRecord
  21. unbound []string
  22. }
  23. func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
  24. func (s *userHandlerRepoStub) CreateWithEmailAliasGuard(context.Context, *service.User) error {
  25. return nil
  26. }
  27. func (s *userHandlerRepoStub) GetByID(context.Context, int64) (*service.User, error) {
  28. cloned := *s.user
  29. return &cloned, nil
  30. }
  31. func (s *userHandlerRepoStub) GetByEmail(context.Context, string) (*service.User, error) {
  32. cloned := *s.user
  33. return &cloned, nil
  34. }
  35. func (s *userHandlerRepoStub) GetFirstAdmin(context.Context) (*service.User, error) {
  36. cloned := *s.user
  37. return &cloned, nil
  38. }
  39. func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User, _ service.UserUpdateFields) error {
  40. cloned := *user
  41. s.user = &cloned
  42. return nil
  43. }
  44. func (s *userHandlerRepoStub) Delete(context.Context, int64) error { return nil }
  45. func (s *userHandlerRepoStub) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) {
  46. if s.user == nil || s.user.AvatarURL == "" {
  47. return nil, nil
  48. }
  49. return &service.UserAvatar{
  50. StorageProvider: s.user.AvatarSource,
  51. URL: s.user.AvatarURL,
  52. ContentType: s.user.AvatarMIME,
  53. ByteSize: s.user.AvatarByteSize,
  54. SHA256: s.user.AvatarSHA256,
  55. }, nil
  56. }
  57. func (s *userHandlerRepoStub) UpsertUserAvatar(_ context.Context, _ int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
  58. s.user.AvatarURL = input.URL
  59. s.user.AvatarSource = input.StorageProvider
  60. s.user.AvatarMIME = input.ContentType
  61. s.user.AvatarByteSize = input.ByteSize
  62. s.user.AvatarSHA256 = input.SHA256
  63. return &service.UserAvatar{
  64. StorageProvider: input.StorageProvider,
  65. URL: input.URL,
  66. ContentType: input.ContentType,
  67. ByteSize: input.ByteSize,
  68. SHA256: input.SHA256,
  69. }, nil
  70. }
  71. func (s *userHandlerRepoStub) DeleteUserAvatar(context.Context, int64) error {
  72. s.user.AvatarURL = ""
  73. s.user.AvatarSource = ""
  74. s.user.AvatarMIME = ""
  75. s.user.AvatarByteSize = 0
  76. s.user.AvatarSHA256 = ""
  77. return nil
  78. }
  79. func (s *userHandlerRepoStub) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
  80. return nil, nil, nil
  81. }
  82. func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
  83. return nil, nil, nil
  84. }
  85. func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
  86. func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
  87. func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
  88. func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
  89. return 0, nil
  90. }
  91. func (s *userHandlerRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
  92. panic("unexpected AdjustBalance call")
  93. }
  94. func (s *userHandlerRepoStub) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
  95. panic("unexpected SetBalance call")
  96. }
  97. func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
  98. return 0, nil
  99. }
  100. func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
  101. return 0, nil
  102. }
  103. func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
  104. func (s *userHandlerRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) {
  105. return false, nil
  106. }
  107. func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
  108. return 0, nil
  109. }
  110. func (s *userHandlerRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error {
  111. return nil
  112. }
  113. func (s *userHandlerRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
  114. return map[int64]*time.Time{}, nil
  115. }
  116. func (s *userHandlerRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
  117. return nil, nil
  118. }
  119. func (s *userHandlerRepoStub) UpdateUserLastActiveAt(_ context.Context, _ int64, activeAt time.Time) error {
  120. if s.user != nil {
  121. s.user.LastActiveAt = &activeAt
  122. }
  123. return nil
  124. }
  125. func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
  126. return nil
  127. }
  128. func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
  129. func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
  130. func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
  131. func (s *userHandlerRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
  132. return s.GetByID(ctx, id)
  133. }
  134. func (s *userHandlerRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
  135. out := make([]service.UserAuthIdentityRecord, len(s.identities))
  136. copy(out, s.identities)
  137. return out, nil
  138. }
  139. func (s *userHandlerRepoStub) UnbindUserAuthProvider(_ context.Context, _ int64, provider string) error {
  140. s.unbound = append(s.unbound, provider)
  141. filtered := s.identities[:0]
  142. for _, identity := range s.identities {
  143. if identity.ProviderType == provider {
  144. continue
  145. }
  146. filtered = append(filtered, identity)
  147. }
  148. s.identities = append([]service.UserAuthIdentityRecord(nil), filtered...)
  149. return nil
  150. }
  151. func TestUserHandlerUpdateProfileReturnsAvatarURL(t *testing.T) {
  152. gin.SetMode(gin.TestMode)
  153. repo := &userHandlerRepoStub{
  154. user: &service.User{
  155. ID: 11,
  156. Email: "handler-avatar@example.com",
  157. Username: "handler-avatar",
  158. Role: service.RoleUser,
  159. Status: service.StatusActive,
  160. },
  161. }
  162. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil, nil, nil)
  163. body := []byte(`{"avatar_url":"https://cdn.example.com/avatar.png"}`)
  164. recorder := httptest.NewRecorder()
  165. c, _ := gin.CreateTestContext(recorder)
  166. c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/user", bytes.NewReader(body))
  167. c.Request.Header.Set("Content-Type", "application/json")
  168. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
  169. handler.UpdateProfile(c)
  170. require.Equal(t, http.StatusOK, recorder.Code)
  171. var resp struct {
  172. Code int `json:"code"`
  173. Data struct {
  174. AvatarURL string `json:"avatar_url"`
  175. Username string `json:"username"`
  176. } `json:"data"`
  177. }
  178. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  179. require.Equal(t, 0, resp.Code)
  180. require.Equal(t, "https://cdn.example.com/avatar.png", resp.Data.AvatarURL)
  181. require.Equal(t, "handler-avatar", resp.Data.Username)
  182. }
  183. func TestUserHandlerGetProfileReturnsIdentitySummaries(t *testing.T) {
  184. gin.SetMode(gin.TestMode)
  185. verifiedAt := time.Date(2026, 4, 20, 8, 30, 0, 0, time.UTC)
  186. repo := &userHandlerRepoStub{
  187. user: &service.User{
  188. ID: 11,
  189. Email: "identity@example.com",
  190. Username: "identity-user",
  191. Role: service.RoleUser,
  192. Status: service.StatusActive,
  193. },
  194. identities: []service.UserAuthIdentityRecord{
  195. {
  196. ProviderType: "linuxdo",
  197. ProviderKey: "linuxdo",
  198. ProviderSubject: "linuxdo-subject-123456",
  199. VerifiedAt: &verifiedAt,
  200. Metadata: map[string]any{
  201. "username": "linuxdo-handle",
  202. },
  203. },
  204. {
  205. ProviderType: "oidc",
  206. ProviderKey: "https://issuer.example.com",
  207. ProviderSubject: "oidc-user-abc",
  208. Metadata: map[string]any{
  209. "suggested_display_name": "OIDC Display",
  210. },
  211. },
  212. },
  213. }
  214. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil, nil, nil)
  215. recorder := httptest.NewRecorder()
  216. c, _ := gin.CreateTestContext(recorder)
  217. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
  218. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
  219. handler.GetProfile(c)
  220. require.Equal(t, http.StatusOK, recorder.Code)
  221. var resp struct {
  222. Code int `json:"code"`
  223. Data struct {
  224. Identities struct {
  225. Email struct {
  226. Bound bool `json:"bound"`
  227. BoundCount int `json:"bound_count"`
  228. DisplayName string `json:"display_name"`
  229. } `json:"email"`
  230. LinuxDo struct {
  231. Bound bool `json:"bound"`
  232. BoundCount int `json:"bound_count"`
  233. DisplayName string `json:"display_name"`
  234. ProviderKey string `json:"provider_key"`
  235. } `json:"linuxdo"`
  236. OIDC struct {
  237. Bound bool `json:"bound"`
  238. DisplayName string `json:"display_name"`
  239. ProviderKey string `json:"provider_key"`
  240. } `json:"oidc"`
  241. WeChat struct {
  242. Bound bool `json:"bound"`
  243. CanBind bool `json:"can_bind"`
  244. BindStartPath string `json:"bind_start_path"`
  245. } `json:"wechat"`
  246. } `json:"identities"`
  247. } `json:"data"`
  248. }
  249. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  250. require.Equal(t, 0, resp.Code)
  251. require.True(t, resp.Data.Identities.Email.Bound)
  252. require.Equal(t, 1, resp.Data.Identities.Email.BoundCount)
  253. require.Equal(t, "identity@example.com", resp.Data.Identities.Email.DisplayName)
  254. require.True(t, resp.Data.Identities.LinuxDo.Bound)
  255. require.Equal(t, 1, resp.Data.Identities.LinuxDo.BoundCount)
  256. require.Equal(t, "linuxdo-handle", resp.Data.Identities.LinuxDo.DisplayName)
  257. require.Equal(t, "linuxdo", resp.Data.Identities.LinuxDo.ProviderKey)
  258. require.True(t, resp.Data.Identities.OIDC.Bound)
  259. require.Equal(t, "OIDC Display", resp.Data.Identities.OIDC.DisplayName)
  260. require.Equal(t, "https://issuer.example.com", resp.Data.Identities.OIDC.ProviderKey)
  261. require.False(t, resp.Data.Identities.WeChat.Bound)
  262. require.True(t, resp.Data.Identities.WeChat.CanBind)
  263. require.Contains(t, resp.Data.Identities.WeChat.BindStartPath, "/api/v1/auth/oauth/wechat/bind/start")
  264. }
  265. func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
  266. gin.SetMode(gin.TestMode)
  267. verifiedAt := time.Date(2026, 4, 20, 8, 30, 0, 0, time.UTC)
  268. repo := &userHandlerRepoStub{
  269. user: &service.User{
  270. ID: 21,
  271. Email: "legacy-profile@example.com",
  272. Username: "linuxdo-handle",
  273. Role: service.RoleUser,
  274. Status: service.StatusActive,
  275. AvatarURL: "https://cdn.example.com/linuxdo.png",
  276. AvatarSource: "remote_url",
  277. },
  278. identities: []service.UserAuthIdentityRecord{
  279. {
  280. ProviderType: "linuxdo",
  281. ProviderKey: "linuxdo",
  282. ProviderSubject: "linuxdo-subject-21",
  283. VerifiedAt: &verifiedAt,
  284. Metadata: map[string]any{
  285. "username": "linuxdo-handle",
  286. "avatar_url": "https://cdn.example.com/linuxdo.png",
  287. },
  288. },
  289. },
  290. }
  291. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil, nil, nil)
  292. recorder := httptest.NewRecorder()
  293. c, _ := gin.CreateTestContext(recorder)
  294. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
  295. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 21})
  296. handler.GetProfile(c)
  297. require.Equal(t, http.StatusOK, recorder.Code)
  298. var resp struct {
  299. Code int `json:"code"`
  300. Data map[string]any `json:"data"`
  301. }
  302. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  303. require.Equal(t, 0, resp.Code)
  304. require.Equal(t, true, resp.Data["email_bound"])
  305. require.Equal(t, true, resp.Data["linuxdo_bound"])
  306. require.Equal(t, false, resp.Data["oidc_bound"])
  307. require.Equal(t, false, resp.Data["wechat_bound"])
  308. require.Equal(t, "https://cdn.example.com/linuxdo.png", resp.Data["avatar_url"])
  309. avatarSource, ok := resp.Data["avatar_source"].(map[string]any)
  310. require.True(t, ok)
  311. require.Equal(t, "linuxdo", avatarSource["provider"])
  312. require.Equal(t, "linuxdo", avatarSource["source"])
  313. authBindings, ok := resp.Data["auth_bindings"].(map[string]any)
  314. require.True(t, ok)
  315. linuxdoBinding, ok := authBindings["linuxdo"].(map[string]any)
  316. require.True(t, ok)
  317. require.Equal(t, true, linuxdoBinding["bound"])
  318. require.Equal(t, "linuxdo", linuxdoBinding["provider"])
  319. identityBindings, ok := resp.Data["identity_bindings"].(map[string]any)
  320. require.True(t, ok)
  321. emailBinding, ok := identityBindings["email"].(map[string]any)
  322. require.True(t, ok)
  323. require.Equal(t, true, emailBinding["bound"])
  324. require.Equal(t, "profile.authBindings.notes.emailManagedFromProfile", emailBinding["note_key"])
  325. linuxdoCompatBinding, ok := identityBindings["linuxdo"].(map[string]any)
  326. require.True(t, ok)
  327. require.Equal(t, "profile.authBindings.notes.canUnbind", linuxdoCompatBinding["note_key"])
  328. profileSources, ok := resp.Data["profile_sources"].(map[string]any)
  329. require.True(t, ok)
  330. usernameSource, ok := profileSources["username"].(map[string]any)
  331. require.True(t, ok)
  332. require.Equal(t, "linuxdo", usernameSource["provider"])
  333. require.Equal(t, "linuxdo", usernameSource["source"])
  334. }
  335. func TestUserHandlerGetProfileDoesNotInferEditedProfileSourcesWithoutMatchingIdentityMetadata(t *testing.T) {
  336. gin.SetMode(gin.TestMode)
  337. repo := &userHandlerRepoStub{
  338. user: &service.User{
  339. ID: 22,
  340. Email: "edited-profile@example.com",
  341. Username: "custom-name",
  342. Role: service.RoleUser,
  343. Status: service.StatusActive,
  344. AvatarURL: "https://cdn.example.com/custom.png",
  345. AvatarSource: "remote_url",
  346. },
  347. identities: []service.UserAuthIdentityRecord{
  348. {
  349. ProviderType: "linuxdo",
  350. ProviderKey: "linuxdo",
  351. ProviderSubject: "linuxdo-subject-22",
  352. Metadata: map[string]any{
  353. "username": "linuxdo-handle",
  354. "avatar_url": "https://cdn.example.com/linuxdo.png",
  355. },
  356. },
  357. },
  358. }
  359. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil, nil, nil)
  360. recorder := httptest.NewRecorder()
  361. c, _ := gin.CreateTestContext(recorder)
  362. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
  363. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 22})
  364. handler.GetProfile(c)
  365. require.Equal(t, http.StatusOK, recorder.Code)
  366. var resp struct {
  367. Code int `json:"code"`
  368. Data map[string]any `json:"data"`
  369. }
  370. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  371. require.Equal(t, 0, resp.Code)
  372. require.NotContains(t, resp.Data, "avatar_source")
  373. require.NotContains(t, resp.Data, "username_source")
  374. require.NotContains(t, resp.Data, "profile_sources")
  375. }
  376. type userHandlerEmailCacheStub struct {
  377. data *service.VerificationCodeData
  378. }
  379. type userHandlerRefreshTokenCacheStub struct {
  380. revokedUserIDs []int64
  381. }
  382. func (s *userHandlerRefreshTokenCacheStub) StoreRefreshToken(context.Context, string, *service.RefreshTokenData, time.Duration) error {
  383. return nil
  384. }
  385. func (s *userHandlerRefreshTokenCacheStub) GetRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
  386. return nil, service.ErrRefreshTokenNotFound
  387. }
  388. func (s *userHandlerRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
  389. return nil
  390. }
  391. func (s *userHandlerRefreshTokenCacheStub) DeleteUserRefreshTokens(_ context.Context, userID int64) error {
  392. s.revokedUserIDs = append(s.revokedUserIDs, userID)
  393. return nil
  394. }
  395. func (s *userHandlerRefreshTokenCacheStub) DeleteTokenFamily(context.Context, string) error {
  396. return nil
  397. }
  398. func (s *userHandlerRefreshTokenCacheStub) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
  399. return nil
  400. }
  401. func (s *userHandlerRefreshTokenCacheStub) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
  402. return nil
  403. }
  404. func (s *userHandlerRefreshTokenCacheStub) GetUserTokenHashes(context.Context, int64) ([]string, error) {
  405. return nil, nil
  406. }
  407. func (s *userHandlerRefreshTokenCacheStub) GetFamilyTokenHashes(context.Context, string) ([]string, error) {
  408. return nil, nil
  409. }
  410. func (s *userHandlerRefreshTokenCacheStub) IsTokenInFamily(context.Context, string, string) (bool, error) {
  411. return false, nil
  412. }
  413. func (s *userHandlerEmailCacheStub) GetVerificationCode(context.Context, string) (*service.VerificationCodeData, error) {
  414. return s.data, nil
  415. }
  416. func (s *userHandlerEmailCacheStub) SetVerificationCode(context.Context, string, *service.VerificationCodeData, time.Duration) error {
  417. return nil
  418. }
  419. func (s *userHandlerEmailCacheStub) DeleteVerificationCode(context.Context, string) error {
  420. return nil
  421. }
  422. func (s *userHandlerEmailCacheStub) GetNotifyVerifyCode(context.Context, string) (*service.VerificationCodeData, error) {
  423. return nil, nil
  424. }
  425. func (s *userHandlerEmailCacheStub) SetNotifyVerifyCode(context.Context, string, *service.VerificationCodeData, time.Duration) error {
  426. return nil
  427. }
  428. func (s *userHandlerEmailCacheStub) DeleteNotifyVerifyCode(context.Context, string) error {
  429. return nil
  430. }
  431. func (s *userHandlerEmailCacheStub) GetPasswordResetToken(context.Context, string) (*service.PasswordResetTokenData, error) {
  432. return nil, nil
  433. }
  434. func (s *userHandlerEmailCacheStub) SetPasswordResetToken(context.Context, string, *service.PasswordResetTokenData, time.Duration) error {
  435. return nil
  436. }
  437. func (s *userHandlerEmailCacheStub) DeletePasswordResetToken(context.Context, string) error {
  438. return nil
  439. }
  440. func (s *userHandlerEmailCacheStub) IsPasswordResetEmailInCooldown(context.Context, string) bool {
  441. return false
  442. }
  443. func (s *userHandlerEmailCacheStub) SetPasswordResetEmailCooldown(context.Context, string, time.Duration) error {
  444. return nil
  445. }
  446. func (s *userHandlerEmailCacheStub) GetNotifyCodeUserRate(context.Context, int64) (int64, error) {
  447. return 0, nil
  448. }
  449. func (s *userHandlerEmailCacheStub) IncrNotifyCodeUserRate(context.Context, int64, time.Duration) (int64, error) {
  450. return 0, nil
  451. }
  452. func TestUserHandlerBindEmailIdentityReturnsProfileResponse(t *testing.T) {
  453. gin.SetMode(gin.TestMode)
  454. repo := &userHandlerRepoStub{
  455. user: &service.User{
  456. ID: 11,
  457. Email: "legacy-user" + service.LinuxDoConnectSyntheticEmailDomain,
  458. Username: "legacy-user",
  459. Role: service.RoleUser,
  460. Status: service.StatusActive,
  461. },
  462. }
  463. emailCache := &userHandlerEmailCacheStub{
  464. data: &service.VerificationCodeData{
  465. Code: "123456",
  466. CreatedAt: time.Now().UTC(),
  467. ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
  468. },
  469. }
  470. cfg := &config.Config{
  471. JWT: config.JWTConfig{
  472. Secret: "test-secret",
  473. ExpireHour: 1,
  474. },
  475. }
  476. emailService := service.NewEmailService(nil, emailCache)
  477. authService := service.NewAuthService(nil, repo, nil, nil, cfg, nil, emailService, nil, nil, nil, nil, nil, nil)
  478. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil, nil, nil)
  479. body := []byte(`{"email":"new@example.com","verify_code":"123456","password":"new-password"}`)
  480. recorder := httptest.NewRecorder()
  481. c, _ := gin.CreateTestContext(recorder)
  482. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/account-bindings/email", bytes.NewReader(body))
  483. c.Request.Header.Set("Content-Type", "application/json")
  484. c.Params = gin.Params{{Key: "provider", Value: "email"}}
  485. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
  486. handler.BindEmailIdentity(c)
  487. require.Equal(t, http.StatusOK, recorder.Code)
  488. var resp struct {
  489. Code int `json:"code"`
  490. Data struct {
  491. Email string `json:"email"`
  492. EmailBound bool `json:"email_bound"`
  493. } `json:"data"`
  494. }
  495. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  496. require.Equal(t, 0, resp.Code)
  497. require.Equal(t, "new@example.com", resp.Data.Email)
  498. require.True(t, resp.Data.EmailBound)
  499. }
  500. func TestUserHandlerUnbindIdentityReturnsUpdatedProfile(t *testing.T) {
  501. gin.SetMode(gin.TestMode)
  502. repo := &userHandlerRepoStub{
  503. user: &service.User{
  504. ID: 21,
  505. Email: "identity@example.com",
  506. Username: "identity-user",
  507. Role: service.RoleUser,
  508. Status: service.StatusActive,
  509. },
  510. identities: []service.UserAuthIdentityRecord{
  511. {
  512. ProviderType: "email",
  513. ProviderKey: "email",
  514. ProviderSubject: "identity@example.com",
  515. },
  516. {
  517. ProviderType: "linuxdo",
  518. ProviderKey: "linuxdo",
  519. ProviderSubject: "linuxdo-subject-21",
  520. Metadata: map[string]any{
  521. "username": "linuxdo-handle",
  522. },
  523. },
  524. },
  525. }
  526. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil, nil, nil)
  527. recorder := httptest.NewRecorder()
  528. c, _ := gin.CreateTestContext(recorder)
  529. c.Request = httptest.NewRequest(http.MethodDelete, "/api/v1/user/account-bindings/linuxdo", nil)
  530. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 21})
  531. c.Params = gin.Params{{Key: "provider", Value: "linuxdo"}}
  532. handler.UnbindIdentity(c)
  533. require.Equal(t, http.StatusOK, recorder.Code)
  534. require.Equal(t, []string{"linuxdo"}, repo.unbound)
  535. var resp struct {
  536. Code int `json:"code"`
  537. Data map[string]any `json:"data"`
  538. }
  539. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  540. require.Equal(t, 0, resp.Code)
  541. authBindings, ok := resp.Data["auth_bindings"].(map[string]any)
  542. require.True(t, ok)
  543. linuxdoBinding, ok := authBindings["linuxdo"].(map[string]any)
  544. require.True(t, ok)
  545. require.Equal(t, false, linuxdoBinding["bound"])
  546. }
  547. func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigured(t *testing.T) {
  548. gin.SetMode(gin.TestMode)
  549. repo := &userHandlerRepoStub{
  550. user: &service.User{
  551. ID: 23,
  552. Email: "identity@example.com",
  553. Username: "identity-user",
  554. Role: service.RoleUser,
  555. Status: service.StatusActive,
  556. TokenVersion: 4,
  557. },
  558. identities: []service.UserAuthIdentityRecord{
  559. {
  560. ProviderType: "email",
  561. ProviderKey: "email",
  562. ProviderSubject: "identity@example.com",
  563. },
  564. {
  565. ProviderType: "linuxdo",
  566. ProviderKey: "linuxdo",
  567. ProviderSubject: "linuxdo-subject-23",
  568. },
  569. },
  570. }
  571. refreshTokenCache := &userHandlerRefreshTokenCacheStub{}
  572. cfg := &config.Config{
  573. JWT: config.JWTConfig{
  574. Secret: "test-secret",
  575. ExpireHour: 1,
  576. },
  577. }
  578. authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
  579. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil, nil, nil)
  580. recorder := httptest.NewRecorder()
  581. c, _ := gin.CreateTestContext(recorder)
  582. c.Request = httptest.NewRequest(http.MethodDelete, "/api/v1/user/account-bindings/linuxdo", nil)
  583. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 23})
  584. c.Params = gin.Params{{Key: "provider", Value: "linuxdo"}}
  585. handler.UnbindIdentity(c)
  586. require.Equal(t, http.StatusOK, recorder.Code)
  587. require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs)
  588. // 撤销依赖的是 refresh session 清理,而不是 token_version:users 表没有这一列
  589. // (见 resolvedTokenVersion,实际值由 email+password_hash 指纹推导),
  590. // 所以此前"自增 TokenVersion 再整行写回"不持久化任何东西,
  591. // 却会用旧快照覆盖并发写入的列。这里断言用户行未被改写。
  592. require.Equal(t, int64(4), repo.user.TokenVersion)
  593. }
  594. func TestUserHandlerUnbindIdentityDoesNotRevokeSessionsWhenNothingWasUnbound(t *testing.T) {
  595. gin.SetMode(gin.TestMode)
  596. repo := &userHandlerRepoStub{
  597. user: &service.User{
  598. ID: 24,
  599. Email: "identity@example.com",
  600. Username: "identity-user",
  601. Role: service.RoleUser,
  602. Status: service.StatusActive,
  603. TokenVersion: 4,
  604. },
  605. identities: []service.UserAuthIdentityRecord{
  606. {
  607. ProviderType: "email",
  608. ProviderKey: "email",
  609. ProviderSubject: "identity@example.com",
  610. },
  611. },
  612. }
  613. refreshTokenCache := &userHandlerRefreshTokenCacheStub{}
  614. cfg := &config.Config{
  615. JWT: config.JWTConfig{
  616. Secret: "test-secret",
  617. ExpireHour: 1,
  618. },
  619. }
  620. authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
  621. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil, nil, nil)
  622. recorder := httptest.NewRecorder()
  623. c, _ := gin.CreateTestContext(recorder)
  624. c.Request = httptest.NewRequest(http.MethodDelete, "/api/v1/user/account-bindings/linuxdo", nil)
  625. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 24})
  626. c.Params = gin.Params{{Key: "provider", Value: "linuxdo"}}
  627. handler.UnbindIdentity(c)
  628. require.Equal(t, http.StatusOK, recorder.Code)
  629. require.Empty(t, repo.unbound)
  630. require.Empty(t, refreshTokenCache.revokedUserIDs)
  631. require.Equal(t, int64(4), repo.user.TokenVersion)
  632. }
  633. func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
  634. gin.SetMode(gin.TestMode)
  635. user := &service.User{
  636. ID: 11,
  637. Email: "current@example.com",
  638. Username: "bound-user",
  639. Role: service.RoleUser,
  640. Status: service.StatusActive,
  641. }
  642. require.NoError(t, user.SetPassword("current-password"))
  643. repo := &userHandlerRepoStub{user: user}
  644. emailCache := &userHandlerEmailCacheStub{
  645. data: &service.VerificationCodeData{
  646. Code: "123456",
  647. CreatedAt: time.Now().UTC(),
  648. ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
  649. },
  650. }
  651. cfg := &config.Config{
  652. JWT: config.JWTConfig{
  653. Secret: "test-secret",
  654. ExpireHour: 1,
  655. },
  656. }
  657. emailService := service.NewEmailService(nil, emailCache)
  658. authService := service.NewAuthService(nil, repo, nil, nil, cfg, nil, emailService, nil, nil, nil, nil, nil, nil)
  659. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil, nil, nil)
  660. body := []byte(`{"email":"new@example.com","verify_code":"123456","password":"wrong-password"}`)
  661. recorder := httptest.NewRecorder()
  662. c, _ := gin.CreateTestContext(recorder)
  663. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/account-bindings/email", bytes.NewReader(body))
  664. c.Request.Header.Set("Content-Type", "application/json")
  665. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
  666. handler.BindEmailIdentity(c)
  667. require.Equal(t, http.StatusBadRequest, recorder.Code)
  668. var resp struct {
  669. Code int `json:"code"`
  670. Message string `json:"message"`
  671. Reason string `json:"reason"`
  672. }
  673. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  674. require.Equal(t, http.StatusBadRequest, resp.Code)
  675. require.Equal(t, "PASSWORD_INCORRECT", resp.Reason)
  676. require.Equal(t, "current password is incorrect", resp.Message)
  677. require.Equal(t, "current@example.com", repo.user.Email)
  678. }
  679. func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
  680. gin.SetMode(gin.TestMode)
  681. repo := &userHandlerRepoStub{
  682. user: &service.User{
  683. ID: 11,
  684. Email: "identity@example.com",
  685. Username: "identity-user",
  686. Role: service.RoleUser,
  687. Status: service.StatusActive,
  688. },
  689. }
  690. handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil, nil, nil)
  691. body := []byte(`{"provider":"wechat","redirect_to":"/settings/profile"}`)
  692. recorder := httptest.NewRecorder()
  693. c, _ := gin.CreateTestContext(recorder)
  694. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/auth-identities/bind/start", bytes.NewReader(body))
  695. c.Request.Header.Set("Content-Type", "application/json")
  696. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
  697. handler.StartIdentityBinding(c)
  698. require.Equal(t, http.StatusOK, recorder.Code)
  699. var resp struct {
  700. Code int `json:"code"`
  701. Data struct {
  702. Provider string `json:"provider"`
  703. AuthorizeURL string `json:"authorize_url"`
  704. Method string `json:"method"`
  705. UseBrowserRedirect bool `json:"use_browser_redirect"`
  706. } `json:"data"`
  707. }
  708. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
  709. require.Equal(t, 0, resp.Code)
  710. require.Equal(t, "wechat", resp.Data.Provider)
  711. require.Equal(t, "GET", resp.Data.Method)
  712. require.True(t, resp.Data.UseBrowserRedirect)
  713. require.Contains(t, resp.Data.AuthorizeURL, "/api/v1/auth/oauth/wechat/bind/start")
  714. require.Contains(t, resp.Data.AuthorizeURL, "intent=bind_current_user")
  715. require.Contains(t, resp.Data.AuthorizeURL, "redirect=%2Fsettings%2Fprofile")
  716. }