updateMemberLogic_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. package member
  2. import (
  3. "database/sql"
  4. "errors"
  5. "github.com/stretchr/testify/assert"
  6. "github.com/stretchr/testify/require"
  7. "perms-system-server/internal/consts"
  8. productModel "perms-system-server/internal/model/product"
  9. memberModel "perms-system-server/internal/model/productmember"
  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/types"
  16. "testing"
  17. "time"
  18. )
  19. func TestUpdateMember_Normal(t *testing.T) {
  20. ctx := ctxhelper.SuperAdminCtx()
  21. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  22. conn := testutil.GetTestSqlConn()
  23. now := time.Now().Unix()
  24. uid := testutil.UniqueId()
  25. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  26. Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
  27. Status: 1, CreateTime: now, UpdateTime: now,
  28. })
  29. require.NoError(t, err)
  30. pId, _ := pRes.LastInsertId()
  31. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  32. Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
  33. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  34. Status: 1, CreateTime: now, UpdateTime: now,
  35. })
  36. require.NoError(t, err)
  37. uId, _ := uRes.LastInsertId()
  38. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  39. ProductCode: uid, UserId: uId, MemberType: "MEMBER",
  40. Status: 1, CreateTime: now, UpdateTime: now,
  41. })
  42. require.NoError(t, err)
  43. mId, _ := mRes.LastInsertId()
  44. t.Cleanup(func() {
  45. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  46. testutil.CleanTable(ctx, conn, "`sys_user`", uId)
  47. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  48. })
  49. logic := NewUpdateMemberLogic(ctx, svcCtx)
  50. mt := "ADMIN"
  51. st := int64(2)
  52. err = logic.UpdateMember(&types.UpdateMemberReq{
  53. Id: mId,
  54. MemberType: &mt,
  55. Status: &st,
  56. })
  57. require.NoError(t, err)
  58. updated, err := svcCtx.SysProductMemberModel.FindOne(ctx, mId)
  59. require.NoError(t, err)
  60. assert.Equal(t, "ADMIN", updated.MemberType)
  61. assert.Equal(t, int64(2), updated.Status)
  62. }
  63. // TC-0221: 无效MemberType
  64. func TestUpdateMember_InvalidMemberType(t *testing.T) {
  65. ctx := ctxhelper.SuperAdminCtx()
  66. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  67. conn := testutil.GetTestSqlConn()
  68. now := time.Now().Unix()
  69. uid := testutil.UniqueId()
  70. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  71. Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
  72. Status: 1, CreateTime: now, UpdateTime: now,
  73. })
  74. require.NoError(t, err)
  75. pId, _ := pRes.LastInsertId()
  76. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  77. Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
  78. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  79. Status: 1, CreateTime: now, UpdateTime: now,
  80. })
  81. require.NoError(t, err)
  82. uId, _ := uRes.LastInsertId()
  83. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  84. ProductCode: uid, UserId: uId, MemberType: "MEMBER",
  85. Status: 1, CreateTime: now, UpdateTime: now,
  86. })
  87. require.NoError(t, err)
  88. mId, _ := mRes.LastInsertId()
  89. t.Cleanup(func() {
  90. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  91. testutil.CleanTable(ctx, conn, "`sys_user`", uId)
  92. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  93. })
  94. logic := NewUpdateMemberLogic(ctx, svcCtx)
  95. mt := "INVALID"
  96. err = logic.UpdateMember(&types.UpdateMemberReq{
  97. Id: mId,
  98. MemberType: &mt,
  99. })
  100. require.Error(t, err)
  101. ce, ok := err.(*response.CodeError)
  102. require.True(t, ok)
  103. assert.Equal(t, 400, ce.Code())
  104. assert.Equal(t, "无效的成员类型", ce.Error())
  105. }
  106. // TC-0220: 不存在
  107. func TestUpdateMember_NotFound(t *testing.T) {
  108. ctx := ctxhelper.SuperAdminCtx()
  109. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  110. logic := NewUpdateMemberLogic(ctx, svcCtx)
  111. mt := "ADMIN"
  112. err := logic.UpdateMember(&types.UpdateMemberReq{
  113. Id: 999999999,
  114. MemberType: &mt,
  115. })
  116. require.Error(t, err)
  117. ce, ok := err.(*response.CodeError)
  118. require.True(t, ok)
  119. assert.Equal(t, 404, ce.Code())
  120. assert.Equal(t, "成员不存在", ce.Error())
  121. }
  122. func int64Ptr(v int64) *int64 { return &v }
  123. // TC-1056: 两字段同时 nil,必须 400,且不得做任何 DB 读写
  124. func TestUpdateMember_BothFieldsNil_RejectedWith400(t *testing.T) {
  125. ctx := ctxhelper.SuperAdminCtx()
  126. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  127. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  128. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  129. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  130. Id: sp.mId,
  131. })
  132. require.Error(t, err)
  133. var ce *response.CodeError
  134. require.True(t, errors.As(err, &ce),
  135. "两字段全 nil 必须命中 response.ErrBadRequest,且以 *CodeError 传递")
  136. assert.Equal(t, 400, ce.Code(),
  137. "两字段全 nil 必须 400,不得退化成 200 no-op 或 500")
  138. assert.Contains(t, ce.Error(), "至少提供一个",
  139. "错误消息应明确提示至少要传 memberType 或 status 之一,便于接入方排查空请求体")
  140. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  141. require.NoError(t, err)
  142. assert.Equal(t, consts.MemberTypeMember, got.MemberType,
  143. "Logic 提前 400,任何 DB 落库都是回归;MemberType 必须保留原值")
  144. assert.Equal(t, int64(consts.StatusEnabled), got.Status,
  145. "Logic 提前 400 时 Status 也必须保留原值")
  146. }
  147. // TC-1057: 仅改 Status,MemberType 字段必须按原值保留;绝不回归成 ""
  148. func TestUpdateMember_OnlyStatus_PreservesMemberType(t *testing.T) {
  149. ctx := ctxhelper.SuperAdminCtx()
  150. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  151. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  152. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  153. require.NoError(t,
  154. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  155. Id: sp.mId,
  156. Status: int64Ptr(consts.StatusDisabled),
  157. }),
  158. "只改 Status 必须成功")
  159. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  160. require.NoError(t, err)
  161. assert.Equal(t, int64(consts.StatusDisabled), got.Status,
  162. "Status 应按入参更新到禁用")
  163. assert.Equal(t, consts.MemberTypeMember, got.MemberType,
  164. "防线:旧实现若以空串当缺省值会把 memberType 改写成 '',"+
  165. "权限侧会当这行为非法成员吊销其全部权限,必须杜绝")
  166. }
  167. // TC-1058: 仅改 MemberType,Status 原值保留
  168. func TestUpdateMember_OnlyMemberType_PreservesStatus(t *testing.T) {
  169. ctx := ctxhelper.SuperAdminCtx()
  170. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  171. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  172. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  173. require.NoError(t,
  174. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  175. Id: sp.mId,
  176. MemberType: strPtr(consts.MemberTypeAdmin),
  177. }),
  178. "只改 MemberType 必须成功")
  179. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  180. require.NoError(t, err)
  181. assert.Equal(t, consts.MemberTypeAdmin, got.MemberType)
  182. assert.Equal(t, int64(consts.StatusEnabled), got.Status,
  183. "Status 未提供时必须保留原值;若回归成 0 会让该成员瞬间冻结")
  184. }
  185. // TC-1059: DEVELOPER 成员仅改 Status 冻结,不得误触 CheckMemberTypeAssignment
  186. // 语义上 CheckMemberTypeAssignment 拦的是"用普通 admin 指派/变更 DEVELOPER"的动作;仅冻结
  187. // 一个已存在的 DEVELOPER 不属于"指派 DEVELOPER",必须放行。否则普通 admin 将永远无法管理
  188. // DEVELOPER 的启停,只能靠超管——属于管理面瘫痪。
  189. func TestUpdateMember_DeveloperStatusOnly_BypassesAssignmentCheck(t *testing.T) {
  190. ctx := ctxhelper.SuperAdminCtx()
  191. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  192. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
  193. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  194. require.NoError(t,
  195. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  196. Id: sp.mId,
  197. Status: int64Ptr(consts.StatusDisabled),
  198. }),
  199. "DEVELOPER 只冻结 (Status=2) 时必须跳过 CheckMemberTypeAssignment;"+
  200. "修复前的 `if memberType == DEVELOPER` 早期校验会把这条合法请求拒成 403")
  201. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  202. require.NoError(t, err)
  203. assert.Equal(t, consts.MemberTypeDeveloper, got.MemberType,
  204. "MemberType 在未传时必须保留 DEVELOPER")
  205. assert.Equal(t, int64(consts.StatusDisabled), got.Status)
  206. }
  207. // TC-1060: 无效 Status(非 1/2)必须 400,不得被"只传 Status"分支绕过校验
  208. func TestUpdateMember_InvalidStatusValue_Rejected(t *testing.T) {
  209. ctx := ctxhelper.SuperAdminCtx()
  210. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  211. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  212. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  213. for _, bad := range []int64{0, 3, -1, 999} {
  214. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  215. Id: sp.mId,
  216. Status: int64Ptr(bad),
  217. })
  218. require.Error(t, err)
  219. var ce *response.CodeError
  220. require.True(t, errors.As(err, &ce),
  221. "无效 Status 必须 *CodeError")
  222. assert.Equal(t, 400, ce.Code(),
  223. "Status=%d 必须 400 被拒,严禁靠 DB CHECK 或下游枚举触发 500", bad)
  224. }
  225. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  226. require.NoError(t, err)
  227. assert.Equal(t, int64(consts.StatusEnabled), got.Status, "非法 Status 全部被拒,原值必须不变")
  228. }
  229. // TC-1061: 值与现状完全一致(no-op)不报错,且**不触发** DB 事务/缓存失效
  230. // 这里只能通过"不返回 error"以及"DB 值稳定 + updateTime 不前进"的软信号来近似验证;
  231. // 契约上:nextType == member.MemberType && nextStatus == member.Status 时 Logic 早退。
  232. func TestUpdateMember_NoOpUpdate_ReturnsNil(t *testing.T) {
  233. ctx := ctxhelper.SuperAdminCtx()
  234. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  235. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  236. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  237. before, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  238. require.NoError(t, err)
  239. require.NoError(t,
  240. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  241. Id: sp.mId,
  242. MemberType: strPtr(before.MemberType),
  243. Status: int64Ptr(before.Status),
  244. }),
  245. "no-op update 必须 nil,不得被 ErrUpdateConflict 之类误报")
  246. after, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  247. require.NoError(t, err)
  248. assert.Equal(t, before.MemberType, after.MemberType)
  249. assert.Equal(t, before.Status, after.Status)
  250. assert.Equal(t, before.UpdateTime, after.UpdateTime,
  251. "Logic 在 no-op 时应直接 return nil,不进事务,"+
  252. "updateTime 不应被推进;推进即说明 Logic 仍然走了一次冗余 UPDATE")
  253. }
  254. // TC-0725: 修复:不能将最后一个 ADMIN 降级为 MEMBER
  255. func TestUpdateMember_DemoteLastAdminRejected(t *testing.T) {
  256. ctx := ctxhelper.SuperAdminCtx()
  257. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  258. sp := seedEnabledProductWithMember(t, svcCtx, "ADMIN")
  259. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  260. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  261. Id: sp.mId, MemberType: strPtr("MEMBER"),
  262. })
  263. require.Error(t, err)
  264. var ce *response.CodeError
  265. require.True(t, errors.As(err, &ce))
  266. assert.Equal(t, 400, ce.Code())
  267. assert.Contains(t, ce.Error(), "最后一个管理员")
  268. m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  269. require.NoError(t, err)
  270. assert.Equal(t, "ADMIN", m.MemberType, "MemberType 不应被改动")
  271. }
  272. // TC-0726: 修复:有多个 ADMIN 时可以降级其中一个
  273. func TestUpdateMember_DemoteAdmin_WhenMultiple_Allowed(t *testing.T) {
  274. ctx := ctxhelper.SuperAdminCtx()
  275. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  276. conn := testutil.GetTestSqlConn()
  277. now := time.Now().Unix()
  278. code := testutil.UniqueId()
  279. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  280. Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
  281. Status: 1, CreateTime: now, UpdateTime: now,
  282. })
  283. require.NoError(t, err)
  284. pId, _ := pRes.LastInsertId()
  285. var uIds, mIds []int64
  286. for i := 0; i < 2; i++ {
  287. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  288. Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  289. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  290. Status: 1, CreateTime: now, UpdateTime: now,
  291. })
  292. require.NoError(t, err)
  293. uId, _ := uRes.LastInsertId()
  294. uIds = append(uIds, uId)
  295. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  296. ProductCode: code, UserId: uId, MemberType: "ADMIN",
  297. Status: 1, CreateTime: now, UpdateTime: now,
  298. })
  299. require.NoError(t, err)
  300. mId, _ := mRes.LastInsertId()
  301. mIds = append(mIds, mId)
  302. }
  303. t.Cleanup(func() {
  304. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  305. testutil.CleanTable(ctx, conn, "`sys_user`", uIds...)
  306. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  307. })
  308. err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  309. Id: mIds[0], MemberType: strPtr("MEMBER"),
  310. })
  311. require.NoError(t, err)
  312. m, err := svcCtx.SysProductMemberModel.FindOne(ctx, mIds[0])
  313. require.NoError(t, err)
  314. assert.Equal(t, "MEMBER", m.MemberType)
  315. }
  316. // TC-0727: 修复:禁用状态的 ADMIN 不计入 active admin 计数,导致剩余 0 个启用 ADMIN 时仍拒绝降级
  317. // 说明:CountActiveAdmins 只统计 status=1 的 ADMIN;即便 DB 里有 2 个 ADMIN,但仅 1 个启用,
  318. // 降级这个唯一启用的 ADMIN 仍应被拒绝。
  319. func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) {
  320. ctx := ctxhelper.SuperAdminCtx()
  321. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  322. conn := testutil.GetTestSqlConn()
  323. now := time.Now().Unix()
  324. code := testutil.UniqueId()
  325. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  326. Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
  327. Status: 1, CreateTime: now, UpdateTime: now,
  328. })
  329. require.NoError(t, err)
  330. pId, _ := pRes.LastInsertId()
  331. var uIds, mIds []int64
  332. statuses := []int64{1, 2} // 一个启用,一个禁用
  333. for _, st := range statuses {
  334. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  335. Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  336. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  337. Status: 1, CreateTime: now, UpdateTime: now,
  338. })
  339. require.NoError(t, err)
  340. uId, _ := uRes.LastInsertId()
  341. uIds = append(uIds, uId)
  342. mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  343. ProductCode: code, UserId: uId, MemberType: "ADMIN",
  344. Status: st, CreateTime: now, UpdateTime: now,
  345. })
  346. require.NoError(t, err)
  347. mId, _ := mRes.LastInsertId()
  348. mIds = append(mIds, mId)
  349. }
  350. t.Cleanup(func() {
  351. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  352. testutil.CleanTable(ctx, conn, "`sys_user`", uIds...)
  353. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  354. })
  355. // 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝
  356. err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  357. Id: mIds[0], MemberType: strPtr("DEVELOPER"),
  358. })
  359. require.Error(t, err)
  360. var ce *response.CodeError
  361. require.True(t, errors.As(err, &ce))
  362. assert.Equal(t, 400, ce.Code())
  363. assert.Contains(t, ce.Error(), "最后一个管理员")
  364. }