updateUserLogic_test.go 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551
  1. package user
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "math"
  8. "perms-system-server/internal/consts"
  9. "perms-system-server/internal/loaders"
  10. deptLogic "perms-system-server/internal/logic/dept"
  11. "perms-system-server/internal/middleware"
  12. deptModel "perms-system-server/internal/model/dept"
  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/ctxhelper"
  18. "perms-system-server/internal/types"
  19. "sync"
  20. "sync/atomic"
  21. "testing"
  22. "time"
  23. "github.com/stretchr/testify/assert"
  24. "github.com/stretchr/testify/require"
  25. "github.com/zeromicro/go-zero/core/stores/redis"
  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: 产品 ADMIN 也必须满足 DeptPath 前缀校验,才能将成员调入目标部门。
  668. // 背景:sys_user.deptId 是全局字段,产品 ADMIN 在 P1 的授权边界止于 P1;如果允许 ADMIN 跨
  669. // 子树挪动同时归属 P1/P2 的共享成员,会在 P2 视角造成"目标 DeptPath 不再以任何 P2 管理员
  670. // 的 DeptPath 为前缀"的结构性失联——与"deptId=0 把用户挪出部门树"是同构的攻击链。
  671. // 因此 UpdateUser 对非超管(含 ADMIN)的 DeptPath 前缀校验必须生效;仅 SuperAdmin 可跨子树。
  672. func TestUpdateUser_ProductAdminMustPassSubtreeCheck(t *testing.T) {
  673. bootstrap := context.Background()
  674. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  675. conn := testutil.GetTestSqlConn()
  676. adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "admin_home", "/300/")
  677. targetHomeDept := insertTestDeptForScope(t, bootstrap, svcCtx, "target_home", "/400/")
  678. anywhereDept := insertTestDeptForScope(t, bootstrap, svcCtx, "anywhere", "/500/")
  679. targetId := insertTestUserWithDept(t, bootstrap, "lf_admin", targetHomeDept)
  680. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  681. t.Cleanup(func() {
  682. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  683. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  684. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetHomeDept, anywhereDept)
  685. })
  686. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  687. UserId: 77777, Username: "lf_admin",
  688. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  689. Status: consts.StatusEnabled, ProductCode: "test_product",
  690. DeptId: adminDeptId, DeptPath: "/300/", MinPermsLevel: math.MaxInt64,
  691. })
  692. newDept := anywhereDept
  693. err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  694. Id: targetId, DeptId: &newDept,
  695. })
  696. require.Error(t, err, "ADMIN 跨子树调入非自己管辖的部门必须被拒绝")
  697. var ce *response.CodeError
  698. require.True(t, errors.As(err, &ce))
  699. assert.Equal(t, 403, ce.Code())
  700. assert.Contains(t, ce.Error(), "无权将用户调入")
  701. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  702. require.NoError(t, err)
  703. assert.Equal(t, targetHomeDept, u.DeptId,
  704. "被拒绝的请求必须对 DB 零副作用,避免 403 伴随脏写入")
  705. }
  706. func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) {
  707. bootstrap := context.Background()
  708. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  709. conn := testutil.GetTestSqlConn()
  710. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_caller_dev", "/700/")
  711. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_target_dev", "/700/1/")
  712. targetId := insertTestUserWithDept(t, bootstrap, "h4_dev", targetDeptId)
  713. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  714. t.Cleanup(func() {
  715. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  716. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  717. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId)
  718. })
  719. devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  720. UserId: 88881,
  721. Username: "h4_dev",
  722. IsSuperAdmin: false,
  723. MemberType: consts.MemberTypeDeveloper,
  724. Status: consts.StatusEnabled,
  725. ProductCode: "test_product",
  726. DeptId: callerDeptId,
  727. DeptPath: "/700/",
  728. MinPermsLevel: math.MaxInt64,
  729. })
  730. zero := int64(0)
  731. err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  732. Id: targetId,
  733. DeptId: &zero,
  734. })
  735. require.Error(t, err, "DEVELOPER 不得把目标移出部门树")
  736. var ce *response.CodeError
  737. require.True(t, errors.As(err, &ce))
  738. assert.Equal(t, 403, ce.Code())
  739. assert.Contains(t, ce.Error(), "仅超级管理员可将用户移出部门",
  740. "L-R15-1:文案已收敛——产品 ADMIN 不再享有此权限,DEVELOPER 自然也不行;"+
  741. "断言仅匹配'仅超级管理员...'前缀即可覆盖所有非超管拒绝分支")
  742. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  743. require.NoError(t, err)
  744. assert.Equal(t, targetDeptId, u.DeptId, "被拒绝的请求对 DB 零副作用")
  745. }
  746. // TC-0815: MEMBER 调用者同理被拒(即便是修改自身的其他字段也不能顺手把自己移出部门)。
  747. // 用户修改自身时,路由层 if caller.UserId == req.Id 分支只拦 DeptId != nil/Status != 0;
  748. // 但修改他人为 deptId=0 的分支仍必须 403,以防任何下级调用者漂白组织结构。
  749. func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) {
  750. bootstrap := context.Background()
  751. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  752. conn := testutil.GetTestSqlConn()
  753. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_caller", "/800/")
  754. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_target", "/800/1/")
  755. targetId := insertTestUserWithDept(t, bootstrap, "h4_mem", targetDeptId)
  756. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  757. t.Cleanup(func() {
  758. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  759. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  760. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId)
  761. })
  762. memberCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  763. UserId: 88882,
  764. Username: "h4_mem",
  765. IsSuperAdmin: false,
  766. MemberType: consts.MemberTypeMember,
  767. Status: consts.StatusEnabled,
  768. ProductCode: "test_product",
  769. DeptId: callerDeptId,
  770. DeptPath: "/800/",
  771. MinPermsLevel: 10,
  772. })
  773. zero := int64(0)
  774. err := NewUpdateUserLogic(memberCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  775. Id: targetId,
  776. DeptId: &zero,
  777. })
  778. require.Error(t, err, "MEMBER 更不得移出他人")
  779. var ce *response.CodeError
  780. require.True(t, errors.As(err, &ce))
  781. assert.Equal(t, 403, ce.Code())
  782. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  783. require.NoError(t, err)
  784. assert.Equal(t, targetDeptId, u.DeptId)
  785. }
  786. // TC-0816(L-R15-1 后契约反转):产品 ADMIN **不再**拥有"把他人移出部门"的权限。
  787. // sys_user.deptId 是全局字段,P1 ADMIN 原先可以让共有成员 B 在 P2 视角下变成
  788. // "DeptId=0 的孤儿"——P2 的 MEMBER/DEVELOPER/子 ADMIN 全部通不过 checkDeptHierarchy
  789. // 的目标部门校验,B 成为 P2 侧的"隐形成员",DeptTree 里也找不到。
  790. // 该破坏组织结构语义的操作属于离职/转岗的 HR 行政流程,应当收敛给 SuperAdmin。
  791. //
  792. // 断言重点不只是 403:还要验证 DB 零副作用(deptId 保持原值),防止实现从
  793. // "check+exec"退化成"exec 后补 check"漏了副作用清理。
  794. func TestUpdateUser_ProductAdminCannotMoveTargetOutOfDept(t *testing.T) {
  795. bootstrap := context.Background()
  796. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  797. conn := testutil.GetTestSqlConn()
  798. adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin", "/900/")
  799. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin_target", "/900/1/")
  800. targetId := insertTestUserWithDept(t, bootstrap, "h4_admin_tgt", targetDeptId)
  801. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  802. t.Cleanup(func() {
  803. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  804. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  805. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetDeptId)
  806. })
  807. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  808. UserId: 88883, Username: "h4_admin",
  809. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  810. Status: consts.StatusEnabled, ProductCode: "test_product",
  811. DeptId: adminDeptId, DeptPath: "/900/", MinPermsLevel: math.MaxInt64,
  812. })
  813. zero := int64(0)
  814. err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  815. Id: targetId, DeptId: &zero,
  816. })
  817. require.Error(t, err, "产品 ADMIN 调 deptId=0 必须被 L-R15-1 拦下")
  818. var ce *response.CodeError
  819. require.True(t, errors.As(err, &ce))
  820. assert.Equal(t, 403, ce.Code(),
  821. "仅超级管理员可将用户移出部门——产品 ADMIN 必须 403")
  822. assert.Contains(t, ce.Error(), "仅超级管理员",
  823. "文案必须显式指向超级管理员权限,避免接入方继续误以为'产品管理员也可以'")
  824. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  825. require.NoError(t, err)
  826. assert.Equal(t, targetDeptId, u.DeptId,
  827. "被 403 拒绝的请求必须对 DB 零副作用——deptId 不得从 "+
  828. "targetDeptId 退化为 0,防止 'check 失败但 exec 已落盘' 的绕过实现")
  829. }
  830. // TC-0817: SuperAdmin 有权将他人移出部门(豁免路径)。
  831. func TestUpdateUser_SuperAdminCanMoveTargetOutOfDept(t *testing.T) {
  832. bootstrap := context.Background()
  833. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  834. conn := testutil.GetTestSqlConn()
  835. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_sa_target", "/950/")
  836. targetId := insertTestUserWithDept(t, bootstrap, "h4_sa_tgt", targetDeptId)
  837. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  838. t.Cleanup(func() {
  839. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  840. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  841. testutil.CleanTable(bootstrap, conn, "`sys_dept`", targetDeptId)
  842. })
  843. zero := int64(0)
  844. require.NoError(t,
  845. NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
  846. Id: targetId, DeptId: &zero,
  847. }),
  848. "SuperAdmin 的 deptId=0 操作是合法的顶层运维")
  849. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  850. require.NoError(t, err)
  851. assert.Equal(t, int64(0), u.DeptId)
  852. }
  853. func insertEnabledDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, name, path string) int64 {
  854. t.Helper()
  855. now := time.Now().Unix()
  856. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  857. ParentId: 0, Name: name + "_" + testutil.UniqueId(), Path: path, Sort: 0,
  858. DeptType: "NORMAL", Remark: "", Status: consts.StatusEnabled,
  859. CreateTime: now, UpdateTime: now,
  860. })
  861. require.NoError(t, err)
  862. id, _ := res.LastInsertId()
  863. return id
  864. }
  865. // TC-1083: UpdateUser tx 分支在 commit 成功后必须失效 sysUser 低层缓存
  866. func TestUpdateUser_DeptChange_PostCommitInvalidatesSysUserCache(t *testing.T) {
  867. bootstrap := context.Background()
  868. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  869. conn := testutil.GetTestSqlConn()
  870. rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
  871. prefix := testutil.GetTestCachePrefix()
  872. srcDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_src", "/r12_1_src/")
  873. dstDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_dst", "/r12_1_dst/")
  874. targetId := insertTestUserFull(t, bootstrap, &userModel.SysUser{
  875. Username: "r12_1_upd_" + testutil.UniqueId(),
  876. Password: testutil.HashPassword("pw"),
  877. Nickname: "orig_nick",
  878. Avatar: sql.NullString{},
  879. Email: "[email protected]",
  880. Phone: "13800000000",
  881. Remark: "orig_remark",
  882. DeptId: srcDeptId,
  883. IsSuperAdmin: consts.IsSuperAdminNo,
  884. MustChangePassword: 2,
  885. Status: consts.StatusEnabled,
  886. })
  887. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  888. t.Cleanup(func() {
  889. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  890. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  891. testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, dstDeptId)
  892. })
  893. // 走一次 FindOne 预热 id / username 两把低层缓存;UpdateUser 内部 `l.svcCtx.SysUserModel.FindOne`
  894. // 也会预热,这里显式做一次把预置断言打实。
  895. pre, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  896. require.NoError(t, err)
  897. idKey := fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, targetId)
  898. usernameKey := fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, pre.Username)
  899. udKey := fmt.Sprintf("%s:ud:%d:%s", prefix, targetId, "test_product")
  900. cachedId, err := rds.Get(idKey)
  901. require.NoError(t, err)
  902. require.NotEmpty(t, cachedId, "预置:sysUser id 缓存已预热")
  903. // 先预热 UserDetails 聚合缓存(否则下面判断"Clean 之后为空"会因为本来就空而变成假通过)
  904. _, err = svcCtx.UserDetailsLoader.Load(bootstrap, targetId, "test_product")
  905. require.NoError(t, err)
  906. cachedUd, err := rds.Get(udKey)
  907. require.NoError(t, err)
  908. require.NotEmpty(t, cachedUd, "预置:UserDetails 聚合缓存已预热")
  909. callerCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  910. UserId: 88888, Username: "r12_1_super",
  911. IsSuperAdmin: true,
  912. MemberType: consts.MemberTypeAdmin,
  913. Status: consts.StatusEnabled,
  914. ProductCode: "test_product",
  915. MinPermsLevel: 0,
  916. })
  917. newDept := dstDeptId
  918. require.NoError(t,
  919. NewUpdateUserLogic(callerCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  920. Id: targetId,
  921. DeptId: &newDept,
  922. }),
  923. "超管改部门应成功走 tx 分支")
  924. // 关键断言 1:sysUser:id 低层缓存已被 post-commit 的 InvalidateProfileCache 清掉
  925. afterId, err := rds.Get(idKey)
  926. require.NoError(t, err)
  927. assert.Empty(t, afterId,
  928. "UpdateUser tx 分支返回后,sysUser:id 低层缓存必须已被 InvalidateProfileCache 失效;"+
  929. "若仍有值,则表示 Logic 层遗漏了 post-commit 的显式 DelCache 调用,"+
  930. "并发读回源时会沿用预热时的旧 deptId/昵称/备注 payload")
  931. // 关键断言 2:username 低层缓存也要被一并清掉
  932. afterUn, err := rds.Get(usernameKey)
  933. require.NoError(t, err)
  934. assert.Empty(t, afterUn,
  935. "sysUser:username 低层缓存也必须被 InvalidateProfileCache 同批失效")
  936. // 关键断言 3:UserDetails 聚合缓存也被清掉(l.svcCtx.UserDetailsLoader.Clean)
  937. afterUd, err := rds.Get(udKey)
  938. require.NoError(t, err)
  939. assert.Empty(t, afterUd,
  940. "UserDetailsLoader.Clean 必须在 post-commit 同一阶段被调用,"+
  941. "保证上层聚合缓存和下层 sysUser 缓存一起过期,避免读链任一环读到旧值")
  942. // 关键断言 4:下一轮 FindOne 取到新 deptId(双重验证:DB 为权威且缓存已经让步)
  943. after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  944. require.NoError(t, err)
  945. assert.Equal(t, dstDeptId, after.DeptId,
  946. "缓存失效后 FindOne 必须从 DB 读到 tx 已提交的新 deptId;"+
  947. "若缓存未清,这里仍会是 srcDeptId(cache stale 的最终症状)")
  948. }
  949. func TestUpdateUser_DeptIdSwitch_VsDeleteDept_NoWriteSkew(t *testing.T) {
  950. bootstrap := ctxhelper.SuperAdminCtx()
  951. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  952. conn := testutil.GetTestSqlConn()
  953. // 构造:用户 U 在 deptA,新候选部门 deptX 空(无子部门 + 无关联用户,满足 DeleteDept 可删条件)。
  954. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptA", "/3100/")
  955. deptXId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptX", "/3200/")
  956. targetId := insertTestUserWithDept(t, bootstrap, "m113_user", deptAId)
  957. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  958. t.Cleanup(func() {
  959. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  960. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  961. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId, deptXId)
  962. })
  963. // 超管身份用于 UpdateUser / DeleteDept。
  964. superCtx := ctxhelper.SuperAdminCtx()
  965. // 两个 goroutine 并发:
  966. // G1: UpdateUser targetId.deptId = deptXId
  967. // G2: DeleteDept deptXId
  968. var (
  969. wg sync.WaitGroup
  970. upErr atomic.Value
  971. upOK atomic.Bool
  972. delErr atomic.Value
  973. delOK atomic.Bool
  974. unexpected atomic.Value
  975. )
  976. start := make(chan struct{})
  977. wg.Add(2)
  978. go func() {
  979. defer wg.Done()
  980. <-start
  981. err := NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  982. Id: targetId,
  983. DeptId: &deptXId,
  984. })
  985. if err == nil {
  986. upOK.Store(true)
  987. } else {
  988. upErr.Store(err)
  989. }
  990. }()
  991. go func() {
  992. defer wg.Done()
  993. <-start
  994. err := deptLogic.NewDeleteDeptLogic(superCtx, svcCtx).DeleteDept(&types.DeleteDeptReq{
  995. Id: deptXId,
  996. })
  997. if err == nil {
  998. delOK.Store(true)
  999. } else {
  1000. delErr.Store(err)
  1001. }
  1002. }()
  1003. close(start)
  1004. wg.Wait()
  1005. // 允许的结果共两种:
  1006. // A) upOK && !delOK:user.deptId==deptX,sys_dept[deptX] 仍在,DeleteDept 收 400
  1007. // B) !upOK && delOK:user.deptId==deptA(未动),sys_dept[deptX] 已删,UpdateUser 收 400
  1008. // 绝不能同时成功(write skew)。DB 终态须自洽。
  1009. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  1010. require.NoError(t, err)
  1011. // dept X 存在性:**绕过 go-zero 的 WithCache**,直接从 MySQL 查真相,避免 UpdateUser
  1012. // 在 FindOne 时预热的缓存把 DeleteDept 的真实删除"遮住"。
  1013. var deptCount int64
  1014. require.NoError(t,
  1015. conn.QueryRowCtx(context.Background(), &deptCount,
  1016. "SELECT COUNT(*) FROM `sys_dept` WHERE `id` = ?", deptXId))
  1017. deptStillThere := deptCount > 0
  1018. switch {
  1019. case upOK.Load() && !delOK.Load():
  1020. assert.Equal(t, deptXId, u.DeptId,
  1021. "UpdateUser 胜出,user.deptId 必须为 deptX")
  1022. assert.True(t, deptStillThere,
  1023. "UpdateUser 胜出后 deptX 必须仍存在,否则存在 orphan 引用")
  1024. var ce *response.CodeError
  1025. require.NotNil(t, delErr.Load(), "DeleteDept 应返回 400 解释失败原因")
  1026. if errors.As(delErr.Load().(error), &ce) {
  1027. assert.Equal(t, 400, ce.Code(),
  1028. "DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'")
  1029. assert.Contains(t, ce.Error(), "关联用户")
  1030. }
  1031. case !upOK.Load() && delOK.Load():
  1032. assert.Equal(t, deptAId, u.DeptId,
  1033. "DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)")
  1034. assert.False(t, deptStillThere,
  1035. "DeleteDept 胜出后 deptX 必须已被删除")
  1036. var ce *response.CodeError
  1037. require.NotNil(t, upErr.Load(), "UpdateUser 应返回 400 解释失败原因")
  1038. if errors.As(upErr.Load().(error), &ce) {
  1039. assert.Equal(t, 400, ce.Code(),
  1040. "UpdateUser 发现目标 dept 已消失必须 400'部门不存在'")
  1041. assert.Contains(t, ce.Error(), "部门不存在")
  1042. }
  1043. case upOK.Load() && delOK.Load():
  1044. t.Fatalf("UpdateUser + DeleteDept **同时成功** —— write skew 未被闭合。" +
  1045. "DB 现在持有 user.deptId 指向已被删 dept 的 orphan 数据。")
  1046. case !upOK.Load() && !delOK.Load():
  1047. unexpected.Store(struct{ up, del error }{upErr.Load().(error), delErr.Load().(error)})
  1048. t.Fatalf("两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load())
  1049. }
  1050. }
  1051. // TC-1050: UpdateUser 只改 deptId 之外的字段(或 deptId=0)时不进事务(性能与锁范围约束)
  1052. // 这是修复的**对偶契约**:避免 DEV 未来不分 case 把所有 UpdateProfile 都塞进事务 / 或反之。
  1053. // 用"目标部门不存在但仅改 Nickname"的 case 证明:非 deptId 变更路径不需要 FindOneForShareTx,
  1054. // 且走的是 UpdateProfile(非事务)。该契约只能以"只调昵称也能成功"的正向场景间接证实:
  1055. func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T) {
  1056. bootstrap := ctxhelper.SuperAdminCtx()
  1057. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1058. conn := testutil.GetTestSqlConn()
  1059. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_onlyNick", "/3300/")
  1060. targetId := insertTestUserWithDept(t, bootstrap, "m113_onlyNick", deptAId)
  1061. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1062. t.Cleanup(func() {
  1063. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1064. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1065. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId)
  1066. })
  1067. newNick := "only_nick_mutate"
  1068. superCtx := ctxhelper.SuperAdminCtx()
  1069. require.NoError(t,
  1070. NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1071. Id: targetId, Nickname: &newNick,
  1072. }),
  1073. "只改昵称不应走事务路径(若走事务会无谓扩大锁范围)")
  1074. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  1075. require.NoError(t, err)
  1076. assert.Equal(t, newNick, u.Nickname)
  1077. assert.Equal(t, deptAId, u.DeptId, "deptId 未变")
  1078. }
  1079. // 备注:原本想写一条"deptId 从 A 改到 A 不走事务路径"的对偶用例,但 MySQL 对"所有字段都
  1080. // 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。
  1081. // 这是底层驱动/引擎层的 side-effect,非 关心的契约。若要验证该对偶,请同时改一个
  1082. // 真实字段(参见上面的 Nickname 用例)。
  1083. // insertTestDeptWithType —— 工具函数:按给定 DeptType 插入一个部门。
  1084. // 专用于 H-R14-1 场景:需要精确控制 DeptType=DEV 触发跨产品权限升级的护栏。
  1085. func insertTestDeptWithType(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path, deptType string) int64 {
  1086. t.Helper()
  1087. now := time.Now().Unix()
  1088. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  1089. ParentId: 0,
  1090. Name: tag + "_" + testutil.UniqueId(),
  1091. Path: path,
  1092. Sort: 0,
  1093. DeptType: deptType,
  1094. Status: consts.StatusEnabled,
  1095. CreateTime: now,
  1096. UpdateTime: now,
  1097. })
  1098. require.NoError(t, err)
  1099. id, _ := res.LastInsertId()
  1100. return id
  1101. }
  1102. // TC-1124: H-R14-1 —— 产品 ADMIN 将目标用户调入 DEV 部门必须 403,防止跨产品权限升级。
  1103. //
  1104. // 攻击链回放:
  1105. //
  1106. // P1.ADMIN 同时在 P2 也是普通成员 → target 同在 P1、P2 → P1.ADMIN 调用 UpdateUser
  1107. // 把 target.deptId 改到 DEV 部门。sys_user.deptId 是全局字段,一次改动立即让 target
  1108. // 在 **P2** 的 UD.loadPerms 里升级为"DEV 部门在编成员"—— 按 userDetailsLoader 的
  1109. // 全权分支,target 在 P2 将拥有 P2 全部 perm,等于绕过了 P2 信任边界。
  1110. // 因此本接口必须把"调入 DEV"收敛给 SuperAdmin,哪怕 ADMIN 的 DeptPath 前缀豁免仍在。
  1111. //
  1112. // 断言:403 + 文案"仅超级管理员可将用户调入研发部门";DB 零副作用(deptId 仍为原值)。
  1113. func TestUpdateUser_H_R14_1_AdminCannotMoveToDevDept(t *testing.T) {
  1114. bootstrap := ctxhelper.SuperAdminCtx()
  1115. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1116. conn := testutil.GetTestSqlConn()
  1117. adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin", "/8100/", consts.DeptTypeNormal)
  1118. srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src", "/8100/1/", consts.DeptTypeNormal)
  1119. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_dev", "/8200/", consts.DeptTypeDev)
  1120. targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt", srcDeptId)
  1121. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1122. t.Cleanup(func() {
  1123. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1124. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1125. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, srcDeptId, devDeptId)
  1126. })
  1127. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  1128. UserId: 99991, Username: "h_r14_1_admin",
  1129. IsSuperAdmin: false,
  1130. MemberType: consts.MemberTypeAdmin,
  1131. Status: consts.StatusEnabled,
  1132. ProductCode: "test_product",
  1133. DeptId: adminDeptId,
  1134. DeptPath: "/8100/",
  1135. MinPermsLevel: math.MaxInt64,
  1136. })
  1137. newDept := devDeptId
  1138. err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1139. Id: targetId, DeptId: &newDept,
  1140. })
  1141. require.Error(t, err, "ADMIN 调入 DEV 必须被拒绝")
  1142. var ce *response.CodeError
  1143. require.True(t, errors.As(err, &ce))
  1144. assert.Equal(t, 403, ce.Code(),
  1145. "H-R14-1:必须 403 关闭跨产品权限升级路径,不得降级为 400/200")
  1146. assert.Contains(t, ce.Error(), "仅超级管理员可将用户调入研发部门",
  1147. "错误文案须明确指向'DEV 部门收敛到 SuperAdmin'的产品决策,方便 SRE 日志定位")
  1148. // 关键:DB 不得被任何形式污染 —— 即便返回 403,sys_user.deptId 也必须停留在 srcDept。
  1149. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1150. require.NoError(t, err)
  1151. assert.Equal(t, srcDeptId, u.DeptId,
  1152. "被拒绝的调入 DEV 请求必须对 DB 零副作用,否则等于无视 403 的 bypass")
  1153. }
  1154. // TC-1125: H-R14-1 对偶正向 —— SuperAdmin 调入 DEV 必须成功。
  1155. // 确保护栏只卡"非超管"这一条路径,不会把 SuperAdmin 的合法运维动作误伤。
  1156. func TestUpdateUser_H_R14_1_SuperAdminCanMoveToDevDept(t *testing.T) {
  1157. bootstrap := ctxhelper.SuperAdminCtx()
  1158. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1159. conn := testutil.GetTestSqlConn()
  1160. srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_src", "/8300/", consts.DeptTypeNormal)
  1161. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_dev", "/8400/", consts.DeptTypeDev)
  1162. targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_su_tgt", srcDeptId)
  1163. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1164. t.Cleanup(func() {
  1165. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1166. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1167. testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, devDeptId)
  1168. })
  1169. superCtx := ctxhelper.SuperAdminCtx()
  1170. newDept := devDeptId
  1171. require.NoError(t,
  1172. NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1173. Id: targetId, DeptId: &newDept,
  1174. }),
  1175. "SuperAdmin 调入 DEV 必须允许,否则会把合法运维动作误杀")
  1176. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1177. require.NoError(t, err)
  1178. assert.Equal(t, devDeptId, u.DeptId, "SuperAdmin 路径下 DeptId 必须真的写入 DEV 部门")
  1179. }
  1180. // TC-1126: 对偶正向 —— ADMIN 在"自己管辖子树内"的跨节点调动(同 DeptPath 前缀)必须放行,
  1181. // 避免 L-R16-1 把 DeptPath 前缀校验拉齐之后,误伤 ADMIN 在自己子树内的日常维护动作。
  1182. // 覆盖的不变量是:"严格前缀匹配"而不是"严格等于"——同子树 = 允许,跨子树 = 拒绝。
  1183. func TestUpdateUser_AdminCanMoveWithinOwnSubtree(t *testing.T) {
  1184. bootstrap := ctxhelper.SuperAdminCtx()
  1185. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1186. conn := testutil.GetTestSqlConn()
  1187. adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin_x", "/8500/", consts.DeptTypeNormal)
  1188. siblingDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_sibling", "/8500/2/", consts.DeptTypeNormal)
  1189. srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src_x", "/8500/1/", consts.DeptTypeNormal)
  1190. targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt_x", srcDeptId)
  1191. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1192. t.Cleanup(func() {
  1193. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1194. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1195. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, siblingDeptId, srcDeptId)
  1196. })
  1197. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  1198. UserId: 99992, Username: "h_r14_1_admin_x",
  1199. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  1200. Status: consts.StatusEnabled, ProductCode: "test_product",
  1201. DeptId: adminDeptId, DeptPath: "/8500/", MinPermsLevel: math.MaxInt64,
  1202. })
  1203. newDept := siblingDeptId
  1204. require.NoError(t,
  1205. NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1206. Id: targetId, DeptId: &newDept,
  1207. }),
  1208. "ADMIN 在自己子树内(/8500/ 前缀下)的跨节点调动必须放行,否则 ADMIN 无法履职")
  1209. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1210. require.NoError(t, err)
  1211. assert.Equal(t, siblingDeptId, u.DeptId)
  1212. }
  1213. // TC-1170: 跨产品场景下,P1 ADMIN 不能把同时是 P2 成员的 target 挪到 P1 子树之外的 NORMAL
  1214. // 部门。攻击面:调用者 ADMIN 在产品 P1 的授权面仅覆盖 P1,但 sys_user.deptId 是**全局**字段,
  1215. // 一旦放行,target 在 P2 视角里的 DeptPath 会落到一个 P2 管理层根本看不到的子树上
  1216. // (P2 的 checkDeptHierarchy 立刻把所有 P2 管理员对 target 的管理动作全部 403)——与
  1217. // deptId=0 的 L-R15-1 攻击链完全同构。必须在 UpdateUser 这一层把 ADMIN 跨子树动作拦截。
  1218. func TestUpdateUser_AdminWithDualProductTarget_CrossSubtreeBlocked(t *testing.T) {
  1219. bootstrap := ctxhelper.SuperAdminCtx()
  1220. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1221. conn := testutil.GetTestSqlConn()
  1222. p1 := "p_dual_1_" + testutil.UniqueId()
  1223. p2 := "p_dual_2_" + testutil.UniqueId()
  1224. adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_p1_admin", "/9100/")
  1225. // target 原属 P1 子树;攻击方向是"挪到 /9200/ —— 完全不在 /9100/ 前缀下"。
  1226. srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_src", "/9100/1/")
  1227. otherSubtreeId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_other", "/9200/")
  1228. targetId := insertTestUserWithDept(t, bootstrap, "dual_tgt", srcDeptId)
  1229. m1 := insertTestMember(t, svcCtx, p1, targetId)
  1230. m2 := insertTestMember(t, svcCtx, p2, targetId)
  1231. t.Cleanup(func() {
  1232. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", m1, m2)
  1233. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1234. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, srcDeptId, otherSubtreeId)
  1235. })
  1236. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  1237. UserId: 66610, Username: "dual_admin",
  1238. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  1239. Status: consts.StatusEnabled, ProductCode: p1,
  1240. DeptId: adminDeptId, DeptPath: "/9100/", MinPermsLevel: math.MaxInt64,
  1241. })
  1242. newDept := otherSubtreeId
  1243. err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1244. Id: targetId, DeptId: &newDept,
  1245. })
  1246. require.Error(t, err, "跨产品共享成员 + ADMIN 跨子树调度 = L-R15-1 同构越权,必须 403")
  1247. var ce *response.CodeError
  1248. require.True(t, errors.As(err, &ce))
  1249. assert.Equal(t, 403, ce.Code())
  1250. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1251. require.NoError(t, err)
  1252. assert.Equal(t, srcDeptId, u.DeptId,
  1253. "DB 不得被污染——否则 P2 视角会立刻失联:P2 所有管理动作对 target 都会 403")
  1254. }
  1255. // TC-1171: UpdateUser 把 target 从"DEV+Enabled 部门"调到 NORMAL 部门 → sys_user.tokenVersion +1。
  1256. // 收窄方向:loadPerms 的 DEV 全权分支以 (MemberType!="" && DeptType=DEV && DeptStatus=Enabled)
  1257. // 为条件,DEV→NORMAL 让本用户在**所有**他已加入的产品里同时失去全权。必须在 UpdateUser 的
  1258. // 事务内把 sys_user.tokenVersion 原子性 +1,让 jwtauthMiddleware 下一次校验 401 旧 access token,
  1259. // 而不是等 UD 聚合缓存的 5min TTL 自然过期。
  1260. func TestUpdateUser_L_R16_2_DevToNormal_RevokesSession(t *testing.T) {
  1261. bootstrap := ctxhelper.SuperAdminCtx()
  1262. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1263. conn := testutil.GetTestSqlConn()
  1264. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_dev", "/1100/", consts.DeptTypeDev)
  1265. normalDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_normal", "/1200/", consts.DeptTypeNormal)
  1266. targetId := insertTestUserWithDept(t, bootstrap, "l_r16_tgt", devDeptId)
  1267. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1268. t.Cleanup(func() {
  1269. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1270. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1271. testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId, normalDeptId)
  1272. })
  1273. before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1274. require.NoError(t, err)
  1275. prevTokenVersion := before.TokenVersion
  1276. newDept := normalDeptId
  1277. require.NoError(t,
  1278. NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
  1279. Id: targetId, DeptId: &newDept,
  1280. }),
  1281. "SuperAdmin 调出 DEV 是合法运维,必须成功")
  1282. after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1283. require.NoError(t, err)
  1284. assert.Equal(t, normalDeptId, after.DeptId, "deptId 必须真实落到 NORMAL 部门")
  1285. assert.Equal(t, prevTokenVersion+1, after.TokenVersion,
  1286. "DEV→NORMAL 构成 DEV 全权收窄,必须同事务 +1;否则 5min TTL 窗口内旧 access token 仍拥有全权")
  1287. }
  1288. // TC-1172: UpdateUser 把 target 从"DEV+Enabled 部门"挪到 deptId=0(移出部门树,SuperAdmin-only)
  1289. // 同样是 DEV 全权收窄,必须把 tokenVersion +1;这里覆盖"newDept == nil 分支"的 devAccessRevoked 判定。
  1290. func TestUpdateUser_L_R16_2_DevToDeptZero_RevokesSession(t *testing.T) {
  1291. bootstrap := ctxhelper.SuperAdminCtx()
  1292. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1293. conn := testutil.GetTestSqlConn()
  1294. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_dev0", "/1300/", consts.DeptTypeDev)
  1295. targetId := insertTestUserWithDept(t, bootstrap, "l_r16_dev0_tgt", devDeptId)
  1296. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1297. t.Cleanup(func() {
  1298. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1299. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1300. testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId)
  1301. })
  1302. before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1303. require.NoError(t, err)
  1304. prevTokenVersion := before.TokenVersion
  1305. zero := int64(0)
  1306. require.NoError(t,
  1307. NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
  1308. Id: targetId, DeptId: &zero,
  1309. }))
  1310. after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1311. require.NoError(t, err)
  1312. assert.Equal(t, int64(0), after.DeptId)
  1313. assert.Equal(t, prevTokenVersion+1, after.TokenVersion,
  1314. "DEV→deptId=0 同样构成 DEV 全权收窄,tokenVersion 必须 +1")
  1315. }
  1316. // TC-1173: UpdateUser 从 NORMAL→NORMAL 挪动 target 不得递增 tokenVersion —— 不构成任何权限收窄,
  1317. // 升级为"吊销会话"会把合法用户无故踢下线,损害可用性。
  1318. func TestUpdateUser_L_R16_2_NormalToNormal_NoRevoke(t *testing.T) {
  1319. bootstrap := ctxhelper.SuperAdminCtx()
  1320. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  1321. conn := testutil.GetTestSqlConn()
  1322. srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r16_n2n_src", "/1400/")
  1323. dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r16_n2n_dst", "/1500/")
  1324. targetId := insertTestUserWithDept(t, bootstrap, "l_r16_n2n_tgt", srcDeptId)
  1325. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1326. t.Cleanup(func() {
  1327. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1328. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1329. testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, dstDeptId)
  1330. })
  1331. before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1332. require.NoError(t, err)
  1333. prevTokenVersion := before.TokenVersion
  1334. newDept := dstDeptId
  1335. require.NoError(t,
  1336. NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
  1337. Id: targetId, DeptId: &newDept,
  1338. }))
  1339. after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  1340. require.NoError(t, err)
  1341. assert.Equal(t, dstDeptId, after.DeptId)
  1342. assert.Equal(t, prevTokenVersion, after.TokenVersion,
  1343. "NORMAL→NORMAL 不是收窄方向,tokenVersion 必须保持不变,否则等于无故 401 合法会话")
  1344. }