changePasswordLogic_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. package auth
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. "go.uber.org/mock/gomock"
  9. "golang.org/x/crypto/bcrypt"
  10. "perms-system-server/internal/consts"
  11. "perms-system-server/internal/loaders"
  12. "perms-system-server/internal/middleware"
  13. userModel "perms-system-server/internal/model/user"
  14. "perms-system-server/internal/response"
  15. "perms-system-server/internal/svc"
  16. "perms-system-server/internal/testutil"
  17. "perms-system-server/internal/testutil/mocks"
  18. "perms-system-server/internal/types"
  19. "strings"
  20. "testing"
  21. "time"
  22. )
  23. func ctxWithUserId(userId int64) context.Context {
  24. return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: userId})
  25. }
  26. func insertTestUser(t *testing.T, ctx context.Context, username, password string) int64 {
  27. t.Helper()
  28. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  29. now := time.Now().Unix()
  30. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  31. Username: username,
  32. Password: password,
  33. Nickname: "test",
  34. Avatar: sql.NullString{},
  35. Email: username + "@test.com",
  36. Phone: "13800000000",
  37. Remark: "",
  38. DeptId: 0,
  39. IsSuperAdmin: 2,
  40. MustChangePassword: 1,
  41. Status: 1,
  42. CreateTime: now,
  43. UpdateTime: now,
  44. })
  45. require.NoError(t, err)
  46. id, _ := res.LastInsertId()
  47. return id
  48. }
  49. // TC-0054: 正常修改
  50. func TestChangePassword_Success(t *testing.T) {
  51. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  52. conn := testutil.GetTestSqlConn()
  53. ctx := context.Background()
  54. oldPwd := "Oldpass123"
  55. newPwd := "Newpass456"
  56. username := testutil.UniqueId()
  57. hashed := testutil.HashPassword(oldPwd)
  58. userId := insertTestUser(t, ctx, username, hashed)
  59. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  60. logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
  61. err := logic.ChangePassword(&types.ChangePasswordReq{
  62. OldPassword: oldPwd,
  63. NewPassword: newPwd,
  64. })
  65. require.NoError(t, err)
  66. updated, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  67. require.NoError(t, err)
  68. assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(updated.Password), []byte(newPwd)))
  69. }
  70. // TC-0055: mustChangePassword重置
  71. func TestChangePassword_MustChangePasswordReset(t *testing.T) {
  72. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  73. conn := testutil.GetTestSqlConn()
  74. ctx := context.Background()
  75. oldPwd := "Oldpass123"
  76. newPwd := "Newpass456"
  77. username := testutil.UniqueId()
  78. hashed := testutil.HashPassword(oldPwd)
  79. userId := insertTestUser(t, ctx, username, hashed)
  80. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  81. logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
  82. err := logic.ChangePassword(&types.ChangePasswordReq{
  83. OldPassword: oldPwd,
  84. NewPassword: newPwd,
  85. })
  86. require.NoError(t, err)
  87. updated, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  88. require.NoError(t, err)
  89. assert.Equal(t, int64(2), updated.MustChangePassword)
  90. }
  91. // TC-0056: 原密码错误
  92. func TestChangePassword_WrongOldPassword(t *testing.T) {
  93. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  94. conn := testutil.GetTestSqlConn()
  95. ctx := context.Background()
  96. username := testutil.UniqueId()
  97. hashed := testutil.HashPassword("Realpass1")
  98. userId := insertTestUser(t, ctx, username, hashed)
  99. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  100. logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
  101. err := logic.ChangePassword(&types.ChangePasswordReq{
  102. OldPassword: "Wrongpass1",
  103. NewPassword: "Newpass456",
  104. })
  105. var codeErr *response.CodeError
  106. require.True(t, errors.As(err, &codeErr))
  107. assert.Equal(t, 400, codeErr.Code())
  108. assert.Equal(t, "原密码错误", codeErr.Error())
  109. }
  110. // TC-0057: 新密码少于8字符
  111. func TestChangePassword_NewPasswordTooShort(t *testing.T) {
  112. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  113. logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
  114. err := logic.ChangePassword(&types.ChangePasswordReq{
  115. OldPassword: "oldpass",
  116. NewPassword: "Pas1234",
  117. })
  118. var codeErr *response.CodeError
  119. require.True(t, errors.As(err, &codeErr))
  120. assert.Equal(t, 400, codeErr.Code())
  121. assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error())
  122. }
  123. // TC-0058: 新密码恰好8字符(含大小写+数字)
  124. func TestChangePassword_NewPasswordExactly8Chars(t *testing.T) {
  125. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  126. conn := testutil.GetTestSqlConn()
  127. ctx := context.Background()
  128. oldPwd := "Oldpass123"
  129. newPwd := "Abcdef1x"
  130. username := testutil.UniqueId()
  131. hashed := testutil.HashPassword(oldPwd)
  132. userId := insertTestUser(t, ctx, username, hashed)
  133. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  134. logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
  135. err := logic.ChangePassword(&types.ChangePasswordReq{
  136. OldPassword: oldPwd,
  137. NewPassword: newPwd,
  138. })
  139. require.NoError(t, err)
  140. updated, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  141. require.NoError(t, err)
  142. assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(updated.Password), []byte(newPwd)))
  143. }
  144. // TC-0059: 新密码空字符串
  145. func TestChangePassword_NewPasswordEmpty(t *testing.T) {
  146. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  147. logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
  148. err := logic.ChangePassword(&types.ChangePasswordReq{
  149. OldPassword: "oldpass",
  150. NewPassword: "",
  151. })
  152. var codeErr *response.CodeError
  153. require.True(t, errors.As(err, &codeErr))
  154. assert.Equal(t, 400, codeErr.Code())
  155. assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error())
  156. }
  157. // TC-0060: 新密码超过72字符
  158. func TestChangePassword_NewPasswordTooLong(t *testing.T) {
  159. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  160. longPwd := "A" + strings.Repeat("a", 71) + "1"
  161. logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
  162. err := logic.ChangePassword(&types.ChangePasswordReq{
  163. OldPassword: "oldpass",
  164. NewPassword: longPwd,
  165. })
  166. var codeErr *response.CodeError
  167. require.True(t, errors.As(err, &codeErr))
  168. assert.Equal(t, 400, codeErr.Code())
  169. assert.Equal(t, "密码长度不能超过72个字符", codeErr.Error())
  170. }
  171. // TC-0061: 新密码恰好72字符
  172. func TestChangePassword_NewPasswordExactly72Chars(t *testing.T) {
  173. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  174. conn := testutil.GetTestSqlConn()
  175. ctx := context.Background()
  176. oldPwd := "Oldpass123"
  177. newPwd := "B" + strings.Repeat("b", 70) + "1"
  178. username := testutil.UniqueId()
  179. hashed := testutil.HashPassword(oldPwd)
  180. userId := insertTestUser(t, ctx, username, hashed)
  181. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  182. logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
  183. err := logic.ChangePassword(&types.ChangePasswordReq{
  184. OldPassword: oldPwd,
  185. NewPassword: newPwd,
  186. })
  187. require.NoError(t, err)
  188. updated, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  189. require.NoError(t, err)
  190. assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(updated.Password), []byte(newPwd)))
  191. }
  192. // TC-0062: 新旧密码相同
  193. func TestChangePassword_SameOldAndNew(t *testing.T) {
  194. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  195. conn := testutil.GetTestSqlConn()
  196. ctx := context.Background()
  197. pwd := "Samepass123"
  198. username := testutil.UniqueId()
  199. hashed := testutil.HashPassword(pwd)
  200. userId := insertTestUser(t, ctx, username, hashed)
  201. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  202. logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
  203. err := logic.ChangePassword(&types.ChangePasswordReq{
  204. OldPassword: pwd,
  205. NewPassword: pwd,
  206. })
  207. var codeErr *response.CodeError
  208. require.True(t, errors.As(err, &codeErr))
  209. assert.Equal(t, 400, codeErr.Code())
  210. assert.Equal(t, "新密码不能与原密码相同", codeErr.Error())
  211. }
  212. // TC-0063: 用户不存在
  213. func TestChangePassword_UserNotFound(t *testing.T) {
  214. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  215. logic := NewChangePasswordLogic(ctxWithUserId(99999999), svcCtx)
  216. err := logic.ChangePassword(&types.ChangePasswordReq{
  217. OldPassword: "Oldpass123",
  218. NewPassword: "Newpass456",
  219. })
  220. var codeErr *response.CodeError
  221. require.True(t, errors.As(err, &codeErr))
  222. assert.Equal(t, 404, codeErr.Code())
  223. assert.Equal(t, "用户不存在", codeErr.Error())
  224. }
  225. func TestChangePassword_UpdateConflict_Maps409(t *testing.T) {
  226. ctrl := gomock.NewController(t)
  227. t.Cleanup(ctrl.Finish)
  228. const userId = int64(777)
  229. oldPwd := "Oldpass123"
  230. newPwd := "Newpass456"
  231. hashed, err := bcrypt.GenerateFromPassword([]byte(oldPwd), bcrypt.DefaultCost)
  232. require.NoError(t, err)
  233. mockUser := mocks.NewMockSysUserModel(ctrl)
  234. mockUser.EXPECT().FindOne(gomock.Any(), userId).
  235. Return(&userModel.SysUser{
  236. Id: userId,
  237. Username: "m_r10_4_subject",
  238. Password: string(hashed),
  239. Status: consts.StatusEnabled,
  240. UpdateTime: 1000,
  241. }, nil)
  242. // 关键:强制底层返回 ErrUpdateConflict。
  243. // 签名增加 username 与 expectedUpdateTime 两个透传参数。
  244. mockUser.EXPECT().
  245. UpdatePassword(gomock.Any(), userId, "m_r10_4_subject", gomock.Any(), int64(consts.MustChangePasswordNo), int64(1000)).
  246. Return(userModel.ErrUpdateConflict)
  247. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
  248. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
  249. logic := NewChangePasswordLogic(ctx, svcCtx)
  250. err = logic.ChangePassword(&types.ChangePasswordReq{
  251. OldPassword: oldPwd,
  252. NewPassword: newPwd,
  253. })
  254. var codeErr *response.CodeError
  255. require.True(t, errors.As(err, &codeErr), "必须是 *response.CodeError,否则会被 rest 兜成 500")
  256. assert.Equal(t, 409, codeErr.Code(), "ErrUpdateConflict 必须映射为 409 Conflict")
  257. assert.Contains(t, codeErr.Error(), "密码已被其他会话修改", "文案与业务契约对齐")
  258. }
  259. // TC-1016: 非 ErrUpdateConflict 的原生错误仍应透传(500 由 rest 兜底),
  260. // 防止修复把所有底层错误都误吞为 409。
  261. func TestChangePassword_GenericUpdateError_StillPropagates(t *testing.T) {
  262. ctrl := gomock.NewController(t)
  263. t.Cleanup(ctrl.Finish)
  264. const userId = int64(778)
  265. oldPwd := "Oldpass123"
  266. newPwd := "Newpass456"
  267. hashed, err := bcrypt.GenerateFromPassword([]byte(oldPwd), bcrypt.DefaultCost)
  268. require.NoError(t, err)
  269. mockUser := mocks.NewMockSysUserModel(ctrl)
  270. mockUser.EXPECT().FindOne(gomock.Any(), userId).
  271. Return(&userModel.SysUser{
  272. Id: userId,
  273. Username: "m_r10_4_subject2",
  274. Password: string(hashed),
  275. Status: consts.StatusEnabled,
  276. UpdateTime: 2000,
  277. }, nil)
  278. genericErr := errors.New("driver: bad connection")
  279. mockUser.EXPECT().
  280. UpdatePassword(gomock.Any(), userId, "m_r10_4_subject2", gomock.Any(), int64(consts.MustChangePasswordNo), int64(2000)).
  281. Return(genericErr)
  282. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
  283. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
  284. logic := NewChangePasswordLogic(ctx, svcCtx)
  285. err = logic.ChangePassword(&types.ChangePasswordReq{
  286. OldPassword: oldPwd,
  287. NewPassword: newPwd,
  288. })
  289. require.Error(t, err)
  290. assert.ErrorIs(t, err, genericErr, "只把 ErrUpdateConflict 映射 409,其余错误原样透传(由 rest 兜 500)")
  291. var codeErr *response.CodeError
  292. assert.False(t, errors.As(err, &codeErr), "非冲突错误不得伪装成 CodeError")
  293. }
  294. func insertToctouUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
  295. username, plainPwd string) (int64, func()) {
  296. t.Helper()
  297. now := time.Now().Unix()
  298. hashed, err := bcrypt.GenerateFromPassword([]byte(plainPwd), bcrypt.DefaultCost)
  299. require.NoError(t, err)
  300. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  301. Username: username,
  302. Password: string(hashed),
  303. Nickname: "toctou",
  304. Avatar: sql.NullString{},
  305. Email: username + "@test.com",
  306. Phone: "13800000000",
  307. Remark: "",
  308. DeptId: 0,
  309. IsSuperAdmin: 2,
  310. MustChangePassword: 2,
  311. Status: 1,
  312. CreateTime: now,
  313. UpdateTime: now,
  314. })
  315. require.NoError(t, err)
  316. id, _ := res.LastInsertId()
  317. cleanup := func() {
  318. testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id)
  319. }
  320. return id, cleanup
  321. }
  322. // TC-1042: E2E —— 400 vs 409 分支隔离:旧密码失配必须 400,绝不能误落 409
  323. func TestChangePassword_E2E_SecondCallWithOldPwd_Maps400(t *testing.T) {
  324. ctx := context.Background()
  325. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  326. svcCtx.TokenOpLimiter = nil
  327. oldPwd := "Oldpass123"
  328. username := "toctou_seq_" + testutil.UniqueId()
  329. userId, cleanup := insertToctouUser(t, ctx, svcCtx, username, oldPwd)
  330. t.Cleanup(cleanup)
  331. lctx := middleware.WithUserDetails(context.Background(),
  332. &loaders.UserDetails{UserId: userId, Username: username, Status: 1})
  333. require.NoError(t,
  334. NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
  335. OldPassword: oldPwd, NewPassword: "NewpassX_11",
  336. }),
  337. "首改必须成功")
  338. err := NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
  339. OldPassword: oldPwd, NewPassword: "NewpassY_22",
  340. })
  341. require.Error(t, err)
  342. var ce *response.CodeError
  343. require.True(t, errors.As(err, &ce))
  344. assert.Equal(t, 400, ce.Code(),
  345. "旧密码已失配应 400'原密码错误';不得因 ErrUpdateConflict 映射被误回 409")
  346. assert.Contains(t, ce.Error(), "原密码错误")
  347. // DB 终态:Password 是首改成功的 NewpassX_11,tokenVersion 恰好 1(而不是 2)。
  348. got, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  349. require.NoError(t, err)
  350. assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(got.Password), []byte("NewpassX_11")))
  351. assert.Equal(t, int64(1), got.TokenVersion,
  352. "首改成功递增 1;第二次因 400 未进入 UpdatePassword,tokenVersion 必须仍是 1")
  353. }
  354. // TC-1043: UpdatePassword 签名护栏(mock 驱动):
  355. // 签名一旦回退(例如 username 再次被内部 FindOne 取而非外层透传),整个链路会编译失败;
  356. // 但契约层面的"必须透传外层 snapshot 的 UpdateTime"更细致:Logic 必须把 FindOne 返回的
  357. // snapshot.UpdateTime 原样交给 UpdatePassword,不得自己算 time.Now() 或重新 FindOne。
  358. // 这里用 mock 钉死该契约:FindOne 返回 UpdateTime=4242,UpdatePassword 必须收到 4242。
  359. func TestChangePassword_ForwardsSnapshotUpdateTime(t *testing.T) {
  360. // 注:此契约已由既有 TestChangePassword_UpdateConflict_Maps409(UpdateTime=1000)覆盖,
  361. // 这里再以另一组数值(4242)做"反证哨兵",若 DEV 不小心硬编码常量/写死 time.Now,
  362. // 两组数值会同时失败,快速定位。
  363. t.Run("expected=4242", func(t *testing.T) { runSnapshotForwardCase(t, 4242) })
  364. t.Run("expected=9876543210", func(t *testing.T) { runSnapshotForwardCase(t, 9876543210) })
  365. }
  366. func runSnapshotForwardCase(t *testing.T, expectedUpdateTime int64) {
  367. ctrl := gomock.NewController(t)
  368. t.Cleanup(ctrl.Finish)
  369. const userId = int64(17)
  370. oldPwd := "Oldpass123"
  371. newPwd := "Newpass456"
  372. hashed, err := bcrypt.GenerateFromPassword([]byte(oldPwd), bcrypt.DefaultCost)
  373. require.NoError(t, err)
  374. mockUser := mocks.NewMockSysUserModel(ctrl)
  375. mockUser.EXPECT().FindOne(gomock.Any(), userId).
  376. Return(&userModel.SysUser{
  377. Id: userId,
  378. Username: "snap_subject",
  379. Password: string(hashed),
  380. Status: 1,
  381. UpdateTime: expectedUpdateTime,
  382. }, nil)
  383. // 合同:UpdatePassword 的第 6 个参数必须与 FindOne 返回的 UpdateTime 字面相等。
  384. mockUser.EXPECT().
  385. UpdatePassword(gomock.Any(), userId, "snap_subject", gomock.Any(),
  386. int64(consts.MustChangePasswordNo), expectedUpdateTime).
  387. Return(nil)
  388. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
  389. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
  390. require.NoError(t, NewChangePasswordLogic(ctx, svcCtx).ChangePassword(
  391. &types.ChangePasswordReq{OldPassword: oldPwd, NewPassword: newPwd}))
  392. }