updateMemberPartialPointer_audit_test.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. package member
  2. import (
  3. "errors"
  4. "testing"
  5. "perms-system-server/internal/consts"
  6. "perms-system-server/internal/response"
  7. "perms-system-server/internal/svc"
  8. "perms-system-server/internal/testutil"
  9. "perms-system-server/internal/testutil/ctxhelper"
  10. "perms-system-server/internal/types"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. )
  14. // ---------------------------------------------------------------------------
  15. // 覆盖目标:审计 L-R11-1 —— UpdateMemberReq 的 MemberType / Status 指针化后,
  16. // 1) 既表达"字段未传"≠"字段传零值"(区分"不改"和"改成 0"/"改成 ''"),
  17. // 2) 又要在两者都 nil 时由 Logic 层兜底 400,避免无害路径被当"空 update"落库;
  18. // 3) 只传 Status 不传 MemberType(反之亦然)时不得回归出"把对面字段写成空字符串"的 bug;
  19. // 4) 已经是 DEVELOPER 的人,只传 Status 冻结/启用时绝不得误触 CheckMemberTypeAssignment,
  20. // 否则普通 admin 永远无法对 DEVELOPER 做冻结/解冻。
  21. // ---------------------------------------------------------------------------
  22. func int64Ptr(v int64) *int64 { return &v }
  23. // TC-1056: L-R11-1 —— 两字段同时 nil,必须 400,且不得做任何 DB 读写
  24. func TestUpdateMember_BothFieldsNil_RejectedWith400(t *testing.T) {
  25. ctx := ctxhelper.SuperAdminCtx()
  26. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  27. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  28. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  29. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  30. Id: sp.mId,
  31. })
  32. require.Error(t, err)
  33. var ce *response.CodeError
  34. require.True(t, errors.As(err, &ce),
  35. "L-R11-1:两字段全 nil 必须命中 response.ErrBadRequest,且以 *CodeError 传递")
  36. assert.Equal(t, 400, ce.Code(),
  37. "L-R11-1:两字段全 nil 必须 400,不得退化成 200 no-op 或 500")
  38. assert.Contains(t, ce.Error(), "至少提供一个",
  39. "错误消息应明确提示至少要传 memberType 或 status 之一,便于接入方排查空请求体")
  40. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  41. require.NoError(t, err)
  42. assert.Equal(t, consts.MemberTypeMember, got.MemberType,
  43. "L-R11-1:Logic 提前 400,任何 DB 落库都是回归;MemberType 必须保留原值")
  44. assert.Equal(t, int64(consts.StatusEnabled), got.Status,
  45. "L-R11-1:Logic 提前 400 时 Status 也必须保留原值")
  46. }
  47. // TC-1057: L-R11-1 —— 仅改 Status,MemberType 字段必须按原值保留;绝不回归成 ""
  48. func TestUpdateMember_OnlyStatus_PreservesMemberType(t *testing.T) {
  49. ctx := ctxhelper.SuperAdminCtx()
  50. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  51. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  52. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  53. require.NoError(t,
  54. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  55. Id: sp.mId,
  56. Status: int64Ptr(consts.StatusDisabled),
  57. }),
  58. "只改 Status 必须成功")
  59. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  60. require.NoError(t, err)
  61. assert.Equal(t, int64(consts.StatusDisabled), got.Status,
  62. "L-R11-1:Status 应按入参更新到禁用")
  63. assert.Equal(t, consts.MemberTypeMember, got.MemberType,
  64. "L-R11-1 回归防线:旧实现若以空串当缺省值会把 memberType 改写成 '',"+
  65. "权限侧会当这行为非法成员吊销其全部权限,必须杜绝")
  66. }
  67. // TC-1058: L-R11-1 —— 仅改 MemberType,Status 原值保留
  68. func TestUpdateMember_OnlyMemberType_PreservesStatus(t *testing.T) {
  69. ctx := ctxhelper.SuperAdminCtx()
  70. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  71. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  72. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  73. require.NoError(t,
  74. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  75. Id: sp.mId,
  76. MemberType: strPtr(consts.MemberTypeAdmin),
  77. }),
  78. "只改 MemberType 必须成功")
  79. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  80. require.NoError(t, err)
  81. assert.Equal(t, consts.MemberTypeAdmin, got.MemberType)
  82. assert.Equal(t, int64(consts.StatusEnabled), got.Status,
  83. "L-R11-1:Status 未提供时必须保留原值;若回归成 0 会让该成员瞬间冻结")
  84. }
  85. // TC-1059: L-R11-1 —— DEVELOPER 成员仅改 Status 冻结,不得误触 CheckMemberTypeAssignment
  86. // 语义上 CheckMemberTypeAssignment 拦的是"用普通 admin 指派/变更 DEVELOPER"的动作;仅冻结
  87. // 一个已存在的 DEVELOPER 不属于"指派 DEVELOPER",必须放行。否则普通 admin 将永远无法管理
  88. // DEVELOPER 的启停,只能靠超管——属于管理面瘫痪。
  89. func TestUpdateMember_DeveloperStatusOnly_BypassesAssignmentCheck(t *testing.T) {
  90. ctx := ctxhelper.SuperAdminCtx()
  91. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  92. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
  93. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  94. require.NoError(t,
  95. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  96. Id: sp.mId,
  97. Status: int64Ptr(consts.StatusDisabled),
  98. }),
  99. "L-R11-1:DEVELOPER 只冻结 (Status=2) 时必须跳过 CheckMemberTypeAssignment;"+
  100. "修复前的 `if memberType == DEVELOPER` 早期校验会把这条合法请求拒成 403")
  101. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  102. require.NoError(t, err)
  103. assert.Equal(t, consts.MemberTypeDeveloper, got.MemberType,
  104. "MemberType 在未传时必须保留 DEVELOPER")
  105. assert.Equal(t, int64(consts.StatusDisabled), got.Status)
  106. }
  107. // TC-1060: L-R11-1 —— 无效 Status(非 1/2)必须 400,不得被"只传 Status"分支绕过校验
  108. func TestUpdateMember_InvalidStatusValue_Rejected(t *testing.T) {
  109. ctx := ctxhelper.SuperAdminCtx()
  110. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  111. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  112. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  113. for _, bad := range []int64{0, 3, -1, 999} {
  114. err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  115. Id: sp.mId,
  116. Status: int64Ptr(bad),
  117. })
  118. require.Error(t, err)
  119. var ce *response.CodeError
  120. require.True(t, errors.As(err, &ce),
  121. "L-R11-1:无效 Status 必须 *CodeError")
  122. assert.Equal(t, 400, ce.Code(),
  123. "L-R11-1:Status=%d 必须 400 被拒,严禁靠 DB CHECK 或下游枚举触发 500", bad)
  124. }
  125. got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  126. require.NoError(t, err)
  127. assert.Equal(t, int64(consts.StatusEnabled), got.Status, "非法 Status 全部被拒,原值必须不变")
  128. }
  129. // TC-1061: L-R11-1 —— 值与现状完全一致(no-op)不报错,且**不触发** DB 事务/缓存失效
  130. // 这里只能通过"不返回 error"以及"DB 值稳定 + updateTime 不前进"的软信号来近似验证;
  131. // 契约上:nextType == member.MemberType && nextStatus == member.Status 时 Logic 早退。
  132. func TestUpdateMember_NoOpUpdate_ReturnsNil(t *testing.T) {
  133. ctx := ctxhelper.SuperAdminCtx()
  134. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  135. sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
  136. t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
  137. before, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  138. require.NoError(t, err)
  139. require.NoError(t,
  140. NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
  141. Id: sp.mId,
  142. MemberType: strPtr(before.MemberType),
  143. Status: int64Ptr(before.Status),
  144. }),
  145. "L-R11-1:no-op update 必须 nil,不得被 ErrUpdateConflict 之类误报")
  146. after, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
  147. require.NoError(t, err)
  148. assert.Equal(t, before.MemberType, after.MemberType)
  149. assert.Equal(t, before.Status, after.Status)
  150. assert.Equal(t, before.UpdateTime, after.UpdateTime,
  151. "L-R11-1 强化:Logic 在 no-op 时应直接 return nil,不进事务,"+
  152. "updateTime 不应被推进;推进即说明 Logic 仍然走了一次冗余 UPDATE")
  153. }