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-1063: 纯新增(added>0 && updated==0 && disabled==0)同样必须触发 CleanByProduct。 // // 口径说明:SyncPerms 的缓存失效条件已经扩到 `added>0 || updated>0 || disabled>0`。 // 根因见 syncPermsService.go 中的 M-R17-1 注释——**全权用户**(SuperAdmin / 本产品 ADMIN / // DEVELOPER / DEV 部门启用成员)的 loadPerms 走 FindAllCodesByProductCode(productCode) // 单条路径,返回该产品下所有 Enabled 的 perm 全集;新增任何一条 perm 都会让该集合变大。 // 若纯新增分支跳过 CleanByProduct,这四类用户的 UD 缓存仍保留旧 perms 快照,最长 5min // 延迟才能看到新权限——发版当天"超管登录拉不到 /v2/C"。 func TestSyncPerms_PureAddTriggersCleanByProduct(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: "r17_1_add_a", Name: "A"}, {Code: "r17_1_add_b", Name: "B"}, {Code: "r17_1_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) exists, err := rds.Exists(idxKey) require.NoError(t, err) assert.False(t, exists, "added=3 / updated=0 / disabled=0 的纯新增同样必须触发 CleanByProduct;"+ "若 canary 仍残留在 productIndexKey,说明 added 分支被误放行(M-R17-1 回归),"+ "全权用户的 UD 缓存将在最长 5min TTL 内继续返回旧 perms 集合") } // 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,产生权限残留") } // TC-1118: M-R14-1 —— ExecuteSyncPerms 的 post-commit CleanByProduct 必须跑在 // DetachCacheCleanCtx 独立 ctx 上。用例口径: // 1. 用 `parent = WithCancel(bg)` 作为调用方 ctx; // 2. 先走一次纯新增做打底; // 3. primeProductIndex 置 canary,随后第二次 sync 改同 code 的 Name → updated=1; // 4. 在 ExecuteSyncPerms 返回后 **立即** `cancel(parent)`; // 5. canary 必须已被删除——说明 CleanByProduct 的 Redis DEL 跑在独立 ctx 上、 // 不依赖 parent 存活。若回退为"直接传 parent ctx",cancel 会与 DEL 竞争, // 实际落到生产时 5min TTL 内的 UD 缓存仍挂着被禁用 / 变更的 perm。 func TestSyncPerms_M_R14_1_CleanByProductDetachedFromRequestCtx(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) defer cancel() 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, parent, pc, appKey, appSecret, 1) t.Cleanup(cleanProduct) t.Cleanup(func() { testutil.CleanTableByField(parent, conn, "`sys_perm`", "productCode", pc) }) // 第一次:打底;canary 放在此之后。此次 added>0 也会触发 Clean(见 TC-1063), // 因此 primeProductIndex 必须在第一次 sync 返回之后再执行,避免被顺带清掉导致 canary 失效。 _, err := ExecuteSyncPerms(parent, svcCtx, appKey, appSecret, []SyncPermItem{ {Code: "r14_detach_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 → 必触发 CleanByProduct。 result, err := ExecuteSyncPerms(parent, svcCtx, appKey, appSecret, []SyncPermItem{ {Code: "r14_detach_upd", Name: "NewName"}, }) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, int64(1), result.Updated, "前置:同名 Code 改 Name 必须 updated=1,否则后续 Clean 断言失去意义") // 模拟"HTTP ctx 在函数返回同时/之后被 cancel"。若未 detach,生产环境里此 cancel // 在 Clean 的 DEL 之前触达,Redis 里的 canary 会留存;若已 detach,cancel 只影响 // parent,CleanByProduct 用的 cleanCtx 独立存活 3s,DEL 正常落地。 cancel() exists, err := rds.Exists(idxKey) require.NoError(t, err) assert.False(t, exists, "M-R14-1:productIndexKey 必须被 CleanByProduct 抹掉;若仍存在,说明 CleanByProduct "+ "被请求 ctx 的 cancel 拖死,对应生产事务已 commit 但 UD 缓存仍挂着被禁用 perm 的 5min 窗口") } 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 错误(避免信息披露)") }