| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151 |
- package pub
- import (
- "context"
- "errors"
- "testing"
- permModel "perms-system-server/internal/model/perm"
- productModel "perms-system-server/internal/model/product"
- "perms-system-server/internal/testutil/mocks"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "github.com/zeromicro/go-zero/core/stores/sqlx"
- "go.uber.org/mock/gomock"
- "golang.org/x/crypto/bcrypt"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:审计第 6 轮 H-3 修复回归 —— ExecuteSyncPerms 必须在 tx 内
- // 1. 先调用 LockByCodeTx 锁住 sys_product 行;
- // 2. 再调用 FindMapByProductCodeWithTx(事务内读 perm map)。
- //
- // 为什么重要:
- // - 修复前 perm map 的 "existing vs. new" 判断发生在 tx 外,两笔并发 sync 都可能
- // 认为 "code X 不存在",之后都在 tx 内 INSERT,撞 UNIQUE(productCode, code) 导致 1062。
- // - 修复后所有并发请求都要先排队拿到 product 行锁,才能读到一致的 existing 集合并写入,
- // 将 "并发同步同一个产品" 串行化。
- //
- // 这个文件只关心"拿锁"这一段的契约(执行顺序 / 错误路径),
- // 避免重叠 syncPermsConflict_audit_test.go 中的 1062 → 409 映射。
- // ---------------------------------------------------------------------------
- // newBaseProductMock 只认 appKey + 校验 secret + 产品启用,返回固定 Code="pc_tx"。
- func newBaseProductMock(ctrl *gomock.Controller, code string) *mocks.MockSysProductModel {
- hashed, _ := bcrypt.GenerateFromPassword([]byte("s"), bcrypt.MinCost)
- m := mocks.NewMockSysProductModel(ctrl)
- m.EXPECT().FindOneByAppKey(gomock.Any(), "ak").
- Return(&productModel.SysProduct{
- Id: 1, Code: code, AppKey: "ak", AppSecret: string(hashed), Status: 1,
- }, nil)
- return m
- }
- // TC-0843: H-3 契约 —— 正常路径下 LockByCodeTx 必须先于 FindMapByProductCodeWithTx,
- // 且两者均在同一个 tx session 内被调用。
- func TestExecuteSyncPerms_LockBeforeMapReadInTx(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- productMock := newBaseProductMock(ctrl, "pc_tx_order")
- permMock := mocks.NewMockSysPermModel(ctrl)
- // 关键点 1:TransactCtx 必须真的传入一个 tx session,并把所有子调用都发生在其中。
- permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
- DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
- return fn(ctx, nil) // nil session 只是 mock 占位
- })
- // 关键点 2:gomock 的 Call.After 强制 LockByCodeTx 先于 FindMapByProductCodeWithTx 执行。
- // 顺序反过来的话 gomock 会在 Finish 时报错。
- // 审计 M-R10-1:事务内复核 Status 必须 Status=1,否则走 403 分支不写 perm
- lockCall := productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_order").
- Return(&productModel.SysProduct{Id: 1, Code: "pc_tx_order", Status: 1}, nil)
- permMock.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "pc_tx_order").
- Return(map[string]*permModel.SysPerm{}, nil).
- After(lockCall)
- // 一条简单的 INSERT + DisableNotIn 让流程走完;非本 TC 的主断言。
- permMock.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(nil)
- permMock.EXPECT().DisableNotInCodesWithTx(gomock.Any(), nil, "pc_tx_order", []string{"x"}, gomock.Any()).
- Return(int64(0), nil)
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
- result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s",
- []SyncPermItem{{Code: "x", Name: "X"}})
- require.NoError(t, err)
- require.NotNil(t, result)
- assert.Equal(t, int64(1), result.Added, "H-3:lock 在 tx 内就位后应当能正常写入")
- }
- // TC-0844: H-3 分支 —— tx 内 LockByCodeTx 返回 sqlx.ErrNotFound(产品在 tx 开启后被删),
- // 必须映射为 SyncPermsError{Code:404, Message:"产品不存在"},而非 500。
- func TestExecuteSyncPerms_LockNotFound_Maps404(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- productMock := newBaseProductMock(ctrl, "pc_tx_gone")
- permMock := mocks.NewMockSysPermModel(ctrl)
- permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
- DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
- return fn(ctx, nil)
- })
- productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_gone").
- Return(nil, sqlx.ErrNotFound)
- // 关键:锁失败后绝不能继续走 FindMapByProductCodeWithTx / BatchInsertWithTx。
- // gomock 默认严格模式会在 Finish 时报 "unexpected call",所以不为这些方法登记任何期望即可。
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
- result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s",
- []SyncPermItem{{Code: "x", Name: "X"}})
- assert.Nil(t, result)
- require.Error(t, err)
- var se *SyncPermsError
- require.True(t, errors.As(err, &se), "H-3:锁不到产品行必须产出 *SyncPermsError")
- assert.Equal(t, 404, se.Code,
- "H-3:tx 开启后 LockByCodeTx=ErrNotFound 意味着产品行在 tx 中不可见,应当返回 404 而非 500")
- assert.Contains(t, se.Message, "产品不存在",
- "H-3:文案应当能让调用方人眼秒懂是什么错误")
- }
- // TC-0845: H-3 容错 —— tx 内 LockByCodeTx 冒出非 NotFound 的通用错误(driver/conn 异常),
- // 必须被事务回滚并被外层包裹为 SyncPermsError(500 级),而非原始 driver 错误直接冒出去。
- func TestExecuteSyncPerms_LockGenericError_WrappedAs500(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- productMock := newBaseProductMock(ctrl, "pc_tx_boom")
- permMock := mocks.NewMockSysPermModel(ctrl)
- permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
- DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
- return fn(ctx, nil)
- })
- boom := errors.New("driver: connection lost")
- productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_boom").
- Return(nil, boom)
- // 锁失败后同样不应调用后续方法。
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
- result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s",
- []SyncPermItem{{Code: "x", Name: "X"}})
- assert.Nil(t, result)
- require.Error(t, err)
- var se *SyncPermsError
- require.True(t, errors.As(err, &se), "H-3:底层错误必须被包成 *SyncPermsError,防止 driver 错误直接上抛")
- assert.Equal(t, 500, se.Code,
- "H-3:非 NotFound 的 DB 错误应当 fail-close 为 500,让接入方区别于 404/409")
- assert.NotContains(t, se.Message, "connection lost",
- "H-3:对外文案不能泄露原始 driver 错误(避免信息披露)")
- }
|