updateDeptLogic_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. package dept
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "perms-system-server/internal/consts"
  7. "perms-system-server/internal/loaders"
  8. "perms-system-server/internal/middleware"
  9. deptModel "perms-system-server/internal/model/dept"
  10. userModel "perms-system-server/internal/model/user"
  11. "perms-system-server/internal/response"
  12. "perms-system-server/internal/svc"
  13. "perms-system-server/internal/testutil"
  14. "perms-system-server/internal/testutil/ctxhelper"
  15. "perms-system-server/internal/testutil/mocks"
  16. "perms-system-server/internal/types"
  17. "testing"
  18. "time"
  19. "github.com/stretchr/testify/assert"
  20. "github.com/stretchr/testify/require"
  21. "go.uber.org/mock/gomock"
  22. )
  23. func TestUpdateDept_Normal(t *testing.T) {
  24. ctx := ctxhelper.SuperAdminCtx()
  25. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  26. conn := testutil.GetTestSqlConn()
  27. deptId, err := insertDeptRaw(ctx, svcCtx, 0, "upd_"+testutil.UniqueId(), "/")
  28. require.NoError(t, err)
  29. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  30. newName := "updated_" + testutil.UniqueId()
  31. l := NewUpdateDeptLogic(ctx, svcCtx)
  32. err = l.UpdateDept(&types.UpdateDeptReq{
  33. Id: deptId,
  34. Name: newName,
  35. Sort: 99,
  36. Remark: "updated remark",
  37. Status: 2,
  38. })
  39. require.NoError(t, err)
  40. d, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
  41. require.NoError(t, err)
  42. assert.Equal(t, newName, d.Name)
  43. assert.Equal(t, int64(99), d.Sort)
  44. assert.Equal(t, "updated remark", d.Remark)
  45. assert.Equal(t, int64(2), d.Status)
  46. }
  47. // TC-0102: 不存在
  48. func TestUpdateDept_NotFound(t *testing.T) {
  49. ctx := ctxhelper.SuperAdminCtx()
  50. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  51. l := NewUpdateDeptLogic(ctx, svcCtx)
  52. err := l.UpdateDept(&types.UpdateDeptReq{
  53. Id: 999999999,
  54. Name: "ghost_" + testutil.UniqueId(),
  55. })
  56. require.Error(t, err)
  57. var ce *response.CodeError
  58. require.True(t, errors.As(err, &ce))
  59. assert.Equal(t, 404, ce.Code())
  60. assert.Equal(t, "部门不存在", ce.Error())
  61. }
  62. // TC-0101: 正常更新
  63. func TestUpdateDept_StatusZeroUnchanged(t *testing.T) {
  64. ctx := ctxhelper.SuperAdminCtx()
  65. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  66. conn := testutil.GetTestSqlConn()
  67. deptId, err := insertDeptRaw(ctx, svcCtx, 0, "s0_"+testutil.UniqueId(), "/")
  68. require.NoError(t, err)
  69. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  70. before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
  71. require.NoError(t, err)
  72. assert.Equal(t, int64(1), before.Status)
  73. l := NewUpdateDeptLogic(ctx, svcCtx)
  74. err = l.UpdateDept(&types.UpdateDeptReq{
  75. Id: deptId,
  76. Name: "changed_" + testutil.UniqueId(),
  77. Status: 0,
  78. })
  79. require.NoError(t, err)
  80. after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
  81. require.NoError(t, err)
  82. assert.Equal(t, int64(1), after.Status)
  83. }
  84. // TC-0103: DeptType NORMAL→DEV
  85. func TestUpdateDept_DeptType_NormalToDev(t *testing.T) {
  86. ctx := ctxhelper.SuperAdminCtx()
  87. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  88. conn := testutil.GetTestSqlConn()
  89. deptId, err := insertDeptRaw(ctx, svcCtx, 0, "dt_"+testutil.UniqueId(), "/")
  90. require.NoError(t, err)
  91. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  92. before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
  93. require.NoError(t, err)
  94. assert.Equal(t, "NORMAL", before.DeptType)
  95. l := NewUpdateDeptLogic(ctx, svcCtx)
  96. err = l.UpdateDept(&types.UpdateDeptReq{
  97. Id: deptId,
  98. Name: "dt_changed_" + testutil.UniqueId(),
  99. DeptType: "DEV",
  100. })
  101. require.NoError(t, err)
  102. after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
  103. require.NoError(t, err)
  104. assert.Equal(t, "DEV", after.DeptType)
  105. }
  106. // TC-0104: DeptType无效值返回错误
  107. func TestUpdateDept_DeptType_InvalidRejected(t *testing.T) {
  108. ctx := ctxhelper.SuperAdminCtx()
  109. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  110. conn := testutil.GetTestSqlConn()
  111. deptId, err := insertDeptRaw(ctx, svcCtx, 0, "dti_"+testutil.UniqueId(), "/")
  112. require.NoError(t, err)
  113. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  114. l := NewUpdateDeptLogic(ctx, svcCtx)
  115. err = l.UpdateDept(&types.UpdateDeptReq{
  116. Id: deptId,
  117. Name: "dti_changed_" + testutil.UniqueId(),
  118. DeptType: "INVALID_TYPE",
  119. })
  120. require.Error(t, err)
  121. var ce *response.CodeError
  122. require.True(t, errors.As(err, &ce))
  123. assert.Equal(t, 400, ce.Code())
  124. assert.Contains(t, ce.Error(), "部门类型无效")
  125. after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
  126. require.NoError(t, err)
  127. assert.Equal(t, "NORMAL", after.DeptType, "无效DeptType不应修改数据库")
  128. }
  129. // TC-0533: updateDept非超管拒绝
  130. func TestUpdateDept_NonSuperAdminRejected(t *testing.T) {
  131. ctx := ctxhelper.AdminCtx("test_product")
  132. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  133. l := NewUpdateDeptLogic(ctx, svcCtx)
  134. err := l.UpdateDept(&types.UpdateDeptReq{Id: 1, Name: "test"})
  135. require.Error(t, err)
  136. var ce *response.CodeError
  137. require.True(t, errors.As(err, &ce))
  138. assert.Equal(t, 403, ce.Code())
  139. }
  140. func superAdminCtx() context.Context {
  141. return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  142. UserId: 1, Username: "su",
  143. IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin,
  144. Status: consts.StatusEnabled,
  145. })
  146. }
  147. // TC-0848: deptType 变更 → FindIdsByDeptId 恰好调用 1 次,返回 [100, 101];handler 返回 nil。
  148. // NORMAL→DEV 方向是放宽权限,不吊销会话;post-commit 仍需 FindIdsByDeptId 清 UD 聚合缓存。
  149. func TestUpdateDept_DeptTypeChanged_InvokesFindIdsOnce(t *testing.T) {
  150. ctrl := gomock.NewController(t)
  151. t.Cleanup(ctrl.Finish)
  152. deptMock := mocks.NewMockSysDeptModel(ctrl)
  153. userMock := mocks.NewMockSysUserModel(ctrl)
  154. // 老部门是 NORMAL,请求改成 DEV → deptTypeChanged=true 才能触发 FindIdsByDeptId。
  155. deptMock.EXPECT().FindOne(gomock.Any(), int64(77)).
  156. Return(&deptModel.SysDept{
  157. Id: 77, Name: "n", DeptType: consts.DeptTypeNormal,
  158. Status: consts.StatusEnabled, UpdateTime: 500,
  159. }, nil)
  160. runDeptTxInline(deptMock)
  161. deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(500)).Return(nil)
  162. deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(77))
  163. // 关键断言:post-commit 恰好调用 1 次;返回的切片会被继续塞进 CleanByUserIds(loader 内部走 Redis)。
  164. userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(77)).
  165. Return([]int64{100, 101}, nil).Times(1)
  166. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  167. Dept: deptMock, User: userMock,
  168. })
  169. err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
  170. Id: 77, Name: "n", Sort: 0, Remark: "", DeptType: consts.DeptTypeDev,
  171. })
  172. require.NoError(t, err,
  173. "正常场景 UpdateDept 必须返回 nil,且仅调用一次 FindIdsByDeptId")
  174. }
  175. // TC-0849: FindIdsByDeptId 返回 err —— handler 仍返回 nil(degraded 成功),旧缓存由 TTL 过期兜底。
  176. func TestUpdateDept_FindIdsByDeptIdError_DegradedSuccess(t *testing.T) {
  177. ctrl := gomock.NewController(t)
  178. t.Cleanup(ctrl.Finish)
  179. deptMock := mocks.NewMockSysDeptModel(ctrl)
  180. userMock := mocks.NewMockSysUserModel(ctrl)
  181. deptMock.EXPECT().FindOne(gomock.Any(), int64(88)).
  182. Return(&deptModel.SysDept{
  183. Id: 88, Name: "n", DeptType: consts.DeptTypeNormal,
  184. Status: consts.StatusEnabled, UpdateTime: 1000,
  185. }, nil)
  186. runDeptTxInline(deptMock)
  187. deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(1000)).Return(nil)
  188. deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(88))
  189. userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(88)).
  190. Return(nil, errors.New("transient DB error"))
  191. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  192. Dept: deptMock, User: userMock,
  193. })
  194. err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
  195. Id: 88, Name: "n", DeptType: consts.DeptTypeDev,
  196. })
  197. assert.NoError(t, err,
  198. "FindIdsByDeptId 失败不得映射 500;TTL 过期兜底,客户端不应重试整次 UpdateDept")
  199. }
  200. // seedDeptWithUser 建一个自定义 DeptType/Status 的部门,并挂一个新 sys_user 到该部门下。
  201. // 返回 (deptId, userId);统一 cleanup 由调用方负责,避免测试之间相互拖拽。
  202. func seedDeptWithUser(t *testing.T, svcCtx *svc.ServiceContext,
  203. tag, path, deptType string, status int64) (int64, int64) {
  204. t.Helper()
  205. bootstrap := ctxhelper.SuperAdminCtx()
  206. now := time.Now().Unix()
  207. dRes, err := svcCtx.SysDeptModel.Insert(bootstrap, &deptModel.SysDept{
  208. ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0,
  209. DeptType: deptType, Status: status, CreateTime: now, UpdateTime: now,
  210. })
  211. require.NoError(t, err)
  212. deptId, _ := dRes.LastInsertId()
  213. uRes, err := svcCtx.SysUserModel.Insert(bootstrap, &userModel.SysUser{
  214. Username: tag + "_u_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  215. Avatar: sql.NullString{}, DeptId: deptId,
  216. IsSuperAdmin: 2, MustChangePassword: 2, Status: 1,
  217. CreateTime: now, UpdateTime: now,
  218. })
  219. require.NoError(t, err)
  220. userId, _ := uRes.LastInsertId()
  221. return deptId, userId
  222. }
  223. // TC-1174: UpdateDept DeptType: DEV → NORMAL 必须把该部门下**所有**在编 sys_user 的
  224. // tokenVersion 原子性 +1(签发层吊销),否则 5min TTL 内旧 access token 仍享有 DEV 全权。
  225. func TestUpdateDept_L_R16_2_DevTypeToNormal_RevokesAllMembers(t *testing.T) {
  226. ctx := ctxhelper.SuperAdminCtx()
  227. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  228. conn := testutil.GetTestSqlConn()
  229. deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev2norm", "/7100/",
  230. consts.DeptTypeDev, consts.StatusEnabled)
  231. t.Cleanup(func() {
  232. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  233. testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
  234. })
  235. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  236. require.NoError(t, err)
  237. prev := before.TokenVersion
  238. require.NoError(t,
  239. NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
  240. Id: deptId, Name: "renamed_" + testutil.UniqueId(), DeptType: consts.DeptTypeNormal,
  241. }))
  242. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  243. require.NoError(t, err)
  244. assert.Equal(t, prev+1, after.TokenVersion,
  245. "DEV→NORMAL 让部门内所有人的 loadPerms 从全权分支掉到普通分支,tokenVersion 必须同事务 +1")
  246. }
  247. // TC-1175: DEV 部门 Status Enabled → Disabled —— 同样构成 DEV 全权收窄,tokenVersion 必须 +1。
  248. func TestUpdateDept_L_R16_2_DevStatusToDisabled_RevokesAllMembers(t *testing.T) {
  249. ctx := ctxhelper.SuperAdminCtx()
  250. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  251. conn := testutil.GetTestSqlConn()
  252. deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev_dis", "/7200/",
  253. consts.DeptTypeDev, consts.StatusEnabled)
  254. t.Cleanup(func() {
  255. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  256. testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
  257. })
  258. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  259. require.NoError(t, err)
  260. prev := before.TokenVersion
  261. require.NoError(t,
  262. NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
  263. Id: deptId, Name: "dev_disabled_" + testutil.UniqueId(),
  264. DeptType: consts.DeptTypeDev, Status: consts.StatusDisabled,
  265. }))
  266. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  267. require.NoError(t, err)
  268. assert.Equal(t, prev+1, after.TokenVersion,
  269. "DEV 部门 Enabled→Disabled 与 DeptType DEV→NORMAL 同构,都让 loadPerms 全权分支失效")
  270. }
  271. // TC-1176: NORMAL 部门 Status Enabled → Disabled —— 业务语义是"冻结本部门所有活动",
  272. // 必须把部门内成员的 tokenVersion 同事务 +1,防止 5min TTL 窗口内旧 token 继续读写。
  273. func TestUpdateDept_L_R16_2_NormalStatusToDisabled_RevokesAllMembers(t *testing.T) {
  274. ctx := ctxhelper.SuperAdminCtx()
  275. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  276. conn := testutil.GetTestSqlConn()
  277. deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm_dis", "/7300/",
  278. consts.DeptTypeNormal, consts.StatusEnabled)
  279. t.Cleanup(func() {
  280. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  281. testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
  282. })
  283. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  284. require.NoError(t, err)
  285. prev := before.TokenVersion
  286. require.NoError(t,
  287. NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
  288. Id: deptId, Name: "norm_disabled_" + testutil.UniqueId(),
  289. DeptType: consts.DeptTypeNormal, Status: consts.StatusDisabled,
  290. }))
  291. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  292. require.NoError(t, err)
  293. assert.Equal(t, prev+1, after.TokenVersion,
  294. "NORMAL 部门 Enabled→Disabled 是冻结动作,部门成员的 tokenVersion 必须 +1")
  295. }
  296. // TC-1177: NORMAL→DEV 是放宽权限的升级方向,tokenVersion 保持不变;把合法会话无故下线
  297. // 不仅没收益,还会让产品侧管理员需要重新登录 + 重新建会话,损害可用性。
  298. func TestUpdateDept_L_R16_2_NormalToDev_NoRevoke(t *testing.T) {
  299. ctx := ctxhelper.SuperAdminCtx()
  300. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  301. conn := testutil.GetTestSqlConn()
  302. deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm2dev", "/7400/",
  303. consts.DeptTypeNormal, consts.StatusEnabled)
  304. t.Cleanup(func() {
  305. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  306. testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
  307. })
  308. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  309. require.NoError(t, err)
  310. prev := before.TokenVersion
  311. require.NoError(t,
  312. NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
  313. Id: deptId, Name: "norm2dev_" + testutil.UniqueId(), DeptType: consts.DeptTypeDev,
  314. }))
  315. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  316. require.NoError(t, err)
  317. assert.Equal(t, prev, after.TokenVersion,
  318. "NORMAL→DEV 是升权方向,不得把现存会话吊销;否则每次类型调整都会触发一次大规模强制下线")
  319. }
  320. // TC-1178: 部门 Status Disabled→Enabled(恢复启用)同样是升权方向,tokenVersion 必须保持不变。
  321. func TestUpdateDept_L_R16_2_StatusReEnable_NoRevoke(t *testing.T) {
  322. ctx := ctxhelper.SuperAdminCtx()
  323. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  324. conn := testutil.GetTestSqlConn()
  325. deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_reenable", "/7500/",
  326. consts.DeptTypeNormal, consts.StatusDisabled)
  327. t.Cleanup(func() {
  328. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  329. testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
  330. })
  331. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  332. require.NoError(t, err)
  333. prev := before.TokenVersion
  334. require.NoError(t,
  335. NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
  336. Id: deptId, Name: "reenable_" + testutil.UniqueId(),
  337. DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled,
  338. }))
  339. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  340. require.NoError(t, err)
  341. assert.Equal(t, prev, after.TokenVersion,
  342. "Disabled→Enabled 是恢复活动,不是收窄方向;tokenVersion 必须保持不变")
  343. }
  344. // deptType / status 都没变时,不应调 FindIdsByDeptId(避免无效缓存失效风暴);dept 自身行
  345. // 的 InvalidateDeptCache 仍需调用,因为 sys_dept 的 updateTime/name/remark/sort 已经被 UPDATE。
  346. func TestUpdateDept_NoEffectiveChange_SkipsFindIds(t *testing.T) {
  347. ctrl := gomock.NewController(t)
  348. t.Cleanup(ctrl.Finish)
  349. deptMock := mocks.NewMockSysDeptModel(ctrl)
  350. userMock := mocks.NewMockSysUserModel(ctrl)
  351. // 老部门 DEV,请求也是 DEV;status 未传 → 没有变更。
  352. deptMock.EXPECT().FindOne(gomock.Any(), int64(99)).
  353. Return(&deptModel.SysDept{
  354. Id: 99, Name: "x", DeptType: consts.DeptTypeDev,
  355. Status: consts.StatusEnabled, UpdateTime: 200,
  356. }, nil)
  357. runDeptTxInline(deptMock)
  358. deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(200)).Return(nil)
  359. deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(99))
  360. // 关键:没有任何 FindIdsByDeptId / FindIdsByDeptIdForShareTx EXPECT 即等价 Times(0)。
  361. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  362. Dept: deptMock, User: userMock,
  363. })
  364. err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
  365. Id: 99, Name: "x", DeptType: consts.DeptTypeDev, Sort: 1,
  366. })
  367. require.NoError(t, err, "无变更时 UpdateDept 只做元字段更新,不得触发缓存清理风暴")
  368. }