syncPermsTxLock_audit_test.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. package pub
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. permModel "perms-system-server/internal/model/perm"
  7. productModel "perms-system-server/internal/model/product"
  8. "perms-system-server/internal/testutil/mocks"
  9. "github.com/stretchr/testify/assert"
  10. "github.com/stretchr/testify/require"
  11. "github.com/zeromicro/go-zero/core/stores/sqlx"
  12. "go.uber.org/mock/gomock"
  13. "golang.org/x/crypto/bcrypt"
  14. )
  15. // ---------------------------------------------------------------------------
  16. // 覆盖目标:审计第 6 轮 H-3 修复回归 —— ExecuteSyncPerms 必须在 tx 内
  17. // 1. 先调用 LockByCodeTx 锁住 sys_product 行;
  18. // 2. 再调用 FindMapByProductCodeWithTx(事务内读 perm map)。
  19. //
  20. // 为什么重要:
  21. // - 修复前 perm map 的 "existing vs. new" 判断发生在 tx 外,两笔并发 sync 都可能
  22. // 认为 "code X 不存在",之后都在 tx 内 INSERT,撞 UNIQUE(productCode, code) 导致 1062。
  23. // - 修复后所有并发请求都要先排队拿到 product 行锁,才能读到一致的 existing 集合并写入,
  24. // 将 "并发同步同一个产品" 串行化。
  25. //
  26. // 这个文件只关心"拿锁"这一段的契约(执行顺序 / 错误路径),
  27. // 避免重叠 syncPermsConflict_audit_test.go 中的 1062 → 409 映射。
  28. // ---------------------------------------------------------------------------
  29. // newBaseProductMock 只认 appKey + 校验 secret + 产品启用,返回固定 Code="pc_tx"。
  30. func newBaseProductMock(ctrl *gomock.Controller, code string) *mocks.MockSysProductModel {
  31. hashed, _ := bcrypt.GenerateFromPassword([]byte("s"), bcrypt.MinCost)
  32. m := mocks.NewMockSysProductModel(ctrl)
  33. m.EXPECT().FindOneByAppKey(gomock.Any(), "ak").
  34. Return(&productModel.SysProduct{
  35. Id: 1, Code: code, AppKey: "ak", AppSecret: string(hashed), Status: 1,
  36. }, nil)
  37. return m
  38. }
  39. // TC-0843: H-3 契约 —— 正常路径下 LockByCodeTx 必须先于 FindMapByProductCodeWithTx,
  40. // 且两者均在同一个 tx session 内被调用。
  41. func TestExecuteSyncPerms_LockBeforeMapReadInTx(t *testing.T) {
  42. ctrl := gomock.NewController(t)
  43. t.Cleanup(ctrl.Finish)
  44. productMock := newBaseProductMock(ctrl, "pc_tx_order")
  45. permMock := mocks.NewMockSysPermModel(ctrl)
  46. // 关键点 1:TransactCtx 必须真的传入一个 tx session,并把所有子调用都发生在其中。
  47. permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
  48. DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
  49. return fn(ctx, nil) // nil session 只是 mock 占位
  50. })
  51. // 关键点 2:gomock 的 Call.After 强制 LockByCodeTx 先于 FindMapByProductCodeWithTx 执行。
  52. // 顺序反过来的话 gomock 会在 Finish 时报错。
  53. // 审计 M-R10-1:事务内复核 Status 必须 Status=1,否则走 403 分支不写 perm
  54. lockCall := productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_order").
  55. Return(&productModel.SysProduct{Id: 1, Code: "pc_tx_order", Status: 1}, nil)
  56. permMock.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "pc_tx_order").
  57. Return(map[string]*permModel.SysPerm{}, nil).
  58. After(lockCall)
  59. // 一条简单的 INSERT + DisableNotIn 让流程走完;非本 TC 的主断言。
  60. permMock.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(nil)
  61. permMock.EXPECT().DisableNotInCodesWithTx(gomock.Any(), nil, "pc_tx_order", []string{"x"}, gomock.Any()).
  62. Return(int64(0), nil)
  63. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
  64. result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s",
  65. []SyncPermItem{{Code: "x", Name: "X"}})
  66. require.NoError(t, err)
  67. require.NotNil(t, result)
  68. assert.Equal(t, int64(1), result.Added, "H-3:lock 在 tx 内就位后应当能正常写入")
  69. }
  70. // TC-0844: H-3 分支 —— tx 内 LockByCodeTx 返回 sqlx.ErrNotFound(产品在 tx 开启后被删),
  71. // 必须映射为 SyncPermsError{Code:404, Message:"产品不存在"},而非 500。
  72. func TestExecuteSyncPerms_LockNotFound_Maps404(t *testing.T) {
  73. ctrl := gomock.NewController(t)
  74. t.Cleanup(ctrl.Finish)
  75. productMock := newBaseProductMock(ctrl, "pc_tx_gone")
  76. permMock := mocks.NewMockSysPermModel(ctrl)
  77. permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
  78. DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
  79. return fn(ctx, nil)
  80. })
  81. productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_gone").
  82. Return(nil, sqlx.ErrNotFound)
  83. // 关键:锁失败后绝不能继续走 FindMapByProductCodeWithTx / BatchInsertWithTx。
  84. // gomock 默认严格模式会在 Finish 时报 "unexpected call",所以不为这些方法登记任何期望即可。
  85. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
  86. result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s",
  87. []SyncPermItem{{Code: "x", Name: "X"}})
  88. assert.Nil(t, result)
  89. require.Error(t, err)
  90. var se *SyncPermsError
  91. require.True(t, errors.As(err, &se), "H-3:锁不到产品行必须产出 *SyncPermsError")
  92. assert.Equal(t, 404, se.Code,
  93. "H-3:tx 开启后 LockByCodeTx=ErrNotFound 意味着产品行在 tx 中不可见,应当返回 404 而非 500")
  94. assert.Contains(t, se.Message, "产品不存在",
  95. "H-3:文案应当能让调用方人眼秒懂是什么错误")
  96. }
  97. // TC-0845: H-3 容错 —— tx 内 LockByCodeTx 冒出非 NotFound 的通用错误(driver/conn 异常),
  98. // 必须被事务回滚并被外层包裹为 SyncPermsError(500 级),而非原始 driver 错误直接冒出去。
  99. func TestExecuteSyncPerms_LockGenericError_WrappedAs500(t *testing.T) {
  100. ctrl := gomock.NewController(t)
  101. t.Cleanup(ctrl.Finish)
  102. productMock := newBaseProductMock(ctrl, "pc_tx_boom")
  103. permMock := mocks.NewMockSysPermModel(ctrl)
  104. permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
  105. DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
  106. return fn(ctx, nil)
  107. })
  108. boom := errors.New("driver: connection lost")
  109. productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_boom").
  110. Return(nil, boom)
  111. // 锁失败后同样不应调用后续方法。
  112. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
  113. result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s",
  114. []SyncPermItem{{Code: "x", Name: "X"}})
  115. assert.Nil(t, result)
  116. require.Error(t, err)
  117. var se *SyncPermsError
  118. require.True(t, errors.As(err, &se), "H-3:底层错误必须被包成 *SyncPermsError,防止 driver 错误直接上抛")
  119. assert.Equal(t, 500, se.Code,
  120. "H-3:非 NotFound 的 DB 错误应当 fail-close 为 500,让接入方区别于 404/409")
  121. assert.NotContains(t, se.Message, "connection lost",
  122. "H-3:对外文案不能泄露原始 driver 错误(避免信息披露)")
  123. }