package pub import ( "context" "errors" "fmt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" "github.com/zeromicro/go-zero/core/stores/sqlx" "go.uber.org/mock/gomock" "golang.org/x/crypto/bcrypt" permModel "perms-system-server/internal/model/perm" productModel "perms-system-server/internal/model/product" "perms-system-server/internal/response" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/mocks" "perms-system-server/internal/types" "testing" "time" ) func insertSyncTestProduct(t *testing.T, ctx context.Context, code, appKey, appSecret string, status int64) (int64, func()) { t.Helper() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() now := time.Now().Unix() hashedSecret, err := bcrypt.GenerateFromPassword([]byte(appSecret), bcrypt.MinCost) require.NoError(t, err) res, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: code, Name: code, AppKey: appKey, AppSecret: string(hashedSecret), Status: status, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() cleanup := func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) } return id, cleanup } // TC-0036: 全部新增 func TestSyncPerms_AllNew(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) }) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{ {Code: "perm_a", Name: "Perm A", Remark: "remark a"}, {Code: "perm_b", Name: "Perm B"}, {Code: "perm_c", Name: "Perm C"}, }, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(3), resp.Added) assert.Equal(t, int64(0), resp.Updated) assert.Equal(t, int64(0), resp.Disabled) } // TC-0037: 更新已有(名称变更) func TestSyncPerms_UpdateExisting(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() now := time.Now().Unix() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: "Old Name", Code: "upd_code", Remark: "old remark", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) }) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{ {Code: "upd_code", Name: "New Name", Remark: "new remark"}, }, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(0), resp.Added) assert.Equal(t, int64(1), resp.Updated) assert.Equal(t, int64(0), resp.Disabled) updated, err := svcCtx.SysPermModel.FindOne(ctx, permId) require.NoError(t, err) assert.Equal(t, "New Name", updated.Name) assert.Equal(t, "new remark", updated.Remark) assert.Equal(t, int64(1), updated.Status) } // TC-0038: 无变化 func TestSyncPerms_NoChanges(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() now := time.Now().Unix() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: "Same Name", Code: "same_code", Remark: "same remark", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) }) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{ {Code: "same_code", Name: "Same Name", Remark: "same remark"}, }, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(0), resp.Added) assert.Equal(t, int64(0), resp.Updated) assert.Equal(t, int64(0), resp.Disabled) } // TC-0039: 禁用权限重启 func TestSyncPerms_ReEnableDisabled(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() now := time.Now().Unix() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: "Disabled Perm", Code: "dis_code", Remark: "", Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) }) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{ {Code: "dis_code", Name: "Disabled Perm"}, }, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(0), resp.Added) assert.Equal(t, int64(1), resp.Updated) reEnabled, err := svcCtx.SysPermModel.FindOne(ctx, permId) require.NoError(t, err) assert.Equal(t, int64(1), reEnabled.Status) } // TC-0040: 移除不在列表的权限 func TestSyncPerms_DisableNotInList(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() now := time.Now().Unix() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) keepCode := testutil.UniqueId() removeCode := testutil.UniqueId() keepRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: "Keep", Code: keepCode, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) keepId, _ := keepRes.LastInsertId() removeRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: "Remove", Code: removeCode, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) removeId, _ := removeRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", keepId, removeId) }) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{ {Code: keepCode, Name: "Keep"}, }, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(0), resp.Added) assert.Equal(t, int64(1), resp.Disabled) disabled, err := svcCtx.SysPermModel.FindOne(ctx, removeId) require.NoError(t, err) assert.Equal(t, int64(2), disabled.Status) kept, err := svcCtx.SysPermModel.FindOne(ctx, keepId) require.NoError(t, err) assert.Equal(t, int64(1), kept.Status) } // TC-0041: 空perms数组应被拒绝 func TestSyncPerms_EmptyPermsRejected(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{}, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Contains(t, codeErr.Error(), "权限列表不能为空") } // TC-0043: appKey无效 func TestSyncPerms_InvalidAppKey(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: "nonexistent_key_" + testutil.UniqueId(), AppSecret: "whatever", Perms: []types.SyncPermItem{{Code: "x", Name: "x"}}, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "无效的appKey", codeErr.Error()) } // TC-0044: appSecret错误 func TestSyncPerms_WrongAppSecret(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: "wrong_secret", Perms: []types.SyncPermItem{{Code: "x", Name: "x"}}, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "appSecret验证失败", codeErr.Error()) } // TC-0045: 产品已禁用 func TestSyncPerms_ProductDisabled(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 2) t.Cleanup(cleanProduct) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{{Code: "x", Name: "x"}}, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 403, codeErr.Code()) assert.Equal(t, "产品已被禁用", codeErr.Error()) } // TC-0046: 大批量(1000条) func TestSyncPerms_LargeBatch1000(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) }) perms := make([]types.SyncPermItem, 1000) for i := 0; i < 1000; i++ { perms[i] = types.SyncPermItem{ Code: fmt.Sprintf("batch_%s_%d", pc, i), Name: fmt.Sprintf("Perm_%d", i), } } logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: perms, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(1000), resp.Added) assert.Equal(t, int64(0), resp.Updated) assert.Equal(t, int64(0), resp.Disabled) } // TC-0047: 重复code去重 func TestSyncPerms_DeduplicateCodes(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) }) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{ {Code: "dup_code", Name: "Perm First"}, {Code: "dup_code", Name: "Perm Duplicate"}, {Code: "unique_code", Name: "Unique"}, }, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(2), resp.Added, "重复code应被去重,只添加2条") } // TC-0042: 验证disabled返回值 func TestSyncPerms_VerifyDisabledCount(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() now := time.Now().Unix() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) var permIds []int64 for i := 0; i < 5; i++ { res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: fmt.Sprintf("p%d", i), Code: fmt.Sprintf("code_%s_%d", pc, i), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() permIds = append(permIds, id) } t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permIds...) }) logic := NewSyncPermsLogic(ctx, svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: appKey, AppSecret: appSecret, Perms: []types.SyncPermItem{ {Code: fmt.Sprintf("code_%s_0", pc), Name: "p0"}, {Code: fmt.Sprintf("code_%s_1", pc), Name: "p1"}, }, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(0), resp.Added) assert.Equal(t, int64(3), resp.Disabled) } func TestSyncPerms_LockByCodeTxNotFound_MapsToHTTP404(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() hashedSecret, err := bcrypt.GenerateFromPassword([]byte("m2_secret"), bcrypt.MinCost) require.NoError(t, err) mockProduct := mocks.NewMockSysProductModel(ctrl) mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "m2_key"). Return(&productModel.SysProduct{ Id: 1, Code: "m2_prod", AppKey: "m2_key", AppSecret: string(hashedSecret), Status: 1, }, nil) // 关键:tx 内 LockByCodeTx 拿到 ErrNotFound → service 返回 SyncPermsError{Code:404, "产品不存在"}。 mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "m2_prod"). Return((*productModel.SysProduct)(nil), sqlx.ErrNotFound) mockPerm := mocks.NewMockSysPermModel(ctrl) mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProduct, Perm: mockPerm}) logic := NewSyncPermsLogic(context.Background(), svcCtx) resp, err := logic.SyncPerms(&types.SyncPermsReq{ AppKey: "m2_key", AppSecret: "m2_secret", Perms: []types.SyncPermItem{{Code: "p1", Name: "P1"}}, }) assert.Nil(t, resp) require.Error(t, err, "tx 内产品消失必须返回错误") var ce *response.CodeError require.ErrorAs(t, err, &ce, "必须映射成 response.CodeError 结构化错误,不能透传 SyncPermsError 原文") assert.Equal(t, 404, ce.Code(), "SyncPermsError{Code:404} 必须落到 HTTP 404 分支;若仍是 500 说明 syncPermsLogic 的 switch 缺少 404 case") assert.Equal(t, "产品不存在", ce.Error(), "保留原始语义文案") } // TC-0980(负值域对称):未映射的 se.Code(例如 500)依旧走 default,原样透传,不得被误收进 404。 // 防御未来有人想"把所有 SyncPermsError 都按 404 处理"的随手改动。 func TestSyncPerms_UnmappedSyncPermsErrCode_StillFallsThroughDefault(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() hashedSecret, err := bcrypt.GenerateFromPassword([]byte("m2_secret"), bcrypt.MinCost) require.NoError(t, err) mockProduct := mocks.NewMockSysProductModel(ctrl) mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "m2_key2"). Return(&productModel.SysProduct{ Id: 1, Code: "m2_prod2", AppKey: "m2_key2", AppSecret: string(hashedSecret), Status: 1, }, nil) // LockByCodeTx 拿到的行必须 Status=1 才能继续进入 diff 逻辑 mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "m2_prod2"). Return(&productModel.SysProduct{Id: 1, Code: "m2_prod2", Status: 1}, nil) mockPerm := mocks.NewMockSysPermModel(ctrl) mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "m2_prod2"). Return(nil, assertAnyErr("internal storage bug")) mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProduct, Perm: mockPerm}) logic := NewSyncPermsLogic(context.Background(), svcCtx) _, err = logic.SyncPerms(&types.SyncPermsReq{ AppKey: "m2_key2", AppSecret: "m2_secret", Perms: []types.SyncPermItem{{Code: "p1", Name: "P1"}}, }) require.Error(t, err) var se *SyncPermsError require.ErrorAs(t, err, &se, "未映射 code 走 default,原 SyncPermsError 被原样透传") assert.Equal(t, 500, se.Code, "500 必须保持 500 原语义,不得被误归类为 404") var ce *response.CodeError assert.False(t, assert.ObjectsAreEqual(err, ce), "500 分支绝不能被映射成 response.CodeError{Code:404}") } // assertAnyErr 构造任意错误,用来模拟 tx 内非业务分支错误。 func assertAnyErr(msg string) error { return &localErr{s: msg} } type localErr struct{ s string } func (e *localErr) Error() string { return e.s } func primeProductIndex(t *testing.T, rds *redis.Redis, cachePrefix, productCode string) string { t.Helper() idxKey := fmt.Sprintf("%s:ud:idx:p:%s", cachePrefix, productCode) canary := fmt.Sprintf("%s:ud:probe:%s", cachePrefix, testutil.UniqueId()) _, err := rds.Sadd(idxKey, canary) require.NoError(t, err, "SAdd 到 productIndexKey 失败,Redis 不可用,测试前置条件失败") members, err := rds.Smembers(idxKey) require.NoError(t, err) require.Contains(t, members, canary, "primeProductIndex: canary 必须先出现在集合里") return idxKey } // TC-1064: 纯新增不触发 CleanByProduct func TestSyncPerms_PureAddDoesNotTriggerCleanByProduct(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) }) idxKey := primeProductIndex(t, rds, cfg.CacheRedis.KeyPrefix, pc) t.Cleanup(func() { _, _ = rds.Del(idxKey) }) result, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{ {Code: "r11_4_add_a", Name: "A"}, {Code: "r11_4_add_b", Name: "B"}, {Code: "r11_4_add_c", Name: "C"}, }) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, int64(3), result.Added) assert.Equal(t, int64(0), result.Updated) assert.Equal(t, int64(0), result.Disabled) // 纯新增路径:CleanByProduct 不得被调用,索引集合必须仍保留 canary。 exists, err := rds.Exists(idxKey) require.NoError(t, err) assert.True(t, exists, "added=3 / updated=0 / disabled=0 属于纯新增,不得触发 CleanByProduct;"+ "productIndexKey 若被删除说明 SyncPerms 仍在走全产品清缓存路径,回归:会把该产品"+ "所有在线用户下一次请求同时打穿回 DB") } // TC-1065: updated > 0 时必须触发 CleanByProduct func TestSyncPerms_UpdateTriggersCleanByProduct(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) }) // 第一次同步:纯新增,为随后的 update 打底;这次不触发 Clean,CleanByProduct 仍然可被后续触发。 _, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{ {Code: "r11_4_upd", Name: "OldName"}, }) require.NoError(t, err) idxKey := primeProductIndex(t, rds, cfg.CacheRedis.KeyPrefix, pc) t.Cleanup(func() { _, _ = rds.Del(idxKey) }) // 第二次同步:同一 Code 改 Name → updated=1。 result, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{ {Code: "r11_4_upd", Name: "NewName"}, }) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, int64(1), result.Updated, "前置:同名 Code 改 Name 必须 updated=1,否则后续断言失去意义") exists, err := rds.Exists(idxKey) require.NoError(t, err) assert.False(t, exists, "updated>0 必须触发 CleanByProduct;若 canary 仍在,说明 Logic 把"+ "updated 情况也误归入'纯新增'分支,已存在 UD 缓存中的旧 perms 将长期对外返回") } // TC-1066: disabled > 0 时必须触发 CleanByProduct func TestSyncPerms_DisableTriggersCleanByProduct(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) conn := testutil.GetTestSqlConn() pc := testutil.UniqueId() appKey := testutil.UniqueId() appSecret := testutil.UniqueId() _, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) }) // 先注入两个 perm。 _, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{ {Code: "r11_4_keep", Name: "K"}, {Code: "r11_4_drop", Name: "D"}, }) require.NoError(t, err) idxKey := primeProductIndex(t, rds, cfg.CacheRedis.KeyPrefix, pc) t.Cleanup(func() { _, _ = rds.Del(idxKey) }) // 第二次只同步 r11_4_keep,r11_4_drop 会被 DisableNotInCodesWithTx 置 disabled。 result, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{ {Code: "r11_4_keep", Name: "K"}, }) require.NoError(t, err) assert.Equal(t, int64(1), result.Disabled, "前置:第二次只同步 keep,drop 必须被 disabled=1") exists, err := rds.Exists(idxKey) require.NoError(t, err) assert.False(t, exists, "disabled>0 必须触发 CleanByProduct,否则已缓存的 UD.perms 里仍挂着"+ "已禁用权限,权限网关会把不再有效的权限判为 allow,产生权限残留") } func TestExecuteSyncPerms_DeduplicatesRequest(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) hashedSecret, err := bcrypt.GenerateFromPassword([]byte("s"), bcrypt.MinCost) require.NoError(t, err) mockProduct := mocks.NewMockSysProductModel(ctrl) mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "ak"). Return(&productModel.SysProduct{ Id: 1, Code: "pc_dedup", AppKey: "ak", AppSecret: string(hashedSecret), Status: 1, }, nil) // LockByCodeTx 拿到的行必须 Status=1 mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_dedup"). Return(&productModel.SysProduct{Id: 1, Code: "pc_dedup", Status: 1}, nil) mockPerm := mocks.NewMockSysPermModel(ctrl) mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "pc_dedup"). Return(map[string]*permModel.SysPerm{}, nil) var captured []*permModel.SysPerm mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) mockPerm.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()). DoAndReturn(func(ctx context.Context, s sqlx.Session, items []*permModel.SysPerm) error { captured = items return nil }) // 去重后 codes 只剩一个,DisableNotInCodesWithTx 用去重后的集合做 NOT IN。 mockPerm.EXPECT().DisableNotInCodesWithTx(gomock.Any(), nil, "pc_dedup", []string{"dup_code"}, gomock.Any()). Return(int64(0), nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ Product: mockProduct, Perm: mockPerm, }) result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s", []SyncPermItem{ {Code: "dup_code", Name: "A"}, {Code: "dup_code", Name: "A-again"}, {Code: "dup_code", Name: "A-yet-again"}, }) require.NoError(t, err) require.NotNil(t, result) require.Len(t, captured, 1, "入参内 code 重复必须去重为 1 条,避免自撞 1062") assert.Equal(t, "dup_code", captured[0].Code) assert.Equal(t, "A", captured[0].Name, "去重策略应稳定到首次出现,使行为可预测") } 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: 契约 —— 正常路径下 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 时报错。 // 事务内复核 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, "lock 在 tx 内就位后应当能正常写入") } // TC-0844: 分支 —— 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), "锁不到产品行必须产出 *SyncPermsError") assert.Equal(t, 404, se.Code, "tx 开启后 LockByCodeTx=ErrNotFound 意味着产品行在 tx 中不可见,应当返回 404 而非 500") assert.Contains(t, se.Message, "产品不存在", "文案应当能让调用方人眼秒懂是什么错误") } // TC-0845: 容错 —— 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), "底层错误必须被包成 *SyncPermsError,防止 driver 错误直接上抛") assert.Equal(t, 500, se.Code, "非 NotFound 的 DB 错误应当 fail-close 为 500,让接入方区别于 404/409") assert.NotContains(t, se.Message, "connection lost", "对外文案不能泄露原始 driver 错误(避免信息披露)") }