|
|
@@ -3,8 +3,10 @@ package member
|
|
|
import (
|
|
|
"database/sql"
|
|
|
"errors"
|
|
|
+ "fmt"
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
"github.com/stretchr/testify/require"
|
|
|
+ "github.com/zeromicro/go-zero/core/stores/redis"
|
|
|
"perms-system-server/internal/consts"
|
|
|
productModel "perms-system-server/internal/model/product"
|
|
|
memberModel "perms-system-server/internal/model/productmember"
|
|
|
@@ -411,3 +413,325 @@ func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) {
|
|
|
assert.Equal(t, 400, ce.Code())
|
|
|
assert.Contains(t, ce.Error(), "最后一个管理员")
|
|
|
}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// M-R15-1 / L-R15-3:UpdateMember 在"降权"路径上必须同事务递增 sys_user.tokenVersion,
|
|
|
+// 让旧 access/refresh token 在下一次 middleware 校验时被 401 踢出,不再依赖
|
|
|
+// UserDetailsLoader.Del 的 best-effort 缓存失效。
|
|
|
+//
|
|
|
+// "降权"语义并集:
|
|
|
+// - MemberType 从 {ADMIN, DEVELOPER} 掉到 MEMBER;
|
|
|
+// - Status 从 Enabled 变 Disabled。
|
|
|
+//
|
|
|
+// 对称"升权"路径(MEMBER→ADMIN / Disabled→Enabled)明确**不**递增 tokenVersion,
|
|
|
+// 避免把无需重新登录的目标用户误踢下线。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+// seedEnabledProductWithMemberAndTv 在标准 seed 之外允许指定初始 Status。
|
|
|
+// 用于构造 "Status=Disabled 的成员被重启用" 这种 seedEnabledProductWithMember 无法直接表达的初态。
|
|
|
+func seedEnabledProductWithMemberAndTv(t *testing.T, svcCtx *svc.ServiceContext, memberType string, initialStatus int64) seededProduct {
|
|
|
+ t.Helper()
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
+ now := time.Now().Unix()
|
|
|
+ code := testutil.UniqueId()
|
|
|
+
|
|
|
+ pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
|
|
|
+ Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
|
|
|
+ Status: 1, CreateTime: now, UpdateTime: now,
|
|
|
+ })
|
|
|
+ require.NoError(t, err)
|
|
|
+ pId, _ := pRes.LastInsertId()
|
|
|
+
|
|
|
+ uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
|
|
|
+ Username: code, Password: testutil.HashPassword("pw"), Nickname: "n",
|
|
|
+ Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
|
|
|
+ Status: 1, CreateTime: now, UpdateTime: now,
|
|
|
+ })
|
|
|
+ require.NoError(t, err)
|
|
|
+ uId, _ := uRes.LastInsertId()
|
|
|
+
|
|
|
+ mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
|
|
|
+ ProductCode: code, UserId: uId, MemberType: memberType,
|
|
|
+ Status: initialStatus, CreateTime: now, UpdateTime: now,
|
|
|
+ })
|
|
|
+ require.NoError(t, err)
|
|
|
+ mId, _ := mRes.LastInsertId()
|
|
|
+
|
|
|
+ t.Cleanup(func() {
|
|
|
+ testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_user`", uId)
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_product`", pId)
|
|
|
+ })
|
|
|
+
|
|
|
+ return seededProduct{code: code, pId: pId, uId: uId, mId: mId, admin: mId}
|
|
|
+}
|
|
|
+
|
|
|
+// getUserTokenVersion 直接从 DB 读最新 tokenVersion,绕过低层缓存——
|
|
|
+// 测试需要观察 DB 真值,不能被"cache 回灌"污染。
|
|
|
+func getUserTokenVersion(t *testing.T, svcCtx *svc.ServiceContext, userId int64) int64 {
|
|
|
+ t.Helper()
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
+ var tv int64
|
|
|
+ require.NoError(t,
|
|
|
+ conn.QueryRowCtx(ctx, &tv,
|
|
|
+ "SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId),
|
|
|
+ "直读 DB 的 tokenVersion")
|
|
|
+ return tv
|
|
|
+}
|
|
|
+
|
|
|
+// seedSecondActiveAdmin 给同一个产品再加一条 ADMIN(Status=1),用于绕过 last-admin 拦截,
|
|
|
+// 这样才能真正进入"降级/禁用唯一 ADMIN 以外的成员"这条降权分支。
|
|
|
+func seedSecondActiveAdmin(t *testing.T, svcCtx *svc.ServiceContext, productCode string) (int64, int64) {
|
|
|
+ t.Helper()
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
+ now := time.Now().Unix()
|
|
|
+
|
|
|
+ uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
|
|
|
+ Username: "keeper_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
|
|
|
+ Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
|
|
|
+ Status: 1, CreateTime: now, UpdateTime: now,
|
|
|
+ })
|
|
|
+ require.NoError(t, err)
|
|
|
+ kUid, _ := uRes.LastInsertId()
|
|
|
+
|
|
|
+ mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
|
|
|
+ ProductCode: productCode, UserId: kUid, MemberType: consts.MemberTypeAdmin,
|
|
|
+ Status: 1, CreateTime: now, UpdateTime: now,
|
|
|
+ })
|
|
|
+ require.NoError(t, err)
|
|
|
+ kMid, _ := mRes.LastInsertId()
|
|
|
+
|
|
|
+ t.Cleanup(func() {
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_product_member`", kMid)
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_user`", kUid)
|
|
|
+ })
|
|
|
+ return kUid, kMid
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1130:降级 ADMIN→MEMBER 时 sys_user.tokenVersion 严格 +1(M-R15-1 方案 A)。
|
|
|
+// 断言 1:tv_after == tv_before + 1;
|
|
|
+// 断言 2:同事务落盘——若 UPDATE 成员成功但 tokenVersion 未增(或相反),就代表
|
|
|
+// IncrementTokenVersionWithTx 脱离了业务事务,必须立刻暴露。
|
|
|
+func TestUpdateMember_DemoteAdminToMember_BumpsTokenVersion(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
|
|
|
+ // 绕开 last-admin 拦截(不是本用例关心的)
|
|
|
+ seedSecondActiveAdmin(t, svcCtx, sp.code)
|
|
|
+
|
|
|
+ tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+
|
|
|
+ require.NoError(t,
|
|
|
+ NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId,
|
|
|
+ MemberType: strPtr(consts.MemberTypeMember),
|
|
|
+ }),
|
|
|
+ "降级 ADMIN→MEMBER 是合法路径,必须成功")
|
|
|
+
|
|
|
+ tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+ assert.Equal(t, tvBefore+1, tvAfter,
|
|
|
+ "M-R15-1:降级必须同事务递增 tokenVersion,让旧 access token 在下一次 middleware 校验时被 401 踢出")
|
|
|
+
|
|
|
+ m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, consts.MemberTypeMember, m.MemberType,
|
|
|
+ "MemberType 与 tokenVersion 必须同事务一起落盘,不得出现'增了 tv 但 member 没改'的脏态")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1131:禁用启用成员时(Status 1→2)sys_user.tokenVersion +1。
|
|
|
+// 此用例显式构造 MEMBER(而非 ADMIN),证明 "status 降权" 与 "type 降权" 是并集而非仅
|
|
|
+// ADMIN 才递增——冻结的普通 MEMBER 同样必须立即吊销 session。
|
|
|
+func TestUpdateMember_DisableEnabledMember_BumpsTokenVersion(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
|
|
|
+
|
|
|
+ tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+
|
|
|
+ require.NoError(t,
|
|
|
+ NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId,
|
|
|
+ Status: int64Ptr(consts.StatusDisabled),
|
|
|
+ }),
|
|
|
+ "禁用启用成员是合法路径,必须成功")
|
|
|
+
|
|
|
+ tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+ assert.Equal(t, tvBefore+1, tvAfter,
|
|
|
+ "M-R15-1:statusRevoked(Enabled→Disabled)必须触发 tokenVersion 递增;"+
|
|
|
+ "否则被冻结成员仍可持旧 token 直到 UD 缓存 TTL(5min)过期")
|
|
|
+
|
|
|
+ m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, int64(consts.StatusDisabled), m.Status)
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1132:降级 DEVELOPER→MEMBER 时同样 +1(DEVELOPER 也算 privileged type)。
|
|
|
+func TestUpdateMember_DemoteDeveloperToMember_BumpsTokenVersion(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
|
|
|
+
|
|
|
+ tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+
|
|
|
+ require.NoError(t,
|
|
|
+ NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId,
|
|
|
+ MemberType: strPtr(consts.MemberTypeMember),
|
|
|
+ }),
|
|
|
+ "DEVELOPER→MEMBER 是合法路径")
|
|
|
+
|
|
|
+ tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+ assert.Equal(t, tvBefore+1, tvAfter,
|
|
|
+ "wasPrivilegedType 判定必须覆盖 DEVELOPER,与 ADMIN 语义对称")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1133:升权 MEMBER→ADMIN 时 **不** 递增 tokenVersion。
|
|
|
+// 这是"会话吊销策略只在权限收窄时触发"这条契约的正向回归——
|
|
|
+// 若未来有人把 `if typeDowngraded || statusRevoked` 简化成无条件 Increment,
|
|
|
+// 本用例会立刻失败。
|
|
|
+func TestUpdateMember_PromoteMemberToAdmin_DoesNotBumpTokenVersion(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
|
|
|
+
|
|
|
+ tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+
|
|
|
+ require.NoError(t,
|
|
|
+ NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId,
|
|
|
+ MemberType: strPtr(consts.MemberTypeAdmin),
|
|
|
+ }),
|
|
|
+ "升权是合法路径")
|
|
|
+
|
|
|
+ tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+ assert.Equal(t, tvBefore, tvAfter,
|
|
|
+ "升权不构成对被管理方的实际损害:旧 token 的 UD 缓存里 memberType 仍是旧值,"+
|
|
|
+ "必须等 Loader.Del 生效或 TTL 到期才能'用上' ADMIN;"+
|
|
|
+ "这里贸然 +1 会给管理员误点一次'踢下线'的副作用")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1134:重启用(Disabled→Enabled)时 tokenVersion 不变。
|
|
|
+func TestUpdateMember_ReEnableDisabledMember_DoesNotBumpTokenVersion(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ sp := seedEnabledProductWithMemberAndTv(t, svcCtx, consts.MemberTypeMember, consts.StatusDisabled)
|
|
|
+
|
|
|
+ tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+
|
|
|
+ require.NoError(t,
|
|
|
+ NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId,
|
|
|
+ Status: int64Ptr(consts.StatusEnabled),
|
|
|
+ }),
|
|
|
+ "解冻是合法路径")
|
|
|
+
|
|
|
+ tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+ assert.Equal(t, tvBefore, tvAfter,
|
|
|
+ "解冻不需要吊销旧 session(用户早已因 Status=Disabled 无法通过中间件);"+
|
|
|
+ "这里递增反而会给合法重登录增加一次无谓的失败")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1135:降级事务失败(last-admin 400)时 tokenVersion 不得被污染。
|
|
|
+//
|
|
|
+// 关键契约:IncrementTokenVersionWithTx 必须在 UpdateWithTx 之前放,但两者都在同一个
|
|
|
+// TransactCtx 闭包里——last-admin 校验 return err → 整个事务 rollback → tokenVersion 也 rollback。
|
|
|
+// 若实现退化成"Increment 走独立事务 + UPDATE 走另一事务",这里就会飘红。
|
|
|
+func TestUpdateMember_DemoteLastAdminRejected_TokenVersionUnchanged(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
|
|
|
+
|
|
|
+ tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+
|
|
|
+ err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId, MemberType: strPtr(consts.MemberTypeMember),
|
|
|
+ })
|
|
|
+ require.Error(t, err, "唯一启用 ADMIN 必须被拒,否则产品将没有管理员")
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 400, ce.Code())
|
|
|
+
|
|
|
+ tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+ assert.Equal(t, tvBefore, tvAfter,
|
|
|
+ "事务 rollback 后 tokenVersion 必须保持初值——"+
|
|
|
+ "否则业务失败会把合法用户莫名踢下线,是 M-R15-1 方案 A 的反面教材")
|
|
|
+
|
|
|
+ m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Equal(t, consts.MemberTypeAdmin, m.MemberType,
|
|
|
+ "member 也不应被改动(整个事务 rollback)")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1136:no-op 更新(入参与现值一致)直接 return nil,不进入事务、tokenVersion 不变。
|
|
|
+// seedEnabledProductWithMember 已是 {Member, Status=1},再传同样的值就是 no-op。
|
|
|
+func TestUpdateMember_NoOpUpdate_DoesNotBumpTokenVersion(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
|
|
|
+
|
|
|
+ tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+
|
|
|
+ require.NoError(t,
|
|
|
+ NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId,
|
|
|
+ MemberType: strPtr(consts.MemberTypeMember),
|
|
|
+ Status: int64Ptr(consts.StatusEnabled),
|
|
|
+ }),
|
|
|
+ "no-op 必须 nil,不能被 ErrUpdateConflict 之类误报")
|
|
|
+
|
|
|
+ tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
|
|
|
+ assert.Equal(t, tvBefore, tvAfter,
|
|
|
+ "no-op 早退分支必须完全绕过事务,tokenVersion 任何微小变动都说明 Logic 仍然进事务了")
|
|
|
+}
|
|
|
+
|
|
|
+func sysUserCacheKeysForMember(id int64, username string) (string, string) {
|
|
|
+ prefix := testutil.GetTestCachePrefix()
|
|
|
+ return fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, id),
|
|
|
+ fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, username)
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1137:降级成功后 post-commit 必须失效 sysUser 低层的 id/username 两把缓存,
|
|
|
+// 否则 UD loader 下次 cache-miss 重建时从这两把 key 里拿到旧 tokenVersion,
|
|
|
+// 会把刚在 DB 里递增的值再次抹回(等价于 M-R15-1 回归)。
|
|
|
+func TestUpdateMember_DemoteInvalidatesSysUserCache(t *testing.T) {
|
|
|
+ ctx := ctxhelper.SuperAdminCtx()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+ rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
|
|
|
+
|
|
|
+ sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
|
|
|
+ seedSecondActiveAdmin(t, svcCtx, sp.code)
|
|
|
+
|
|
|
+ // 预热两把 sysUser 低层缓存
|
|
|
+ u0, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
|
|
|
+ require.NoError(t, err)
|
|
|
+ _, err = svcCtx.SysUserModel.FindOneByUsername(ctx, u0.Username)
|
|
|
+ require.NoError(t, err)
|
|
|
+ idKey, usernameKey := sysUserCacheKeysForMember(sp.uId, u0.Username)
|
|
|
+ beforeId, _ := rds.Get(idKey)
|
|
|
+ beforeUn, _ := rds.Get(usernameKey)
|
|
|
+ require.NotEmpty(t, beforeId, "前置条件:id-key 缓存已预热")
|
|
|
+ require.NotEmpty(t, beforeUn, "前置条件:username-key 缓存已预热")
|
|
|
+
|
|
|
+ require.NoError(t,
|
|
|
+ NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
|
|
|
+ Id: sp.mId,
|
|
|
+ MemberType: strPtr(consts.MemberTypeMember),
|
|
|
+ }))
|
|
|
+
|
|
|
+ // 契约:两把 key 必须被 InvalidateProfileCache 清理掉(post-commit 显式失效入口)
|
|
|
+ gotIdAfter, _ := rds.Get(idKey)
|
|
|
+ assert.Empty(t, gotIdAfter,
|
|
|
+ "L-R15-3:post-commit 必须失效 sysUser:id:%d,否则 UD loader cache-miss 重建会拿到旧 tokenVersion", sp.uId)
|
|
|
+ gotUnAfter, _ := rds.Get(usernameKey)
|
|
|
+ assert.Empty(t, gotUnAfter,
|
|
|
+ "L-R15-3:sysUser:username:%s 同样必须被失效", u0.Username)
|
|
|
+
|
|
|
+ // 验证下一次 FindOne 真的读到 DB 中递增后的 tokenVersion(而非 stale cache)
|
|
|
+ uNow, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.Greater(t, uNow.TokenVersion, u0.TokenVersion,
|
|
|
+ "缓存失效后 FindOne 必须回源 DB 并读到已递增的 tokenVersion")
|
|
|
+}
|