| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- package product
- import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "testing"
- "time"
- "perms-system-server/internal/consts"
- productModel "perms-system-server/internal/model/product"
- memberModel "perms-system-server/internal/model/productmember"
- userModel "perms-system-server/internal/model/user"
- "perms-system-server/internal/response"
- "perms-system-server/internal/svc"
- "perms-system-server/internal/testutil"
- "perms-system-server/internal/testutil/ctxhelper"
- "perms-system-server/internal/types"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "github.com/zeromicro/go-zero/core/stores/redis"
- )
- func insertTestProduct(t *testing.T, ctx context.Context) *productModel.SysProduct {
- t.Helper()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- code := testutil.UniqueId()
- now := time.Now().Unix()
- result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
- Code: code,
- Name: "待更新产品",
- AppKey: "test_key_" + code,
- AppSecret: "test_secret_" + code,
- Remark: "原始备注",
- Status: 1,
- CreateTime: now,
- UpdateTime: now,
- })
- require.NoError(t, err)
- id, _ := result.LastInsertId()
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_product`", id)
- })
- return &productModel.SysProduct{
- Id: id,
- Code: code,
- Name: "待更新产品",
- AppKey: "test_key_" + code,
- AppSecret: "test_secret_" + code,
- Remark: "原始备注",
- Status: 1,
- CreateTime: now,
- UpdateTime: now,
- }
- }
- // TC-0076: 正常更新
- func TestUpdateProduct_Success(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- p := insertTestProduct(t, ctx)
- logic := NewUpdateProductLogic(ctx, svcCtx)
- err := logic.UpdateProduct(&types.UpdateProductReq{
- Id: p.Id,
- Name: "已更新名称",
- Remark: "已更新备注",
- Status: 2,
- })
- require.NoError(t, err)
- updated, err := svcCtx.SysProductModel.FindOne(ctx, p.Id)
- require.NoError(t, err)
- assert.Equal(t, "已更新名称", updated.Name)
- assert.Equal(t, "已更新备注", updated.Remark)
- assert.Equal(t, int64(2), updated.Status)
- }
- // TC-0077: 不存在
- func TestUpdateProduct_NotFound(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- logic := NewUpdateProductLogic(ctx, svcCtx)
- err := logic.UpdateProduct(&types.UpdateProductReq{
- Id: 999999999,
- Name: "不存在",
- })
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 404, codeErr.Code())
- assert.Equal(t, "产品不存在", codeErr.Error())
- }
- // TC-0078: 不传status
- func TestUpdateProduct_StatusZeroUnchanged(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- p := insertTestProduct(t, ctx)
- logic := NewUpdateProductLogic(ctx, svcCtx)
- err := logic.UpdateProduct(&types.UpdateProductReq{
- Id: p.Id,
- Name: "新名称",
- Remark: "新备注",
- Status: 0,
- })
- require.NoError(t, err)
- updated, err := svcCtx.SysProductModel.FindOne(ctx, p.Id)
- require.NoError(t, err)
- assert.Equal(t, "新名称", updated.Name)
- assert.Equal(t, "新备注", updated.Remark)
- assert.Equal(t, int64(1), updated.Status, "status should remain unchanged when req.Status is 0")
- }
- // TC-0536: updateProduct非超管拒绝
- func TestUpdateProduct_NonSuperAdminRejected(t *testing.T) {
- ctx := ctxhelper.AdminCtx("test_product")
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- logic := NewUpdateProductLogic(ctx, svcCtx)
- err := logic.UpdateProduct(&types.UpdateProductReq{Id: 1, Name: "test"})
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- assert.Equal(t, 403, ce.Code())
- }
- // TC-0090: updateProduct 非法状态值被拒绝(修复验证)
- func TestUpdateProduct_InvalidStatusRejected(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- p := insertTestProduct(t, ctx)
- logic := NewUpdateProductLogic(ctx, svcCtx)
- invalid := []int64{3, 99, -1}
- for _, st := range invalid {
- err := logic.UpdateProduct(&types.UpdateProductReq{Id: p.Id, Status: st})
- require.Error(t, err, "status=%d 应被拒绝", st)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- assert.Equal(t, 400, ce.Code())
- }
- after, err := svcCtx.SysProductModel.FindOne(ctx, p.Id)
- require.NoError(t, err)
- assert.Equal(t, int64(1), after.Status, "非法状态不应落库")
- }
- // ---------------------------------------------------------------------------
- // L-R15-3:UpdateProduct 在 Enabled → Disabled 迁移时,事务内同步对该产品下
- // 所有启用成员的 sys_user.tokenVersion 做 +1,让旧 access token 在下一次
- // middleware 校验时被 401 踢出。Disabled → Enabled / 非状态更新不触发。
- // 断言三件事:
- // - revoke 集合完备且不越界(只影响 target product 的 active 成员);
- // - 事务原子:产品写失败则 tokenVersion 不涨,反之亦然;
- // - post-commit 失效 sysProduct + sysUser 两侧低层缓存。
- // ---------------------------------------------------------------------------
- // seedProductWithMembers 构造一个启用中的产品,附带若干成员:
- // - memberSpec 每项是 {memberType, status},按顺序 seed;
- // - 返回产品及各 userId(顺序对应入参)。
- // 统一用 UniqueId 避免与既有数据冲突;t.Cleanup 里按 productCode 清理成员,再删用户/产品。
- type memberSpec struct {
- MemberType string
- Status int64 // 1=Enabled, 2=Disabled
- }
- type seededProductMembers struct {
- product *productModel.SysProduct
- userIds []int64
- }
- func seedProductWithMembers(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, specs []memberSpec) seededProductMembers {
- t.Helper()
- conn := testutil.GetTestSqlConn()
- code := testutil.UniqueId()
- now := time.Now().Unix()
- pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
- Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
- Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- pId, _ := pRes.LastInsertId()
- var userIds []int64
- for i, s := range specs {
- uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: fmt.Sprintf("%s_u%d", code, i),
- Password: testutil.HashPassword("pw"),
- Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- uId, _ := uRes.LastInsertId()
- userIds = append(userIds, uId)
- _, err = svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
- ProductCode: code, UserId: uId, MemberType: s.MemberType,
- Status: s.Status, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- }
- t.Cleanup(func() {
- testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
- for _, uId := range userIds {
- testutil.CleanTable(ctx, conn, "`sys_user`", uId)
- }
- testutil.CleanTable(ctx, conn, "`sys_product`", pId)
- })
- p, err := svcCtx.SysProductModel.FindOne(ctx, pId)
- require.NoError(t, err)
- return seededProductMembers{product: p, userIds: userIds}
- }
- func readTokenVersion(t *testing.T, ctx context.Context, userId int64) int64 {
- t.Helper()
- conn := testutil.GetTestSqlConn()
- var tv int64
- require.NoError(t,
- conn.QueryRowCtx(ctx, &tv,
- "SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId))
- return tv
- }
- // TC-1138:Enabled→Disabled 时,产品下的启用成员 tokenVersion 全部 +1;禁用成员不影响。
- // 覆盖 ADMIN/DEVELOPER/MEMBER 三种 type,证明 revoke 集合按 status 过滤而非按 memberType。
- func TestUpdateProduct_Disable_BumpsAllActiveMemberTokenVersions(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
- {consts.MemberTypeAdmin, consts.StatusEnabled},
- {consts.MemberTypeDeveloper, consts.StatusEnabled},
- {consts.MemberTypeMember, consts.StatusEnabled},
- {consts.MemberTypeMember, consts.StatusDisabled}, // 禁用成员必须被跳过
- })
- before := make([]int64, len(seeded.userIds))
- for i, uId := range seeded.userIds {
- before[i] = readTokenVersion(t, ctx, uId)
- }
- require.NoError(t,
- NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
- Id: seeded.product.Id,
- Name: seeded.product.Name,
- Status: consts.StatusDisabled,
- }))
- // 前 3 个(启用)每个 +1;最后一个(禁用)保持不变
- for i := 0; i < 3; i++ {
- after := readTokenVersion(t, ctx, seeded.userIds[i])
- assert.Equal(t, before[i]+1, after,
- "第 %d 个启用成员(userId=%d)tokenVersion 必须 +1", i, seeded.userIds[i])
- }
- after := readTokenVersion(t, ctx, seeded.userIds[3])
- assert.Equal(t, before[3], after,
- "禁用成员不应被 bump——FindActiveMemberUserIdsByProductCodeTx 必须 WHERE status=1 过滤,"+
- "否则已经冻结的旧成员会被二次踢出(无意义)且放大批量 UPDATE 的行数")
- }
- // TC-1139:Disabled→Enabled 时 tokenVersion 不变(重启用不吊销 session)。
- // 对应 shouldRevokeSessions = (prev==Enabled && next==Disabled) 这一严格方向性判断。
- func TestUpdateProduct_Enable_DoesNotBumpTokenVersion(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
- {consts.MemberTypeMember, consts.StatusEnabled},
- })
- // 先禁用一次把前置条件做足
- require.NoError(t,
- NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
- Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
- }))
- before := readTokenVersion(t, ctx, seeded.userIds[0])
- // 重启用
- require.NoError(t,
- NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
- Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusEnabled,
- }))
- after := readTokenVersion(t, ctx, seeded.userIds[0])
- assert.Equal(t, before, after,
- "Disabled→Enabled 不递增——重启用不会让成员获得未曾持有的权限(他们此前即处于 token 已失效态),"+
- "再递增只会给合法重登录增加一次无谓的 401")
- }
- // TC-1140:仅改名不改 status 时 tokenVersion 不变。
- // 非状态迁移走 shouldRevokeSessions=false 分支,不得踢任何人下线。
- func TestUpdateProduct_NameOnlyChange_DoesNotBumpTokenVersion(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
- {consts.MemberTypeAdmin, consts.StatusEnabled},
- {consts.MemberTypeMember, consts.StatusEnabled},
- })
- before := []int64{
- readTokenVersion(t, ctx, seeded.userIds[0]),
- readTokenVersion(t, ctx, seeded.userIds[1]),
- }
- require.NoError(t,
- NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
- Id: seeded.product.Id, Name: "new_" + seeded.product.Name, Status: 0,
- }))
- for i, uId := range seeded.userIds {
- assert.Equal(t, before[i], readTokenVersion(t, ctx, uId),
- "非状态更新不得触发 revoke——userId=%d 被意外递增意味着 shouldRevokeSessions 判定漂移", uId)
- }
- }
- // TC-1141:跨产品隔离——禁用 P1 不应影响只属于 P2 的用户 tokenVersion。
- // 对应 FindActiveMemberUserIdsByProductCodeTx 的 WHERE productCode=? 精确过滤。
- func TestUpdateProduct_Disable_CrossProductIsolation(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- p1 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
- {consts.MemberTypeMember, consts.StatusEnabled},
- })
- p2 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
- {consts.MemberTypeMember, consts.StatusEnabled},
- })
- p1Before := readTokenVersion(t, ctx, p1.userIds[0])
- p2Before := readTokenVersion(t, ctx, p2.userIds[0])
- require.NoError(t,
- NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
- Id: p1.product.Id, Name: p1.product.Name, Status: consts.StatusDisabled,
- }))
- assert.Equal(t, p1Before+1, readTokenVersion(t, ctx, p1.userIds[0]),
- "P1 成员必须 +1")
- assert.Equal(t, p2Before, readTokenVersion(t, ctx, p2.userIds[0]),
- "P2 成员 tokenVersion 绝不允许被波及——这类'共享 userId'跨产品污染是 L-R15-3 的反面教材")
- }
- // TC-1142:空活跃成员产品禁用时,UpdateProduct 仍必须成功(不能因 ids 为空误抛错)。
- // 同时产品行必须按期从 Enabled 翻到 Disabled,post-commit 失效 productCache 的三把 key。
- func TestUpdateProduct_Disable_NoActiveMembers_StillSucceeds(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- seeded := seedProductWithMembers(t, ctx, svcCtx, nil) // 没有成员
- require.NoError(t,
- NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
- Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
- }),
- "len(ids)==0 的快捷分支必须 nil,不得因为 BatchIncrement 空入参抛错")
- p, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
- require.NoError(t, err)
- assert.Equal(t, int64(consts.StatusDisabled), p.Status,
- "产品行仍必须被正确更新——revoke 集为空不构成短路 UPDATE 的理由")
- }
- // TC-1143(新增):post-commit 必须失效 sysProduct 的 id / appKey / code 三把低层缓存。
- // 否则下次 FindOne/FindOneByAppKey/FindOneByCode cache-hit 会读到旧 Status=Enabled,
- // 令客户端观察到"产品已禁但还能正常查到"的脏态。
- func TestUpdateProduct_Disable_InvalidatesProductCache(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- cfg := testutil.GetTestConfig()
- svcCtx := svc.NewServiceContext(cfg)
- rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
- seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
- {consts.MemberTypeMember, consts.StatusEnabled},
- })
- // 预热三把 key
- _, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
- require.NoError(t, err)
- _, err = svcCtx.SysProductModel.FindOneByAppKey(ctx, seeded.product.AppKey)
- require.NoError(t, err)
- _, err = svcCtx.SysProductModel.FindOneByCode(ctx, seeded.product.Code)
- require.NoError(t, err)
- prefix := testutil.GetTestCachePrefix()
- keyId := fmt.Sprintf("%s:cache:sysProduct:id:%d", prefix, seeded.product.Id)
- keyAppKey := fmt.Sprintf("%s:cache:sysProduct:appKey:%s", prefix, seeded.product.AppKey)
- keyCode := fmt.Sprintf("%s:cache:sysProduct:code:%s", prefix, seeded.product.Code)
- for _, k := range []string{keyId, keyAppKey, keyCode} {
- v, _ := rds.Get(k)
- require.NotEmpty(t, v, "预置:%s 必须已写入缓存", k)
- }
- require.NoError(t,
- NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
- Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
- }))
- for _, k := range []string{keyId, keyAppKey, keyCode} {
- v, _ := rds.Get(k)
- assert.Empty(t, v,
- "L-R15-3:post-commit 必须清理 sysProduct 低层缓存 %s,否则 FindOne 仍命中旧 Enabled 值", k)
- }
- }
|