updateUserLogic_test.go 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385
  1. package user
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/require"
  9. "github.com/zeromicro/go-zero/core/stores/redis"
  10. "math"
  11. "perms-system-server/internal/consts"
  12. "perms-system-server/internal/loaders"
  13. deptLogic "perms-system-server/internal/logic/dept"
  14. "perms-system-server/internal/middleware"
  15. deptModel "perms-system-server/internal/model/dept"
  16. userModel "perms-system-server/internal/model/user"
  17. "perms-system-server/internal/response"
  18. "perms-system-server/internal/svc"
  19. "perms-system-server/internal/testutil"
  20. "perms-system-server/internal/testutil/ctxhelper"
  21. "perms-system-server/internal/types"
  22. "sync"
  23. "sync/atomic"
  24. "testing"
  25. "time"
  26. )
  27. func insertTestDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext) int64 {
  28. t.Helper()
  29. now := time.Now().Unix()
  30. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  31. Name: testutil.UniqueId(),
  32. ParentId: 0,
  33. Path: "/",
  34. Sort: 0,
  35. DeptType: "NORMAL",
  36. Status: consts.StatusEnabled,
  37. CreateTime: now,
  38. UpdateTime: now,
  39. })
  40. require.NoError(t, err)
  41. id, _ := res.LastInsertId()
  42. return id
  43. }
  44. // TC-0156: 正常更新
  45. func TestUpdateUser_Success(t *testing.T) {
  46. ctx := ctxhelper.SuperAdminCtx()
  47. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  48. conn := testutil.GetTestSqlConn()
  49. username := testutil.UniqueId()
  50. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  51. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  52. deptId := insertTestDept(t, ctx, svcCtx)
  53. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  54. logic := NewUpdateUserLogic(ctx, svcCtx)
  55. err := logic.UpdateUser(&types.UpdateUserReq{
  56. Id: userId,
  57. Nickname: strPtr("新昵称"),
  58. Email: strPtr("[email protected]"),
  59. Phone: strPtr("13900139000"),
  60. Remark: strPtr("更新备注"),
  61. DeptId: int64Ptr(deptId),
  62. })
  63. require.NoError(t, err)
  64. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  65. require.NoError(t, err)
  66. assert.Equal(t, "新昵称", user.Nickname)
  67. assert.Equal(t, "[email protected]", user.Email)
  68. assert.Equal(t, "13900139000", user.Phone)
  69. assert.Equal(t, "更新备注", user.Remark)
  70. assert.Equal(t, deptId, user.DeptId)
  71. }
  72. // TC-0157: 不存在
  73. func TestUpdateUser_NotFound(t *testing.T) {
  74. ctx := ctxhelper.SuperAdminCtx()
  75. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  76. logic := NewUpdateUserLogic(ctx, svcCtx)
  77. err := logic.UpdateUser(&types.UpdateUserReq{
  78. Id: 999999999,
  79. Nickname: strPtr("ghost"),
  80. })
  81. require.Error(t, err)
  82. var codeErr *response.CodeError
  83. require.True(t, errors.As(err, &codeErr))
  84. assert.Equal(t, 404, codeErr.Code())
  85. assert.Equal(t, "用户不存在", codeErr.Error())
  86. }
  87. // TC-0158: 仅传id
  88. func TestUpdateUser_OnlyId_NothingChanges(t *testing.T) {
  89. ctx := ctxhelper.SuperAdminCtx()
  90. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  91. conn := testutil.GetTestSqlConn()
  92. username := testutil.UniqueId()
  93. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  94. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  95. // 真实使用场景: 用户打开编辑页面后再提交, 插入与更新之间存在时间间隔,
  96. // updateTime 粒度为秒级, 因此在 INSERT 之后等待 1 秒再发起 UPDATE, 避免同秒 no-op SQL 被 MySQL 视为 0 affected rows.
  97. time.Sleep(1100 * time.Millisecond)
  98. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  99. require.NoError(t, err)
  100. logic := NewUpdateUserLogic(ctx, svcCtx)
  101. err = logic.UpdateUser(&types.UpdateUserReq{Id: userId})
  102. require.NoError(t, err)
  103. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  104. require.NoError(t, err)
  105. assert.Equal(t, before.Nickname, after.Nickname)
  106. assert.Equal(t, before.Email, after.Email)
  107. assert.Equal(t, before.Phone, after.Phone)
  108. assert.Equal(t, before.DeptId, after.DeptId)
  109. }
  110. // TC-0159: 清空nickname
  111. func TestUpdateUser_ClearNickname(t *testing.T) {
  112. ctx := ctxhelper.SuperAdminCtx()
  113. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  114. conn := testutil.GetTestSqlConn()
  115. username := testutil.UniqueId()
  116. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  117. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  118. logic := NewUpdateUserLogic(ctx, svcCtx)
  119. err := logic.UpdateUser(&types.UpdateUserReq{
  120. Id: userId,
  121. Nickname: strPtr(""),
  122. })
  123. require.NoError(t, err)
  124. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  125. require.NoError(t, err)
  126. assert.Equal(t, "", user.Nickname)
  127. }
  128. // TC-0160: 清空email
  129. func TestUpdateUser_ClearEmail(t *testing.T) {
  130. ctx := ctxhelper.SuperAdminCtx()
  131. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  132. conn := testutil.GetTestSqlConn()
  133. username := testutil.UniqueId()
  134. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  135. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  136. logic := NewUpdateUserLogic(ctx, svcCtx)
  137. err := logic.UpdateUser(&types.UpdateUserReq{
  138. Id: userId,
  139. Email: strPtr(""),
  140. })
  141. require.NoError(t, err)
  142. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  143. require.NoError(t, err)
  144. assert.Equal(t, "", user.Email)
  145. }
  146. // TC-0162: 非法email格式
  147. func TestUpdateUser_InvalidEmail(t *testing.T) {
  148. ctx := ctxhelper.SuperAdminCtx()
  149. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  150. conn := testutil.GetTestSqlConn()
  151. username := testutil.UniqueId()
  152. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  153. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  154. logic := NewUpdateUserLogic(ctx, svcCtx)
  155. err := logic.UpdateUser(&types.UpdateUserReq{
  156. Id: userId,
  157. Email: strPtr("bad-email"),
  158. })
  159. require.Error(t, err)
  160. var codeErr *response.CodeError
  161. require.True(t, errors.As(err, &codeErr))
  162. assert.Equal(t, 400, codeErr.Code())
  163. assert.Equal(t, "邮箱格式不正确", codeErr.Error())
  164. }
  165. // TC-0166: DeptId设为0(取消部门)
  166. func TestUpdateUser_DeptIdZero_Clear(t *testing.T) {
  167. ctx := ctxhelper.SuperAdminCtx()
  168. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  169. conn := testutil.GetTestSqlConn()
  170. username := testutil.UniqueId()
  171. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  172. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  173. deptId := insertTestDept(t, ctx, svcCtx)
  174. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  175. logic := NewUpdateUserLogic(ctx, svcCtx)
  176. err := logic.UpdateUser(&types.UpdateUserReq{
  177. Id: userId,
  178. DeptId: int64Ptr(deptId),
  179. })
  180. require.NoError(t, err)
  181. err = logic.UpdateUser(&types.UpdateUserReq{
  182. Id: userId,
  183. DeptId: int64Ptr(0),
  184. })
  185. require.NoError(t, err)
  186. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  187. require.NoError(t, err)
  188. assert.Equal(t, int64(0), user.DeptId)
  189. }
  190. // TC-0167: DeptId设为正值
  191. func TestUpdateUser_DeptIdSet(t *testing.T) {
  192. ctx := ctxhelper.SuperAdminCtx()
  193. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  194. conn := testutil.GetTestSqlConn()
  195. username := testutil.UniqueId()
  196. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  197. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  198. deptId := insertTestDept(t, ctx, svcCtx)
  199. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  200. logic := NewUpdateUserLogic(ctx, svcCtx)
  201. err := logic.UpdateUser(&types.UpdateUserReq{
  202. Id: userId,
  203. DeptId: int64Ptr(deptId),
  204. })
  205. require.NoError(t, err)
  206. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  207. require.NoError(t, err)
  208. assert.Equal(t, deptId, user.DeptId)
  209. }
  210. // TC-0168: DeptId不传(nil)
  211. func TestUpdateUser_NilDeptId_Unchanged(t *testing.T) {
  212. ctx := ctxhelper.SuperAdminCtx()
  213. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  214. conn := testutil.GetTestSqlConn()
  215. username := testutil.UniqueId()
  216. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  217. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  218. deptId := insertTestDept(t, ctx, svcCtx)
  219. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  220. logic := NewUpdateUserLogic(ctx, svcCtx)
  221. err := logic.UpdateUser(&types.UpdateUserReq{
  222. Id: userId,
  223. DeptId: int64Ptr(deptId),
  224. })
  225. require.NoError(t, err)
  226. err = logic.UpdateUser(&types.UpdateUserReq{
  227. Id: userId,
  228. Nickname: strPtr("changed"),
  229. })
  230. require.NoError(t, err)
  231. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  232. require.NoError(t, err)
  233. assert.Equal(t, deptId, user.DeptId)
  234. assert.Equal(t, "changed", user.Nickname)
  235. }
  236. // TC-0161: 清空remark
  237. func TestUpdateUser_ClearRemark(t *testing.T) {
  238. ctx := ctxhelper.SuperAdminCtx()
  239. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  240. conn := testutil.GetTestSqlConn()
  241. username := testutil.UniqueId()
  242. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  243. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  244. logic := NewUpdateUserLogic(ctx, svcCtx)
  245. err := logic.UpdateUser(&types.UpdateUserReq{
  246. Id: userId,
  247. Remark: strPtr("some remark"),
  248. })
  249. require.NoError(t, err)
  250. err = logic.UpdateUser(&types.UpdateUserReq{
  251. Id: userId,
  252. Remark: strPtr(""),
  253. })
  254. require.NoError(t, err)
  255. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  256. require.NoError(t, err)
  257. assert.Equal(t, "", user.Remark)
  258. }
  259. // TC-0164: 合法phone
  260. func TestUpdateUser_ValidInternationalPhone(t *testing.T) {
  261. ctx := ctxhelper.SuperAdminCtx()
  262. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  263. conn := testutil.GetTestSqlConn()
  264. username := testutil.UniqueId()
  265. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  266. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  267. logic := NewUpdateUserLogic(ctx, svcCtx)
  268. err := logic.UpdateUser(&types.UpdateUserReq{
  269. Id: userId,
  270. Phone: strPtr("+8613800138000"),
  271. })
  272. require.NoError(t, err)
  273. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  274. require.NoError(t, err)
  275. assert.Equal(t, "+8613800138000", user.Phone)
  276. }
  277. // TC-0163: 非法phone格式
  278. func TestUpdateUser_InvalidPhone(t *testing.T) {
  279. ctx := ctxhelper.SuperAdminCtx()
  280. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  281. conn := testutil.GetTestSqlConn()
  282. username := testutil.UniqueId()
  283. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  284. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  285. logic := NewUpdateUserLogic(ctx, svcCtx)
  286. err := logic.UpdateUser(&types.UpdateUserReq{
  287. Id: userId,
  288. Phone: strPtr("12345"),
  289. })
  290. require.Error(t, err)
  291. var codeErr *response.CodeError
  292. require.True(t, errors.As(err, &codeErr))
  293. assert.Equal(t, 400, codeErr.Code())
  294. assert.Equal(t, "手机号格式不正确", codeErr.Error())
  295. }
  296. // TC-0165: 不传email(nil)
  297. func TestUpdateUser_NilEmail_Unchanged(t *testing.T) {
  298. ctx := ctxhelper.SuperAdminCtx()
  299. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  300. conn := testutil.GetTestSqlConn()
  301. username := testutil.UniqueId()
  302. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  303. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  304. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  305. require.NoError(t, err)
  306. originalEmail := before.Email
  307. logic := NewUpdateUserLogic(ctx, svcCtx)
  308. err = logic.UpdateUser(&types.UpdateUserReq{
  309. Id: userId,
  310. Nickname: strPtr("changed-nick"),
  311. })
  312. require.NoError(t, err)
  313. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  314. require.NoError(t, err)
  315. assert.Equal(t, originalEmail, after.Email)
  316. assert.Equal(t, "changed-nick", after.Nickname)
  317. }
  318. // TC-0542: MEMBER用户尝试修改其他用户被CheckManageAccess拒绝
  319. func TestUpdateUser_MemberCannotManageOtherUser(t *testing.T) {
  320. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  321. conn := testutil.GetTestSqlConn()
  322. superCtx := ctxhelper.SuperAdminCtx()
  323. targetName := testutil.UniqueId()
  324. targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass"))
  325. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) })
  326. ctx := ctxhelper.MemberCtx("test_product")
  327. logic := NewUpdateUserLogic(ctx, svcCtx)
  328. err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("hacked")})
  329. require.Error(t, err)
  330. var ce *response.CodeError
  331. require.True(t, errors.As(err, &ce))
  332. assert.Equal(t, 403, ce.Code())
  333. }
  334. // TC-0170: 产品管理员可以修改其管理范围内的用户信息
  335. func TestUpdateUser_ProductAdminCanManageUser(t *testing.T) {
  336. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  337. conn := testutil.GetTestSqlConn()
  338. superCtx := ctxhelper.SuperAdminCtx()
  339. deptId := insertTestDept(t, superCtx, svcCtx)
  340. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_dept`", deptId) })
  341. targetName := testutil.UniqueId()
  342. targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass"))
  343. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) })
  344. productCode := "test_product"
  345. mId := insertTestMember(t, svcCtx, productCode, targetId)
  346. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) })
  347. ctx := ctxhelper.AdminCtx(productCode)
  348. logic := NewUpdateUserLogic(ctx, svcCtx)
  349. err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("new-nick")})
  350. require.NoError(t, err)
  351. user, err := svcCtx.SysUserModel.FindOne(superCtx, targetId)
  352. require.NoError(t, err)
  353. assert.Equal(t, "new-nick", user.Nickname)
  354. }
  355. // TC-0171: UpdateUser 昵称超过64字符被拒绝
  356. func TestUpdateUser_NicknameTooLong(t *testing.T) {
  357. ctx := ctxhelper.SuperAdminCtx()
  358. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  359. conn := testutil.GetTestSqlConn()
  360. username := testutil.UniqueId()
  361. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  362. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  363. longNick := string(make([]byte, 65))
  364. for i := range longNick {
  365. longNick = longNick[:i] + "a" + longNick[i+1:]
  366. }
  367. logic := NewUpdateUserLogic(ctx, svcCtx)
  368. err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: &longNick})
  369. require.Error(t, err)
  370. var ce *response.CodeError
  371. require.True(t, errors.As(err, &ce))
  372. assert.Equal(t, 400, ce.Code())
  373. assert.Contains(t, ce.Error(), "昵称长度不能超过64个字符")
  374. }
  375. // TC-0172: UpdateUser 部门不存在被拒绝
  376. func TestUpdateUser_DeptNotExists(t *testing.T) {
  377. ctx := ctxhelper.SuperAdminCtx()
  378. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  379. conn := testutil.GetTestSqlConn()
  380. username := testutil.UniqueId()
  381. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  382. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  383. logic := NewUpdateUserLogic(ctx, svcCtx)
  384. err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, DeptId: int64Ptr(999999999)})
  385. require.Error(t, err)
  386. var ce *response.CodeError
  387. require.True(t, errors.As(err, &ce))
  388. assert.Equal(t, 400, ce.Code())
  389. assert.Contains(t, ce.Error(), "部门不存在")
  390. }
  391. // TC-1101: UpdateUser 对 *req.DeptId < 0(-1 / MinInt64)必须 400 拒绝,
  392. // 与 CreateUser 的同规格 gating 闭环——不让已有账号通过 Update 变成 deptId=负数 的僵尸。
  393. func TestUpdateUser_NegativeDeptIdRejected(t *testing.T) {
  394. ctx := ctxhelper.SuperAdminCtx()
  395. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  396. conn := testutil.GetTestSqlConn()
  397. username := testutil.UniqueId()
  398. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  399. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  400. cases := []struct {
  401. name string
  402. deptId int64
  403. }{
  404. {"negative_one", -1},
  405. {"min_int64", math.MinInt64},
  406. }
  407. for _, tc := range cases {
  408. tc := tc
  409. t.Run(tc.name, func(t *testing.T) {
  410. err := NewUpdateUserLogic(ctx, svcCtx).UpdateUser(&types.UpdateUserReq{
  411. Id: userId,
  412. DeptId: int64Ptr(tc.deptId),
  413. })
  414. require.Error(t, err)
  415. var ce *response.CodeError
  416. require.True(t, errors.As(err, &ce))
  417. assert.Equal(t, 400, ce.Code())
  418. assert.Equal(t, "部门ID必须为非负整数", ce.Error())
  419. u, fErr := svcCtx.SysUserModel.FindOne(ctx, userId)
  420. require.NoError(t, fErr)
  421. assert.NotEqual(t, tc.deptId, u.DeptId, "被拒绝的更新不得落盘")
  422. })
  423. }
  424. }
  425. // TC-0543: updateUser自己修改DeptId被拒绝
  426. func TestUpdateUser_SelfEditDeptIdRejected(t *testing.T) {
  427. ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
  428. UserId: 100,
  429. Username: "self_user",
  430. Status: consts.StatusEnabled,
  431. })
  432. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  433. logic := NewUpdateUserLogic(ctx, svcCtx)
  434. err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, DeptId: int64Ptr(5)})
  435. require.Error(t, err)
  436. var ce *response.CodeError
  437. require.True(t, errors.As(err, &ce))
  438. assert.Equal(t, 403, ce.Code())
  439. assert.Equal(t, "不允许修改自己的部门和状态", ce.Error())
  440. }
  441. // TC-0544: updateUser自己修改Status被拒绝
  442. func TestUpdateUser_SelfEditStatusRejected(t *testing.T) {
  443. ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
  444. UserId: 100,
  445. Username: "self_user",
  446. Status: consts.StatusEnabled,
  447. })
  448. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  449. logic := NewUpdateUserLogic(ctx, svcCtx)
  450. err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, Status: 2})
  451. require.Error(t, err)
  452. var ce *response.CodeError
  453. require.True(t, errors.As(err, &ce))
  454. assert.Equal(t, 403, ce.Code())
  455. assert.Equal(t, "不允许修改自己的部门和状态", ce.Error())
  456. }
  457. // TC-0545: updateUser未登录被拒绝
  458. func TestUpdateUser_NotLoggedInRejected(t *testing.T) {
  459. ctx := context.Background()
  460. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  461. logic := NewUpdateUserLogic(ctx, svcCtx)
  462. err := logic.UpdateUser(&types.UpdateUserReq{Id: 1, Nickname: strPtr("hacked")})
  463. require.Error(t, err)
  464. var ce *response.CodeError
  465. require.True(t, errors.As(err, &ce))
  466. assert.Equal(t, 401, ce.Code())
  467. assert.Equal(t, "未登录", ce.Error())
  468. }
  469. // TC-0169: 超管A通过updateUser修改超管B的状态被拒绝(修复验证)
  470. func TestUpdateUser_SuperAdminCannotFreezeOtherSuperAdmin(t *testing.T) {
  471. ctx := ctxhelper.SuperAdminCtx()
  472. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  473. conn := testutil.GetTestSqlConn()
  474. now := time.Now().Unix()
  475. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  476. Username: testutil.UniqueId(),
  477. Password: testutil.HashPassword("pass"),
  478. Nickname: "super_b",
  479. IsSuperAdmin: consts.IsSuperAdminYes,
  480. MustChangePassword: 2,
  481. Status: consts.StatusEnabled,
  482. CreateTime: now,
  483. UpdateTime: now,
  484. })
  485. require.NoError(t, err)
  486. superBId, _ := uRes.LastInsertId()
  487. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", superBId) })
  488. logic := NewUpdateUserLogic(ctx, svcCtx)
  489. err = logic.UpdateUser(&types.UpdateUserReq{
  490. Id: superBId,
  491. Status: consts.StatusDisabled,
  492. })
  493. require.Error(t, err)
  494. var ce *response.CodeError
  495. require.True(t, errors.As(err, &ce))
  496. assert.Equal(t, 403, ce.Code())
  497. // 最新重构:Status 校验统一走 authHelper.ValidateStatusChange,文案为"不能修改超级管理员的状态"
  498. assert.Equal(t, "不能修改超级管理员的状态", ce.Error())
  499. user, err := svcCtx.SysUserModel.FindOne(ctx, superBId)
  500. require.NoError(t, err)
  501. assert.Equal(t, int64(consts.StatusEnabled), user.Status, "超管B的状态不应被修改")
  502. }
  503. // TC-0173: updateUser 修改状态时会递增 tokenVersion(修复验证)
  504. func TestUpdateUser_StatusChange_IncrementsTokenVersion(t *testing.T) {
  505. ctx := ctxhelper.SuperAdminCtx()
  506. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  507. conn := testutil.GetTestSqlConn()
  508. username := testutil.UniqueId()
  509. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  510. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  511. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  512. require.NoError(t, err)
  513. origTv := before.TokenVersion
  514. logic := NewUpdateUserLogic(ctx, svcCtx)
  515. err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Status: consts.StatusDisabled})
  516. require.NoError(t, err)
  517. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  518. require.NoError(t, err)
  519. assert.Equal(t, int64(consts.StatusDisabled), after.Status)
  520. assert.Equal(t, origTv+1, after.TokenVersion, "状态变化应递增 tokenVersion")
  521. }
  522. // TC-0174: updateUser 只改 profile 不会递增 tokenVersion
  523. func TestUpdateUser_ProfileOnly_NoTokenVersionChange(t *testing.T) {
  524. ctx := ctxhelper.SuperAdminCtx()
  525. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  526. conn := testutil.GetTestSqlConn()
  527. username := testutil.UniqueId()
  528. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  529. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  530. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  531. require.NoError(t, err)
  532. logic := NewUpdateUserLogic(ctx, svcCtx)
  533. err = logic.UpdateUser(&types.UpdateUserReq{
  534. Id: userId,
  535. Nickname: strPtr("新名字"),
  536. Email: strPtr("[email protected]"),
  537. })
  538. require.NoError(t, err)
  539. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  540. require.NoError(t, err)
  541. assert.Equal(t, "新名字", after.Nickname)
  542. assert.Equal(t, before.TokenVersion, after.TokenVersion, "不改状态时 tokenVersion 不应变化")
  543. }
  544. // TC-0175: updateUser 乐观锁冲突 -> 409
  545. // 乐观锁依赖秒级 updateTime, 需在两次更新之间保证 >= 1 秒的间隔, 否则 MySQL 看到的新/旧 updateTime 相同无法生效.
  546. func TestUpdateUser_OptimisticLockConflict_Returns409(t *testing.T) {
  547. ctx := ctxhelper.SuperAdminCtx()
  548. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  549. conn := testutil.GetTestSqlConn()
  550. username := testutil.UniqueId()
  551. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  552. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  553. orig, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  554. require.NoError(t, err)
  555. time.Sleep(1100 * time.Millisecond)
  556. logic := NewUpdateUserLogic(ctx, svcCtx)
  557. err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: strPtr("first")})
  558. require.NoError(t, err)
  559. err = svcCtx.SysUserModel.UpdateProfile(ctx, userId, orig.Username, "second",
  560. orig.Email, orig.Phone, orig.Remark, orig.DeptId, orig.Status, false, orig.UpdateTime)
  561. require.ErrorIs(t, err, userModel.ErrUpdateConflict, "基于旧 updateTime 的更新应失败")
  562. }
  563. func insertTestDeptForScope(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path string) int64 {
  564. t.Helper()
  565. now := time.Now().Unix()
  566. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  567. ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0,
  568. DeptType: "NORMAL", Remark: "", Status: 1, CreateTime: now, UpdateTime: now,
  569. })
  570. require.NoError(t, err)
  571. id, _ := res.LastInsertId()
  572. return id
  573. }
  574. func insertTestUserWithDept(t *testing.T, ctx context.Context, tag string, deptId int64) int64 {
  575. t.Helper()
  576. now := time.Now().Unix()
  577. return insertTestUserFull(t, ctx, &userModel.SysUser{
  578. Username: "ddu_" + tag + "_" + testutil.UniqueId(),
  579. Password: testutil.HashPassword("pw"),
  580. Nickname: "n",
  581. Avatar: sql.NullString{},
  582. Email: "[email protected]",
  583. Phone: "13800000000",
  584. DeptId: deptId,
  585. IsSuperAdmin: consts.IsSuperAdminNo,
  586. MustChangePassword: 2,
  587. Status: consts.StatusEnabled,
  588. CreateTime: now,
  589. UpdateTime: now,
  590. })
  591. }
  592. // TC-0746: -F 修复回归 —— DEVELOPER 调用者不得将目标用户的 deptId 调到
  593. // 自己 DeptPath 子树之外的部门。UpdateUser 必须在 req.DeptId 变更时做 Path 前缀校验。
  594. func TestUpdateUser_DeveloperCannotMoveTargetOutsideSubtree(t *testing.T) {
  595. bootstrap := context.Background()
  596. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  597. conn := testutil.GetTestSqlConn()
  598. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller", "/100/")
  599. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "target", "/100/200/")
  600. outsideDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "outside", "/999/")
  601. targetId := insertTestUserWithDept(t, bootstrap, "lf_out", targetDeptId)
  602. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  603. t.Cleanup(func() {
  604. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  605. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  606. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId, outsideDeptId)
  607. })
  608. devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  609. UserId: 55555, Username: "lf_dev",
  610. IsSuperAdmin: false,
  611. MemberType: consts.MemberTypeDeveloper,
  612. Status: consts.StatusEnabled,
  613. ProductCode: "test_product",
  614. DeptId: callerDeptId,
  615. DeptPath: "/100/",
  616. MinPermsLevel: math.MaxInt64,
  617. })
  618. newDept := outsideDeptId
  619. err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  620. Id: targetId,
  621. DeptId: &newDept,
  622. })
  623. require.Error(t, err, "调入外部部门应被拒绝")
  624. var ce *response.CodeError
  625. require.True(t, errors.As(err, &ce))
  626. assert.Equal(t, 403, ce.Code())
  627. assert.Contains(t, ce.Error(), "无权将用户调入")
  628. user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  629. require.NoError(t, err)
  630. assert.Equal(t, targetDeptId, user.DeptId, "被拒绝的请求必须不改动 DB")
  631. }
  632. // TC-0747: -F 正向回归 —— DEVELOPER 将目标用户调入自己子树下的部门应允许。
  633. func TestUpdateUser_DeveloperCanMoveTargetWithinSubtree(t *testing.T) {
  634. bootstrap := context.Background()
  635. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  636. conn := testutil.GetTestSqlConn()
  637. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller_in", "/200/")
  638. srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "src_in", "/200/1/")
  639. dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dst_in", "/200/2/")
  640. targetId := insertTestUserWithDept(t, bootstrap, "lf_in", srcDeptId)
  641. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  642. t.Cleanup(func() {
  643. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  644. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  645. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, srcDeptId, dstDeptId)
  646. })
  647. devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  648. UserId: 66666, Username: "lf_dev_ok",
  649. IsSuperAdmin: false,
  650. MemberType: consts.MemberTypeDeveloper,
  651. Status: consts.StatusEnabled,
  652. ProductCode: "test_product",
  653. DeptId: callerDeptId,
  654. DeptPath: "/200/",
  655. MinPermsLevel: math.MaxInt64,
  656. })
  657. newDept := dstDeptId
  658. require.NoError(t,
  659. NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  660. Id: targetId, DeptId: &newDept,
  661. }),
  662. "caller DeptPath 的前缀子部门必须允许调入")
  663. user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  664. require.NoError(t, err)
  665. assert.Equal(t, dstDeptId, user.DeptId)
  666. }
  667. // TC-0748: -F —— 产品 ADMIN 调用者被豁免 DeptPath 前缀校验(可跨部门转移)。
  668. func TestUpdateUser_ProductAdminExemptFromSubtreeCheck(t *testing.T) {
  669. bootstrap := context.Background()
  670. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  671. conn := testutil.GetTestSqlConn()
  672. adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "admin_home", "/300/")
  673. targetHomeDept := insertTestDeptForScope(t, bootstrap, svcCtx, "target_home", "/400/")
  674. anywhereDept := insertTestDeptForScope(t, bootstrap, svcCtx, "anywhere", "/500/")
  675. targetId := insertTestUserWithDept(t, bootstrap, "lf_admin", targetHomeDept)
  676. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  677. t.Cleanup(func() {
  678. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  679. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  680. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetHomeDept, anywhereDept)
  681. })
  682. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  683. UserId: 77777, Username: "lf_admin",
  684. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  685. Status: consts.StatusEnabled, ProductCode: "test_product",
  686. DeptId: adminDeptId, DeptPath: "/300/", MinPermsLevel: math.MaxInt64,
  687. })
  688. newDept := anywhereDept
  689. require.NoError(t,
  690. NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  691. Id: targetId, DeptId: &newDept,
  692. }),
  693. "产品 ADMIN 在 UpdateUser 的 DeptPath 前缀校验中被豁免")
  694. }
  695. func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) {
  696. bootstrap := context.Background()
  697. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  698. conn := testutil.GetTestSqlConn()
  699. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_caller_dev", "/700/")
  700. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_target_dev", "/700/1/")
  701. targetId := insertTestUserWithDept(t, bootstrap, "h4_dev", targetDeptId)
  702. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  703. t.Cleanup(func() {
  704. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  705. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  706. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId)
  707. })
  708. devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  709. UserId: 88881,
  710. Username: "h4_dev",
  711. IsSuperAdmin: false,
  712. MemberType: consts.MemberTypeDeveloper,
  713. Status: consts.StatusEnabled,
  714. ProductCode: "test_product",
  715. DeptId: callerDeptId,
  716. DeptPath: "/700/",
  717. MinPermsLevel: math.MaxInt64,
  718. })
  719. zero := int64(0)
  720. err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  721. Id: targetId,
  722. DeptId: &zero,
  723. })
  724. require.Error(t, err, "DEVELOPER 不得把目标移出部门树")
  725. var ce *response.CodeError
  726. require.True(t, errors.As(err, &ce))
  727. assert.Equal(t, 403, ce.Code())
  728. assert.Contains(t, ce.Error(), "仅超级管理员可将用户移出部门",
  729. "L-R15-1:文案已收敛——产品 ADMIN 不再享有此权限,DEVELOPER 自然也不行;"+
  730. "断言仅匹配'仅超级管理员...'前缀即可覆盖所有非超管拒绝分支")
  731. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  732. require.NoError(t, err)
  733. assert.Equal(t, targetDeptId, u.DeptId, "被拒绝的请求对 DB 零副作用")
  734. }
  735. // TC-0815: MEMBER 调用者同理被拒(即便是修改自身的其他字段也不能顺手把自己移出部门)。
  736. // 用户修改自身时,路由层 if caller.UserId == req.Id 分支只拦 DeptId != nil/Status != 0;
  737. // 但修改他人为 deptId=0 的分支仍必须 403,以防任何下级调用者漂白组织结构。
  738. func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) {
  739. bootstrap := context.Background()
  740. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  741. conn := testutil.GetTestSqlConn()
  742. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_caller", "/800/")
  743. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_target", "/800/1/")
  744. targetId := insertTestUserWithDept(t, bootstrap, "h4_mem", targetDeptId)
  745. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  746. t.Cleanup(func() {
  747. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  748. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  749. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId)
  750. })
  751. memberCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  752. UserId: 88882,
  753. Username: "h4_mem",
  754. IsSuperAdmin: false,
  755. MemberType: consts.MemberTypeMember,
  756. Status: consts.StatusEnabled,
  757. ProductCode: "test_product",
  758. DeptId: callerDeptId,
  759. DeptPath: "/800/",
  760. MinPermsLevel: 10,
  761. })
  762. zero := int64(0)
  763. err := NewUpdateUserLogic(memberCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  764. Id: targetId,
  765. DeptId: &zero,
  766. })
  767. require.Error(t, err, "MEMBER 更不得移出他人")
  768. var ce *response.CodeError
  769. require.True(t, errors.As(err, &ce))
  770. assert.Equal(t, 403, ce.Code())
  771. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  772. require.NoError(t, err)
  773. assert.Equal(t, targetDeptId, u.DeptId)
  774. }
  775. // TC-0816(L-R15-1 后契约反转):产品 ADMIN **不再**拥有"把他人移出部门"的权限。
  776. // sys_user.deptId 是全局字段,P1 ADMIN 原先可以让共有成员 B 在 P2 视角下变成
  777. // "DeptId=0 的孤儿"——P2 的 MEMBER/DEVELOPER/子 ADMIN 全部通不过 checkDeptHierarchy
  778. // 的目标部门校验,B 成为 P2 侧的"隐形成员",DeptTree 里也找不到。
  779. // 该破坏组织结构语义的操作属于离职/转岗的 HR 行政流程,应当收敛给 SuperAdmin。
  780. //
  781. // 断言重点不只是 403:还要验证 DB 零副作用(deptId 保持原值),防止实现从
  782. // "check+exec"退化成"exec 后补 check"漏了副作用清理。
  783. func TestUpdateUser_ProductAdminCannotMoveTargetOutOfDept(t *testing.T) {
  784. bootstrap := context.Background()
  785. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  786. conn := testutil.GetTestSqlConn()
  787. adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin", "/900/")
  788. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin_target", "/900/1/")
  789. targetId := insertTestUserWithDept(t, bootstrap, "h4_admin_tgt", targetDeptId)
  790. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  791. t.Cleanup(func() {
  792. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  793. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  794. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetDeptId)
  795. })
  796. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  797. UserId: 88883, Username: "h4_admin",
  798. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  799. Status: consts.StatusEnabled, ProductCode: "test_product",
  800. DeptId: adminDeptId, DeptPath: "/900/", MinPermsLevel: math.MaxInt64,
  801. })
  802. zero := int64(0)
  803. err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  804. Id: targetId, DeptId: &zero,
  805. })
  806. require.Error(t, err, "产品 ADMIN 调 deptId=0 必须被 L-R15-1 拦下")
  807. var ce *response.CodeError
  808. require.True(t, errors.As(err, &ce))
  809. assert.Equal(t, 403, ce.Code(),
  810. "仅超级管理员可将用户移出部门——产品 ADMIN 必须 403")
  811. assert.Contains(t, ce.Error(), "仅超级管理员",
  812. "文案必须显式指向超级管理员权限,避免接入方继续误以为'产品管理员也可以'")
  813. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  814. require.NoError(t, err)
  815. assert.Equal(t, targetDeptId, u.DeptId,
  816. "被 403 拒绝的请求必须对 DB 零副作用——deptId 不得从 "+
  817. "targetDeptId 退化为 0,防止 'check 失败但 exec 已落盘' 的绕过实现")
  818. }
  819. // TC-0817: SuperAdmin 有权将他人移出部门(豁免路径)。
  820. func TestUpdateUser_SuperAdminCanMoveTargetOutOfDept(t *testing.T) {
  821. bootstrap := context.Background()
  822. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  823. conn := testutil.GetTestSqlConn()
  824. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_sa_target", "/950/")
  825. targetId := insertTestUserWithDept(t, bootstrap, "h4_sa_tgt", targetDeptId)
  826. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  827. t.Cleanup(func() {
  828. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  829. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  830. testutil.CleanTable(bootstrap, conn, "`sys_dept`", targetDeptId)
  831. })
  832. zero := int64(0)
  833. require.NoError(t,
  834. NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
  835. Id: targetId, DeptId: &zero,
  836. }),
  837. "SuperAdmin 的 deptId=0 操作是合法的顶层运维")
  838. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  839. require.NoError(t, err)
  840. assert.Equal(t, int64(0), u.DeptId)
  841. }
  842. func insertEnabledDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, name, path string) int64 {
  843. t.Helper()
  844. now := time.Now().Unix()
  845. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  846. ParentId: 0, Name: name + "_" + testutil.UniqueId(), Path: path, Sort: 0,
  847. DeptType: "NORMAL", Remark: "", Status: consts.StatusEnabled,
  848. CreateTime: now, UpdateTime: now,
  849. })
  850. require.NoError(t, err)
  851. id, _ := res.LastInsertId()
  852. return id
  853. }
  854. // TC-1083: UpdateUser tx 分支在 commit 成功后必须失效 sysUser 低层缓存
  855. func TestUpdateUser_DeptChange_PostCommitInvalidatesSysUserCache(t *testing.T) {
  856. bootstrap := context.Background()
  857. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  858. conn := testutil.GetTestSqlConn()
  859. rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
  860. prefix := testutil.GetTestCachePrefix()
  861. srcDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_src", "/r12_1_src/")
  862. dstDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_dst", "/r12_1_dst/")
  863. targetId := insertTestUserFull(t, bootstrap, &userModel.SysUser{
  864. Username: "r12_1_upd_" + testutil.UniqueId(),
  865. Password: testutil.HashPassword("pw"),
  866. Nickname: "orig_nick",
  867. Avatar: sql.NullString{},
  868. Email: "[email protected]",
  869. Phone: "13800000000",
  870. Remark: "orig_remark",
  871. DeptId: srcDeptId,
  872. IsSuperAdmin: consts.IsSuperAdminNo,
  873. MustChangePassword: 2,
  874. Status: consts.StatusEnabled,
  875. })
  876. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  877. t.Cleanup(func() {
  878. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  879. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  880. testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, dstDeptId)
  881. })
  882. // 走一次 FindOne 预热 id / username 两把低层缓存;UpdateUser 内部 `l.svcCtx.SysUserModel.FindOne`
  883. // 也会预热,这里显式做一次把预置断言打实。
  884. pre, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  885. require.NoError(t, err)
  886. idKey := fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, targetId)
  887. usernameKey := fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, pre.Username)
  888. udKey := fmt.Sprintf("%s:ud:%d:%s", prefix, targetId, "test_product")
  889. cachedId, err := rds.Get(idKey)
  890. require.NoError(t, err)
  891. require.NotEmpty(t, cachedId, "预置:sysUser id 缓存已预热")
  892. // 先预热 UserDetails 聚合缓存(否则下面判断"Clean 之后为空"会因为本来就空而变成假通过)
  893. _, err = svcCtx.UserDetailsLoader.Load(bootstrap, targetId, "test_product")
  894. require.NoError(t, err)
  895. cachedUd, err := rds.Get(udKey)
  896. require.NoError(t, err)
  897. require.NotEmpty(t, cachedUd, "预置:UserDetails 聚合缓存已预热")
  898. callerCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  899. UserId: 88888, Username: "r12_1_super",
  900. IsSuperAdmin: true,
  901. MemberType: consts.MemberTypeAdmin,
  902. Status: consts.StatusEnabled,
  903. ProductCode: "test_product",
  904. MinPermsLevel: 0,
  905. })
  906. newDept := dstDeptId
  907. require.NoError(t,
  908. NewUpdateUserLogic(callerCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  909. Id: targetId,
  910. DeptId: &newDept,
  911. }),
  912. "超管改部门应成功走 tx 分支")
  913. // 关键断言 1:sysUser:id 低层缓存已被 post-commit 的 InvalidateProfileCache 清掉
  914. afterId, err := rds.Get(idKey)
  915. require.NoError(t, err)
  916. assert.Empty(t, afterId,
  917. "UpdateUser tx 分支返回后,sysUser:id 低层缓存必须已被 InvalidateProfileCache 失效;"+
  918. "若仍有值,则表示 Logic 层遗漏了 post-commit 的显式 DelCache 调用,"+
  919. "并发读回源时会沿用预热时的旧 deptId/昵称/备注 payload")
  920. // 关键断言 2:username 低层缓存也要被一并清掉
  921. afterUn, err := rds.Get(usernameKey)
  922. require.NoError(t, err)
  923. assert.Empty(t, afterUn,
  924. "sysUser:username 低层缓存也必须被 InvalidateProfileCache 同批失效")
  925. // 关键断言 3:UserDetails 聚合缓存也被清掉(l.svcCtx.UserDetailsLoader.Clean)
  926. afterUd, err := rds.Get(udKey)
  927. require.NoError(t, err)
  928. assert.Empty(t, afterUd,
  929. "UserDetailsLoader.Clean 必须在 post-commit 同一阶段被调用,"+
  930. "保证上层聚合缓存和下层 sysUser 缓存一起过期,避免读链任一环读到旧值")
  931. // 关键断言 4:下一轮 FindOne 取到新 deptId(双重验证:DB 为权威且缓存已经让步)
  932. after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  933. require.NoError(t, err)
  934. assert.Equal(t, dstDeptId, after.DeptId,
  935. "缓存失效后 FindOne 必须从 DB 读到 tx 已提交的新 deptId;"+
  936. "若缓存未清,这里仍会是 srcDeptId(cache stale 的最终症状)")
  937. }
  938. func TestUpdateUser_DeptIdSwitch_VsDeleteDept_NoWriteSkew(t *testing.T) {
  939. bootstrap := ctxhelper.SuperAdminCtx()
  940. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  941. conn := testutil.GetTestSqlConn()
  942. // 构造:用户 U 在 deptA,新候选部门 deptX 空(无子部门 + 无关联用户,满足 DeleteDept 可删条件)。
  943. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptA", "/3100/")
  944. deptXId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptX", "/3200/")
  945. targetId := insertTestUserWithDept(t, bootstrap, "m113_user", deptAId)
  946. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  947. t.Cleanup(func() {
  948. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  949. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  950. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId, deptXId)
  951. })
  952. // 超管身份用于 UpdateUser / DeleteDept。
  953. superCtx := ctxhelper.SuperAdminCtx()
  954. // 两个 goroutine 并发:
  955. // G1: UpdateUser targetId.deptId = deptXId
  956. // G2: DeleteDept deptXId
  957. var (
  958. wg sync.WaitGroup
  959. upErr atomic.Value
  960. upOK atomic.Bool
  961. delErr atomic.Value
  962. delOK atomic.Bool
  963. unexpected atomic.Value
  964. )
  965. start := make(chan struct{})
  966. wg.Add(2)
  967. go func() {
  968. defer wg.Done()
  969. <-start
  970. err := NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  971. Id: targetId,
  972. DeptId: &deptXId,
  973. })
  974. if err == nil {
  975. upOK.Store(true)
  976. } else {
  977. upErr.Store(err)
  978. }
  979. }()
  980. go func() {
  981. defer wg.Done()
  982. <-start
  983. err := deptLogic.NewDeleteDeptLogic(superCtx, svcCtx).DeleteDept(&types.DeleteDeptReq{
  984. Id: deptXId,
  985. })
  986. if err == nil {
  987. delOK.Store(true)
  988. } else {
  989. delErr.Store(err)
  990. }
  991. }()
  992. close(start)
  993. wg.Wait()
  994. // 允许的结果共两种:
  995. // A) upOK && !delOK:user.deptId==deptX,sys_dept[deptX] 仍在,DeleteDept 收 400
  996. // B) !upOK && delOK:user.deptId==deptA(未动),sys_dept[deptX] 已删,UpdateUser 收 400
  997. // 绝不能同时成功(write skew)。DB 终态须自洽。
  998. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  999. require.NoError(t, err)
  1000. // dept X 存在性:**绕过 go-zero 的 WithCache**,直接从 MySQL 查真相,避免 UpdateUser
  1001. // 在 FindOne 时预热的缓存把 DeleteDept 的真实删除"遮住"。
  1002. var deptCount int64
  1003. require.NoError(t,
  1004. conn.QueryRowCtx(context.Background(), &deptCount,
  1005. "SELECT COUNT(*) FROM `sys_dept` WHERE `id` = ?", deptXId))
  1006. deptStillThere := deptCount > 0
  1007. switch {
  1008. case upOK.Load() && !delOK.Load():
  1009. assert.Equal(t, deptXId, u.DeptId,
  1010. "UpdateUser 胜出,user.deptId 必须为 deptX")
  1011. assert.True(t, deptStillThere,
  1012. "UpdateUser 胜出后 deptX 必须仍存在,否则存在 orphan 引用")
  1013. var ce *response.CodeError
  1014. require.NotNil(t, delErr.Load(), "DeleteDept 应返回 400 解释失败原因")
  1015. if errors.As(delErr.Load().(error), &ce) {
  1016. assert.Equal(t, 400, ce.Code(),
  1017. "DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'")
  1018. assert.Contains(t, ce.Error(), "关联用户")
  1019. }
  1020. case !upOK.Load() && delOK.Load():
  1021. assert.Equal(t, deptAId, u.DeptId,
  1022. "DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)")
  1023. assert.False(t, deptStillThere,
  1024. "DeleteDept 胜出后 deptX 必须已被删除")
  1025. var ce *response.CodeError
  1026. require.NotNil(t, upErr.Load(), "UpdateUser 应返回 400 解释失败原因")
  1027. if errors.As(upErr.Load().(error), &ce) {
  1028. assert.Equal(t, 400, ce.Code(),
  1029. "UpdateUser 发现目标 dept 已消失必须 400'部门不存在'")
  1030. assert.Contains(t, ce.Error(), "部门不存在")
  1031. }
  1032. case upOK.Load() && delOK.Load():
  1033. t.Fatalf("UpdateUser + DeleteDept **同时成功** —— write skew 未被闭合。" +
  1034. "DB 现在持有 user.deptId 指向已被删 dept 的 orphan 数据。")
  1035. case !upOK.Load() && !delOK.Load():
  1036. unexpected.Store(struct{ up, del error }{upErr.Load().(error), delErr.Load().(error)})
  1037. t.Fatalf("两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load())
  1038. }
  1039. }
  1040. // TC-1050: UpdateUser 只改 deptId 之外的字段(或 deptId=0)时不进事务(性能与锁范围约束)
  1041. // 这是修复的**对偶契约**:避免 DEV 未来不分 case 把所有 UpdateProfile 都塞进事务 / 或反之。
  1042. // 用"目标部门不存在但仅改 Nickname"的 case 证明:非 deptId 变更路径不需要 FindOneForShareTx,
  1043. // 且走的是 UpdateProfile(非事务)。该契约只能以"只调昵称也能成功"的正向场景间接证实:
  1044. func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T) {
  1045. bootstrap := ctxhelper.SuperAdminCtx()
  1046. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1047. conn := testutil.GetTestSqlConn()
  1048. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_onlyNick", "/3300/")
  1049. targetId := insertTestUserWithDept(t, bootstrap, "m113_onlyNick", deptAId)
  1050. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1051. t.Cleanup(func() {
  1052. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1053. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1054. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId)
  1055. })
  1056. newNick := "only_nick_mutate"
  1057. superCtx := ctxhelper.SuperAdminCtx()
  1058. require.NoError(t,
  1059. NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1060. Id: targetId, Nickname: &newNick,
  1061. }),
  1062. "只改昵称不应走事务路径(若走事务会无谓扩大锁范围)")
  1063. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  1064. require.NoError(t, err)
  1065. assert.Equal(t, newNick, u.Nickname)
  1066. assert.Equal(t, deptAId, u.DeptId, "deptId 未变")
  1067. }
  1068. // 备注:原本想写一条"deptId 从 A 改到 A 不走事务路径"的对偶用例,但 MySQL 对"所有字段都
  1069. // 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。
  1070. // 这是底层驱动/引擎层的 side-effect,非 关心的契约。若要验证该对偶,请同时改一个
  1071. // 真实字段(参见上面的 Nickname 用例)。
  1072. // insertTestDeptWithType —— 工具函数:按给定 DeptType 插入一个部门。
  1073. // 专用于 H-R14-1 场景:需要精确控制 DeptType=DEV 触发跨产品权限升级的护栏。
  1074. func insertTestDeptWithType(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path, deptType string) int64 {
  1075. t.Helper()
  1076. now := time.Now().Unix()
  1077. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  1078. ParentId: 0,
  1079. Name: tag + "_" + testutil.UniqueId(),
  1080. Path: path,
  1081. Sort: 0,
  1082. DeptType: deptType,
  1083. Status: consts.StatusEnabled,
  1084. CreateTime: now,
  1085. UpdateTime: now,
  1086. })
  1087. require.NoError(t, err)
  1088. id, _ := res.LastInsertId()
  1089. return id
  1090. }
  1091. // TC-1124: H-R14-1 —— 产品 ADMIN 将目标用户调入 DEV 部门必须 403,防止跨产品权限升级。
  1092. //
  1093. // 攻击链回放:
  1094. // P1.ADMIN 同时在 P2 也是普通成员 → target 同在 P1、P2 → P1.ADMIN 调用 UpdateUser
  1095. // 把 target.deptId 改到 DEV 部门。sys_user.deptId 是全局字段,一次改动立即让 target
  1096. // 在 **P2** 的 UD.loadPerms 里升级为"DEV 部门在编成员"—— 按 userDetailsLoader 的
  1097. // 全权分支,target 在 P2 将拥有 P2 全部 perm,等于绕过了 P2 信任边界。
  1098. // 因此本接口必须把"调入 DEV"收敛给 SuperAdmin,哪怕 ADMIN 的 DeptPath 前缀豁免仍在。
  1099. //
  1100. // 断言:403 + 文案"仅超级管理员可将用户调入研发部门";DB 零副作用(deptId 仍为原值)。
  1101. func TestUpdateUser_H_R14_1_AdminCannotMoveToDevDept(t *testing.T) {
  1102. bootstrap := ctxhelper.SuperAdminCtx()
  1103. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1104. conn := testutil.GetTestSqlConn()
  1105. adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin", "/8100/", consts.DeptTypeNormal)
  1106. srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src", "/8100/1/", consts.DeptTypeNormal)
  1107. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_dev", "/8200/", consts.DeptTypeDev)
  1108. targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt", srcDeptId)
  1109. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1110. t.Cleanup(func() {
  1111. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1112. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1113. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, srcDeptId, devDeptId)
  1114. })
  1115. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  1116. UserId: 99991, Username: "h_r14_1_admin",
  1117. IsSuperAdmin: false,
  1118. MemberType: consts.MemberTypeAdmin,
  1119. Status: consts.StatusEnabled,
  1120. ProductCode: "test_product",
  1121. DeptId: adminDeptId,
  1122. DeptPath: "/8100/",
  1123. MinPermsLevel: math.MaxInt64,
  1124. })
  1125. newDept := devDeptId
  1126. err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1127. Id: targetId, DeptId: &newDept,
  1128. })
  1129. require.Error(t, err, "ADMIN 调入 DEV 必须被拒绝")
  1130. var ce *response.CodeError
  1131. require.True(t, errors.As(err, &ce))
  1132. assert.Equal(t, 403, ce.Code(),
  1133. "H-R14-1:必须 403 关闭跨产品权限升级路径,不得降级为 400/200")
  1134. assert.Contains(t, ce.Error(), "仅超级管理员可将用户调入研发部门",
  1135. "错误文案须明确指向'DEV 部门收敛到 SuperAdmin'的产品决策,方便 SRE 日志定位")
  1136. // 关键:DB 不得被任何形式污染 —— 即便返回 403,sys_user.deptId 也必须停留在 srcDept。
  1137. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1138. require.NoError(t, err)
  1139. assert.Equal(t, srcDeptId, u.DeptId,
  1140. "被拒绝的调入 DEV 请求必须对 DB 零副作用,否则等于无视 403 的 bypass")
  1141. }
  1142. // TC-1125: H-R14-1 对偶正向 —— SuperAdmin 调入 DEV 必须成功。
  1143. // 确保护栏只卡"非超管"这一条路径,不会把 SuperAdmin 的合法运维动作误伤。
  1144. func TestUpdateUser_H_R14_1_SuperAdminCanMoveToDevDept(t *testing.T) {
  1145. bootstrap := ctxhelper.SuperAdminCtx()
  1146. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1147. conn := testutil.GetTestSqlConn()
  1148. srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_src", "/8300/", consts.DeptTypeNormal)
  1149. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_dev", "/8400/", consts.DeptTypeDev)
  1150. targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_su_tgt", srcDeptId)
  1151. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1152. t.Cleanup(func() {
  1153. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1154. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1155. testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, devDeptId)
  1156. })
  1157. superCtx := ctxhelper.SuperAdminCtx()
  1158. newDept := devDeptId
  1159. require.NoError(t,
  1160. NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1161. Id: targetId, DeptId: &newDept,
  1162. }),
  1163. "SuperAdmin 调入 DEV 必须允许,否则会把合法运维动作误杀")
  1164. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1165. require.NoError(t, err)
  1166. assert.Equal(t, devDeptId, u.DeptId, "SuperAdmin 路径下 DeptId 必须真的写入 DEV 部门")
  1167. }
  1168. // TC-1126: H-R14-1 负向回归 —— 确认 DEV 护栏不会把"ADMIN 跨子树调入 NORMAL"的合法动作误伤。
  1169. // ADMIN 跨子树调入 NORMAL 部门本就有 TC-0748 保证放行;本用例是为 H-R14-1 修复后再做一次确认,
  1170. // 防止未来在 DEV 护栏基础上把判断一不小心写成 `newDept.DeptType != DeptTypeNormal || !SuperAdmin`
  1171. // 之类的过度收紧。
  1172. func TestUpdateUser_H_R14_1_AdminCanMoveToCrossSubtreeNormal(t *testing.T) {
  1173. bootstrap := ctxhelper.SuperAdminCtx()
  1174. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1175. conn := testutil.GetTestSqlConn()
  1176. adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin_x", "/8500/", consts.DeptTypeNormal)
  1177. farDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_far", "/8600/", consts.DeptTypeNormal)
  1178. srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src_x", "/8500/1/", consts.DeptTypeNormal)
  1179. targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt_x", srcDeptId)
  1180. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1181. t.Cleanup(func() {
  1182. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1183. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1184. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, farDeptId, srcDeptId)
  1185. })
  1186. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  1187. UserId: 99992, Username: "h_r14_1_admin_x",
  1188. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  1189. Status: consts.StatusEnabled, ProductCode: "test_product",
  1190. DeptId: adminDeptId, DeptPath: "/8500/", MinPermsLevel: math.MaxInt64,
  1191. })
  1192. newDept := farDeptId
  1193. require.NoError(t,
  1194. NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1195. Id: targetId, DeptId: &newDept,
  1196. }),
  1197. "ADMIN 跨子树调入 NORMAL 部门必须仍然放行;H-R14-1 修复只封堵 DEV 这一条路径")
  1198. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1199. require.NoError(t, err)
  1200. assert.Equal(t, farDeptId, u.DeptId)
  1201. }