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 时报错。 lockCall := productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_order"). Return(&productModel.SysProduct{Id: 1, Code: "pc_tx_order"}, 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 错误(避免信息披露)") }