updateMemberLogic_test.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737
  1. package member
  2. import (
  3. "database/sql"
  4. "errors"
  5. "fmt"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. "github.com/zeromicro/go-zero/core/stores/redis"
  9. "perms-system-server/internal/consts"
  10. productModel "perms-system-server/internal/model/product"
  11. memberModel "perms-system-server/internal/model/productmember"
  12. userModel "perms-system-server/internal/model/user"
  13. "perms-system-server/internal/response"
  14. "perms-system-server/internal/svc"
  15. "perms-system-server/internal/testutil"
  16. "perms-system-server/internal/testutil/ctxhelper"
  17. "perms-system-server/internal/types"
  18. "testing"
  19. "time"
  20. )
  21. func TestUpdateMember_Normal(t *testing.T) {
  22. ctx := ctxhelper.SuperAdminCtx()
  23. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  24. conn := testutil.GetTestSqlConn()
  25. now := time.Now().Unix()
  26. uid := testutil.UniqueId()
  27. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  28. Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
  29. Status: 1, CreateTime: now, UpdateTime: now,
  30. })
  31. require.NoError(t, err)
  32. pId, _ := pRes.LastInsertId()
  33. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  34. Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
  35. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  36. Status: 1, CreateTime: now, UpdateTime: now,
  37. })
  38. require.NoError(t, err)
  39. uId, _ := uRes.LastInsertId()
  40. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  41. ProductCode: uid, UserId: uId, MemberType: "MEMBER",
  42. Status: 1, CreateTime: now, UpdateTime: now,
  43. })
  44. require.NoError(t, err)
  45. mId, _ := mRes.LastInsertId()
  46. t.Cleanup(func() {
  47. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  48. testutil.CleanTable(ctx, conn, "`sys_user`", uId)
  49. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  50. })
  51. logic := NewUpdateMemberLogic(ctx, svcCtx)
  52. mt := "ADMIN"
  53. st := int64(2)
  54. err = logic.UpdateMember(&types.UpdateMemberReq{
  55. Id: mId,
  56. MemberType: &mt,
  57. Status: &st,
  58. })
  59. require.NoError(t, err)
  60. updated, err := svcCtx.SysProductMemberModel.FindOne(ctx, mId)
  61. require.NoError(t, err)
  62. assert.Equal(t, "ADMIN", updated.MemberType)
  63. assert.Equal(t, int64(2), updated.Status)
  64. }
  65. // TC-0221: 无效MemberType
  66. func TestUpdateMember_InvalidMemberType(t *testing.T) {
  67. ctx := ctxhelper.SuperAdminCtx()
  68. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  69. conn := testutil.GetTestSqlConn()
  70. now := time.Now().Unix()
  71. uid := testutil.UniqueId()
  72. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  73. Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
  74. Status: 1, CreateTime: now, UpdateTime: now,
  75. })
  76. require.NoError(t, err)
  77. pId, _ := pRes.LastInsertId()
  78. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  79. Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
  80. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  81. Status: 1, CreateTime: now, UpdateTime: now,
  82. })
  83. require.NoError(t, err)
  84. uId, _ := uRes.LastInsertId()
  85. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  86. ProductCode: uid, UserId: uId, MemberType: "MEMBER",
  87. Status: 1, CreateTime: now, UpdateTime: now,
  88. })
  89. require.NoError(t, err)
  90. mId, _ := mRes.LastInsertId()
  91. t.Cleanup(func() {
  92. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  93. testutil.CleanTable(ctx, conn, "`sys_user`", uId)
  94. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  95. })
  96. logic := NewUpdateMemberLogic(ctx, svcCtx)
  97. mt := "INVALID"
  98. err = logic.UpdateMember(&types.UpdateMemberReq{
  99. Id: mId,
  100. MemberType: &mt,
  101. })
  102. require.Error(t, err)
  103. ce, ok := err.(*response.CodeError)
  104. require.True(t, ok)
  105. assert.Equal(t, 400, ce.Code())
  106. assert.Equal(t, "无效的成员类型", ce.Error())
  107. }
  108. // TC-0220: 不存在
  109. func TestUpdateMember_NotFound(t *testing.T) {
  110. ctx := ctxhelper.SuperAdminCtx()
  111. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  112. logic := NewUpdateMemberLogic(ctx, svcCtx)
  113. mt := "ADMIN"
  114. err := logic.UpdateMember(&types.UpdateMemberReq{
  115. Id: 999999999,
  116. MemberType: &mt,
  117. })
  118. require.Error(t, err)
  119. ce, ok := err.(*response.CodeError)
  120. require.True(t, ok)
  121. assert.Equal(t, 404, ce.Code())
  122. assert.Equal(t, "成员不存在", ce.Error())
  123. }
  124. func int64Ptr(v int64) *int64 { return &v }
  125. // TC-1056: 两字段同时 nil,必须 400,且不得做任何 DB 读写
  126. func TestUpdateMember_BothFieldsNil_RejectedWith400(t *testing.T) {
  127. ctx := ctxhelper.SuperAdminCtx()
  128. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  129. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  130. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  131. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  132. Id: sp.mId,
  133. })
  134. require.Error(t, err)
  135. var ce *response.CodeError
  136. require.True(t, errors.As(err, &ce),
  137. "两字段全 nil 必须命中 response.ErrBadRequest,且以 *CodeError 传递")
  138. assert.Equal(t, 400, ce.Code(),
  139. "两字段全 nil 必须 400,不得退化成 200 no-op 或 500")
  140. assert.Contains(t, ce.Error(), "至少提供一个",
  141. "错误消息应明确提示至少要传 memberType 或 status 之一,便于接入方排查空请求体")
  142. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  143. require.NoError(t, err)
  144. assert.Equal(t, consts.MemberTypeMember, got.MemberType,
  145. "Logic 提前 400,任何 DB 落库都是回归;MemberType 必须保留原值")
  146. assert.Equal(t, int64(consts.StatusEnabled), got.Status,
  147. "Logic 提前 400 时 Status 也必须保留原值")
  148. }
  149. // TC-1057: 仅改 Status,MemberType 字段必须按原值保留;绝不回归成 ""
  150. func TestUpdateMember_OnlyStatus_PreservesMemberType(t *testing.T) {
  151. ctx := ctxhelper.SuperAdminCtx()
  152. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  153. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  154. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  155. require.NoError(t,
  156. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  157. Id: sp.mId,
  158. Status: int64Ptr(consts.StatusDisabled),
  159. }),
  160. "只改 Status 必须成功")
  161. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  162. require.NoError(t, err)
  163. assert.Equal(t, int64(consts.StatusDisabled), got.Status,
  164. "Status 应按入参更新到禁用")
  165. assert.Equal(t, consts.MemberTypeMember, got.MemberType,
  166. "防线:旧实现若以空串当缺省值会把 memberType 改写成 '',"+
  167. "权限侧会当这行为非法成员吊销其全部权限,必须杜绝")
  168. }
  169. // TC-1058: 仅改 MemberType,Status 原值保留
  170. func TestUpdateMember_OnlyMemberType_PreservesStatus(t *testing.T) {
  171. ctx := ctxhelper.SuperAdminCtx()
  172. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  173. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  174. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  175. require.NoError(t,
  176. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  177. Id: sp.mId,
  178. MemberType: strPtr(consts.MemberTypeAdmin),
  179. }),
  180. "只改 MemberType 必须成功")
  181. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  182. require.NoError(t, err)
  183. assert.Equal(t, consts.MemberTypeAdmin, got.MemberType)
  184. assert.Equal(t, int64(consts.StatusEnabled), got.Status,
  185. "Status 未提供时必须保留原值;若回归成 0 会让该成员瞬间冻结")
  186. }
  187. // TC-1059: DEVELOPER 成员仅改 Status 冻结,不得误触 CheckMemberTypeAssignment
  188. // 语义上 CheckMemberTypeAssignment 拦的是"用普通 admin 指派/变更 DEVELOPER"的动作;仅冻结
  189. // 一个已存在的 DEVELOPER 不属于"指派 DEVELOPER",必须放行。否则普通 admin 将永远无法管理
  190. // DEVELOPER 的启停,只能靠超管——属于管理面瘫痪。
  191. func TestUpdateMember_DeveloperStatusOnly_BypassesAssignmentCheck(t *testing.T) {
  192. ctx := ctxhelper.SuperAdminCtx()
  193. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  194. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
  195. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  196. require.NoError(t,
  197. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  198. Id: sp.mId,
  199. Status: int64Ptr(consts.StatusDisabled),
  200. }),
  201. "DEVELOPER 只冻结 (Status=2) 时必须跳过 CheckMemberTypeAssignment;"+
  202. "修复前的 `if memberType == DEVELOPER` 早期校验会把这条合法请求拒成 403")
  203. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  204. require.NoError(t, err)
  205. assert.Equal(t, consts.MemberTypeDeveloper, got.MemberType,
  206. "MemberType 在未传时必须保留 DEVELOPER")
  207. assert.Equal(t, int64(consts.StatusDisabled), got.Status)
  208. }
  209. // TC-1060: 无效 Status(非 1/2)必须 400,不得被"只传 Status"分支绕过校验
  210. func TestUpdateMember_InvalidStatusValue_Rejected(t *testing.T) {
  211. ctx := ctxhelper.SuperAdminCtx()
  212. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  213. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  214. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  215. for _, bad := range []int64{0, 3, -1, 999} {
  216. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  217. Id: sp.mId,
  218. Status: int64Ptr(bad),
  219. })
  220. require.Error(t, err)
  221. var ce *response.CodeError
  222. require.True(t, errors.As(err, &ce),
  223. "无效 Status 必须 *CodeError")
  224. assert.Equal(t, 400, ce.Code(),
  225. "Status=%d 必须 400 被拒,严禁靠 DB CHECK 或下游枚举触发 500", bad)
  226. }
  227. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  228. require.NoError(t, err)
  229. assert.Equal(t, int64(consts.StatusEnabled), got.Status, "非法 Status 全部被拒,原值必须不变")
  230. }
  231. // TC-1061: 值与现状完全一致(no-op)不报错,且**不触发** DB 事务/缓存失效
  232. // 这里只能通过"不返回 error"以及"DB 值稳定 + updateTime 不前进"的软信号来近似验证;
  233. // 契约上:nextType == member.MemberType && nextStatus == member.Status 时 Logic 早退。
  234. func TestUpdateMember_NoOpUpdate_ReturnsNil(t *testing.T) {
  235. ctx := ctxhelper.SuperAdminCtx()
  236. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  237. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  238. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  239. before, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  240. require.NoError(t, err)
  241. require.NoError(t,
  242. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  243. Id: sp.mId,
  244. MemberType: strPtr(before.MemberType),
  245. Status: int64Ptr(before.Status),
  246. }),
  247. "no-op update 必须 nil,不得被 ErrUpdateConflict 之类误报")
  248. after, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  249. require.NoError(t, err)
  250. assert.Equal(t, before.MemberType, after.MemberType)
  251. assert.Equal(t, before.Status, after.Status)
  252. assert.Equal(t, before.UpdateTime, after.UpdateTime,
  253. "Logic 在 no-op 时应直接 return nil,不进事务,"+
  254. "updateTime 不应被推进;推进即说明 Logic 仍然走了一次冗余 UPDATE")
  255. }
  256. // TC-0725: 修复:不能将最后一个 ADMIN 降级为 MEMBER
  257. func TestUpdateMember_DemoteLastAdminRejected(t *testing.T) {
  258. ctx := ctxhelper.SuperAdminCtx()
  259. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  260. sp := seedEnabledProductWithMember(t, svcCtx, "ADMIN")
  261. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  262. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  263. Id: sp.mId, MemberType: strPtr("MEMBER"),
  264. })
  265. require.Error(t, err)
  266. var ce *response.CodeError
  267. require.True(t, errors.As(err, &ce))
  268. assert.Equal(t, 400, ce.Code())
  269. assert.Contains(t, ce.Error(), "最后一个管理员")
  270. m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  271. require.NoError(t, err)
  272. assert.Equal(t, "ADMIN", m.MemberType, "MemberType 不应被改动")
  273. }
  274. // TC-0726: 修复:有多个 ADMIN 时可以降级其中一个
  275. func TestUpdateMember_DemoteAdmin_WhenMultiple_Allowed(t *testing.T) {
  276. ctx := ctxhelper.SuperAdminCtx()
  277. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  278. conn := testutil.GetTestSqlConn()
  279. now := time.Now().Unix()
  280. code := testutil.UniqueId()
  281. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  282. Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
  283. Status: 1, CreateTime: now, UpdateTime: now,
  284. })
  285. require.NoError(t, err)
  286. pId, _ := pRes.LastInsertId()
  287. var uIds, mIds []int64
  288. for i := 0; i < 2; i++ {
  289. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  290. Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  291. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  292. Status: 1, CreateTime: now, UpdateTime: now,
  293. })
  294. require.NoError(t, err)
  295. uId, _ := uRes.LastInsertId()
  296. uIds = append(uIds, uId)
  297. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  298. ProductCode: code, UserId: uId, MemberType: "ADMIN",
  299. Status: 1, CreateTime: now, UpdateTime: now,
  300. })
  301. require.NoError(t, err)
  302. mId, _ := mRes.LastInsertId()
  303. mIds = append(mIds, mId)
  304. }
  305. t.Cleanup(func() {
  306. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  307. testutil.CleanTable(ctx, conn, "`sys_user`", uIds...)
  308. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  309. })
  310. err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  311. Id: mIds[0], MemberType: strPtr("MEMBER"),
  312. })
  313. require.NoError(t, err)
  314. m, err := svcCtx.SysProductMemberModel.FindOne(ctx, mIds[0])
  315. require.NoError(t, err)
  316. assert.Equal(t, "MEMBER", m.MemberType)
  317. }
  318. // TC-0727: 修复:禁用状态的 ADMIN 不计入 active admin 计数,导致剩余 0 个启用 ADMIN 时仍拒绝降级
  319. // 说明:CountActiveAdmins 只统计 status=1 的 ADMIN;即便 DB 里有 2 个 ADMIN,但仅 1 个启用,
  320. // 降级这个唯一启用的 ADMIN 仍应被拒绝。
  321. func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) {
  322. ctx := ctxhelper.SuperAdminCtx()
  323. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  324. conn := testutil.GetTestSqlConn()
  325. now := time.Now().Unix()
  326. code := testutil.UniqueId()
  327. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  328. Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
  329. Status: 1, CreateTime: now, UpdateTime: now,
  330. })
  331. require.NoError(t, err)
  332. pId, _ := pRes.LastInsertId()
  333. var uIds, mIds []int64
  334. statuses := []int64{1, 2} // 一个启用,一个禁用
  335. for _, st := range statuses {
  336. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  337. Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  338. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  339. Status: 1, CreateTime: now, UpdateTime: now,
  340. })
  341. require.NoError(t, err)
  342. uId, _ := uRes.LastInsertId()
  343. uIds = append(uIds, uId)
  344. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  345. ProductCode: code, UserId: uId, MemberType: "ADMIN",
  346. Status: st, CreateTime: now, UpdateTime: now,
  347. })
  348. require.NoError(t, err)
  349. mId, _ := mRes.LastInsertId()
  350. mIds = append(mIds, mId)
  351. }
  352. t.Cleanup(func() {
  353. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  354. testutil.CleanTable(ctx, conn, "`sys_user`", uIds...)
  355. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  356. })
  357. // 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝
  358. err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  359. Id: mIds[0], MemberType: strPtr("DEVELOPER"),
  360. })
  361. require.Error(t, err)
  362. var ce *response.CodeError
  363. require.True(t, errors.As(err, &ce))
  364. assert.Equal(t, 400, ce.Code())
  365. assert.Contains(t, ce.Error(), "最后一个管理员")
  366. }
  367. // ---------------------------------------------------------------------------
  368. // M-R15-1 / L-R15-3:UpdateMember 在"降权"路径上必须同事务递增 sys_user.tokenVersion,
  369. // 让旧 access/refresh token 在下一次 middleware 校验时被 401 踢出,不再依赖
  370. // UserDetailsLoader.Del 的 best-effort 缓存失效。
  371. //
  372. // "降权"语义并集:
  373. // - MemberType 从 {ADMIN, DEVELOPER} 掉到 MEMBER;
  374. // - Status 从 Enabled 变 Disabled。
  375. //
  376. // 对称"升权"路径(MEMBER→ADMIN / Disabled→Enabled)明确**不**递增 tokenVersion,
  377. // 避免把无需重新登录的目标用户误踢下线。
  378. // ---------------------------------------------------------------------------
  379. // seedEnabledProductWithMemberAndTv 在标准 seed 之外允许指定初始 Status。
  380. // 用于构造 "Status=Disabled 的成员被重启用" 这种 seedEnabledProductWithMember 无法直接表达的初态。
  381. func seedEnabledProductWithMemberAndTv(t *testing.T, svcCtx *svc.ServiceContext, memberType string, initialStatus int64) seededProduct {
  382. t.Helper()
  383. ctx := ctxhelper.SuperAdminCtx()
  384. conn := testutil.GetTestSqlConn()
  385. now := time.Now().Unix()
  386. code := testutil.UniqueId()
  387. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  388. Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
  389. Status: 1, CreateTime: now, UpdateTime: now,
  390. })
  391. require.NoError(t, err)
  392. pId, _ := pRes.LastInsertId()
  393. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  394. Username: code, Password: testutil.HashPassword("pw"), Nickname: "n",
  395. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  396. Status: 1, CreateTime: now, UpdateTime: now,
  397. })
  398. require.NoError(t, err)
  399. uId, _ := uRes.LastInsertId()
  400. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  401. ProductCode: code, UserId: uId, MemberType: memberType,
  402. Status: initialStatus, CreateTime: now, UpdateTime: now,
  403. })
  404. require.NoError(t, err)
  405. mId, _ := mRes.LastInsertId()
  406. t.Cleanup(func() {
  407. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  408. testutil.CleanTable(ctx, conn, "`sys_user`", uId)
  409. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  410. })
  411. return seededProduct{code: code, pId: pId, uId: uId, mId: mId, admin: mId}
  412. }
  413. // getUserTokenVersion 直接从 DB 读最新 tokenVersion,绕过低层缓存——
  414. // 测试需要观察 DB 真值,不能被"cache 回灌"污染。
  415. func getUserTokenVersion(t *testing.T, svcCtx *svc.ServiceContext, userId int64) int64 {
  416. t.Helper()
  417. ctx := ctxhelper.SuperAdminCtx()
  418. conn := testutil.GetTestSqlConn()
  419. var tv int64
  420. require.NoError(t,
  421. conn.QueryRowCtx(ctx, &tv,
  422. "SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId),
  423. "直读 DB 的 tokenVersion")
  424. return tv
  425. }
  426. // seedSecondActiveAdmin 给同一个产品再加一条 ADMIN(Status=1),用于绕过 last-admin 拦截,
  427. // 这样才能真正进入"降级/禁用唯一 ADMIN 以外的成员"这条降权分支。
  428. func seedSecondActiveAdmin(t *testing.T, svcCtx *svc.ServiceContext, productCode string) (int64, int64) {
  429. t.Helper()
  430. ctx := ctxhelper.SuperAdminCtx()
  431. conn := testutil.GetTestSqlConn()
  432. now := time.Now().Unix()
  433. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  434. Username: "keeper_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  435. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  436. Status: 1, CreateTime: now, UpdateTime: now,
  437. })
  438. require.NoError(t, err)
  439. kUid, _ := uRes.LastInsertId()
  440. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  441. ProductCode: productCode, UserId: kUid, MemberType: consts.MemberTypeAdmin,
  442. Status: 1, CreateTime: now, UpdateTime: now,
  443. })
  444. require.NoError(t, err)
  445. kMid, _ := mRes.LastInsertId()
  446. t.Cleanup(func() {
  447. testutil.CleanTable(ctx, conn, "`sys_product_member`", kMid)
  448. testutil.CleanTable(ctx, conn, "`sys_user`", kUid)
  449. })
  450. return kUid, kMid
  451. }
  452. // TC-1130:降级 ADMIN→MEMBER 时 sys_user.tokenVersion 严格 +1(M-R15-1 方案 A)。
  453. // 断言 1:tv_after == tv_before + 1;
  454. // 断言 2:同事务落盘——若 UPDATE 成员成功但 tokenVersion 未增(或相反),就代表
  455. // IncrementTokenVersionWithTx 脱离了业务事务,必须立刻暴露。
  456. func TestUpdateMember_DemoteAdminToMember_BumpsTokenVersion(t *testing.T) {
  457. ctx := ctxhelper.SuperAdminCtx()
  458. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  459. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
  460. // 绕开 last-admin 拦截(不是本用例关心的)
  461. seedSecondActiveAdmin(t, svcCtx, sp.code)
  462. tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
  463. require.NoError(t,
  464. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  465. Id: sp.mId,
  466. MemberType: strPtr(consts.MemberTypeMember),
  467. }),
  468. "降级 ADMIN→MEMBER 是合法路径,必须成功")
  469. tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
  470. assert.Equal(t, tvBefore+1, tvAfter,
  471. "M-R15-1:降级必须同事务递增 tokenVersion,让旧 access token 在下一次 middleware 校验时被 401 踢出")
  472. m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  473. require.NoError(t, err)
  474. assert.Equal(t, consts.MemberTypeMember, m.MemberType,
  475. "MemberType 与 tokenVersion 必须同事务一起落盘,不得出现'增了 tv 但 member 没改'的脏态")
  476. }
  477. // TC-1131:禁用启用成员时(Status 1→2)sys_user.tokenVersion +1。
  478. // 此用例显式构造 MEMBER(而非 ADMIN),证明 "status 降权" 与 "type 降权" 是并集而非仅
  479. // ADMIN 才递增——冻结的普通 MEMBER 同样必须立即吊销 session。
  480. func TestUpdateMember_DisableEnabledMember_BumpsTokenVersion(t *testing.T) {
  481. ctx := ctxhelper.SuperAdminCtx()
  482. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  483. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  484. tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
  485. require.NoError(t,
  486. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  487. Id: sp.mId,
  488. Status: int64Ptr(consts.StatusDisabled),
  489. }),
  490. "禁用启用成员是合法路径,必须成功")
  491. tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
  492. assert.Equal(t, tvBefore+1, tvAfter,
  493. "M-R15-1:statusRevoked(Enabled→Disabled)必须触发 tokenVersion 递增;"+
  494. "否则被冻结成员仍可持旧 token 直到 UD 缓存 TTL(5min)过期")
  495. m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  496. require.NoError(t, err)
  497. assert.Equal(t, int64(consts.StatusDisabled), m.Status)
  498. }
  499. // TC-1132:降级 DEVELOPER→MEMBER 时同样 +1(DEVELOPER 也算 privileged type)。
  500. func TestUpdateMember_DemoteDeveloperToMember_BumpsTokenVersion(t *testing.T) {
  501. ctx := ctxhelper.SuperAdminCtx()
  502. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  503. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
  504. tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
  505. require.NoError(t,
  506. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  507. Id: sp.mId,
  508. MemberType: strPtr(consts.MemberTypeMember),
  509. }),
  510. "DEVELOPER→MEMBER 是合法路径")
  511. tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
  512. assert.Equal(t, tvBefore+1, tvAfter,
  513. "wasPrivilegedType 判定必须覆盖 DEVELOPER,与 ADMIN 语义对称")
  514. }
  515. // TC-1133:升权 MEMBER→ADMIN 时 **不** 递增 tokenVersion。
  516. // 这是"会话吊销策略只在权限收窄时触发"这条契约的正向回归——
  517. // 若未来有人把 `if typeDowngraded || statusRevoked` 简化成无条件 Increment,
  518. // 本用例会立刻失败。
  519. func TestUpdateMember_PromoteMemberToAdmin_DoesNotBumpTokenVersion(t *testing.T) {
  520. ctx := ctxhelper.SuperAdminCtx()
  521. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  522. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  523. tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
  524. require.NoError(t,
  525. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  526. Id: sp.mId,
  527. MemberType: strPtr(consts.MemberTypeAdmin),
  528. }),
  529. "升权是合法路径")
  530. tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
  531. assert.Equal(t, tvBefore, tvAfter,
  532. "升权不构成对被管理方的实际损害:旧 token 的 UD 缓存里 memberType 仍是旧值,"+
  533. "必须等 Loader.Del 生效或 TTL 到期才能'用上' ADMIN;"+
  534. "这里贸然 +1 会给管理员误点一次'踢下线'的副作用")
  535. }
  536. // TC-1134:重启用(Disabled→Enabled)时 tokenVersion 不变。
  537. func TestUpdateMember_ReEnableDisabledMember_DoesNotBumpTokenVersion(t *testing.T) {
  538. ctx := ctxhelper.SuperAdminCtx()
  539. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  540. sp := seedEnabledProductWithMemberAndTv(t, svcCtx, consts.MemberTypeMember, consts.StatusDisabled)
  541. tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
  542. require.NoError(t,
  543. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  544. Id: sp.mId,
  545. Status: int64Ptr(consts.StatusEnabled),
  546. }),
  547. "解冻是合法路径")
  548. tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
  549. assert.Equal(t, tvBefore, tvAfter,
  550. "解冻不需要吊销旧 session(用户早已因 Status=Disabled 无法通过中间件);"+
  551. "这里递增反而会给合法重登录增加一次无谓的失败")
  552. }
  553. // TC-1135:降级事务失败(last-admin 400)时 tokenVersion 不得被污染。
  554. //
  555. // 关键契约:IncrementTokenVersionWithTx 必须在 UpdateWithTx 之前放,但两者都在同一个
  556. // TransactCtx 闭包里——last-admin 校验 return err → 整个事务 rollback → tokenVersion 也 rollback。
  557. // 若实现退化成"Increment 走独立事务 + UPDATE 走另一事务",这里就会飘红。
  558. func TestUpdateMember_DemoteLastAdminRejected_TokenVersionUnchanged(t *testing.T) {
  559. ctx := ctxhelper.SuperAdminCtx()
  560. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  561. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
  562. tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
  563. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  564. Id: sp.mId, MemberType: strPtr(consts.MemberTypeMember),
  565. })
  566. require.Error(t, err, "唯一启用 ADMIN 必须被拒,否则产品将没有管理员")
  567. var ce *response.CodeError
  568. require.True(t, errors.As(err, &ce))
  569. assert.Equal(t, 400, ce.Code())
  570. tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
  571. assert.Equal(t, tvBefore, tvAfter,
  572. "事务 rollback 后 tokenVersion 必须保持初值——"+
  573. "否则业务失败会把合法用户莫名踢下线,是 M-R15-1 方案 A 的反面教材")
  574. m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  575. require.NoError(t, err)
  576. assert.Equal(t, consts.MemberTypeAdmin, m.MemberType,
  577. "member 也不应被改动(整个事务 rollback)")
  578. }
  579. // TC-1136:no-op 更新(入参与现值一致)直接 return nil,不进入事务、tokenVersion 不变。
  580. // seedEnabledProductWithMember 已是 {Member, Status=1},再传同样的值就是 no-op。
  581. func TestUpdateMember_NoOpUpdate_DoesNotBumpTokenVersion(t *testing.T) {
  582. ctx := ctxhelper.SuperAdminCtx()
  583. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  584. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  585. tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
  586. require.NoError(t,
  587. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  588. Id: sp.mId,
  589. MemberType: strPtr(consts.MemberTypeMember),
  590. Status: int64Ptr(consts.StatusEnabled),
  591. }),
  592. "no-op 必须 nil,不能被 ErrUpdateConflict 之类误报")
  593. tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
  594. assert.Equal(t, tvBefore, tvAfter,
  595. "no-op 早退分支必须完全绕过事务,tokenVersion 任何微小变动都说明 Logic 仍然进事务了")
  596. }
  597. func sysUserCacheKeysForMember(id int64, username string) (string, string) {
  598. prefix := testutil.GetTestCachePrefix()
  599. return fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, id),
  600. fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, username)
  601. }
  602. // TC-1137:降级成功后 post-commit 必须失效 sysUser 低层的 id/username 两把缓存,
  603. // 否则 UD loader 下次 cache-miss 重建时从这两把 key 里拿到旧 tokenVersion,
  604. // 会把刚在 DB 里递增的值再次抹回(等价于 M-R15-1 回归)。
  605. func TestUpdateMember_DemoteInvalidatesSysUserCache(t *testing.T) {
  606. ctx := ctxhelper.SuperAdminCtx()
  607. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  608. rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
  609. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
  610. seedSecondActiveAdmin(t, svcCtx, sp.code)
  611. // 预热两把 sysUser 低层缓存
  612. u0, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
  613. require.NoError(t, err)
  614. _, err = svcCtx.SysUserModel.FindOneByUsername(ctx, u0.Username)
  615. require.NoError(t, err)
  616. idKey, usernameKey := sysUserCacheKeysForMember(sp.uId, u0.Username)
  617. beforeId, _ := rds.Get(idKey)
  618. beforeUn, _ := rds.Get(usernameKey)
  619. require.NotEmpty(t, beforeId, "前置条件:id-key 缓存已预热")
  620. require.NotEmpty(t, beforeUn, "前置条件:username-key 缓存已预热")
  621. require.NoError(t,
  622. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  623. Id: sp.mId,
  624. MemberType: strPtr(consts.MemberTypeMember),
  625. }))
  626. // 契约:两把 key 必须被 InvalidateProfileCache 清理掉(post-commit 显式失效入口)
  627. gotIdAfter, _ := rds.Get(idKey)
  628. assert.Empty(t, gotIdAfter,
  629. "L-R15-3:post-commit 必须失效 sysUser:id:%d,否则 UD loader cache-miss 重建会拿到旧 tokenVersion", sp.uId)
  630. gotUnAfter, _ := rds.Get(usernameKey)
  631. assert.Empty(t, gotUnAfter,
  632. "L-R15-3:sysUser:username:%s 同样必须被失效", u0.Username)
  633. // 验证下一次 FindOne 真的读到 DB 中递增后的 tokenVersion(而非 stale cache)
  634. uNow, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
  635. require.NoError(t, err)
  636. assert.Greater(t, uNow.TokenVersion, u0.TokenVersion,
  637. "缓存失效后 FindOne 必须回源 DB 并读到已递增的 tokenVersion")
  638. }