| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737 |
- 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"
- 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"
- "testing"
- "time"
- )
- func TestUpdateMember_Normal(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- now := time.Now().Unix()
- uid := testutil.UniqueId()
- pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
- Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- pId, _ := pRes.LastInsertId()
- uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
- 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: uid, UserId: uId, MemberType: "MEMBER",
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- mId, _ := mRes.LastInsertId()
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
- testutil.CleanTable(ctx, conn, "`sys_user`", uId)
- testutil.CleanTable(ctx, conn, "`sys_product`", pId)
- })
- logic := NewUpdateMemberLogic(ctx, svcCtx)
- mt := "ADMIN"
- st := int64(2)
- err = logic.UpdateMember(&types.UpdateMemberReq{
- Id: mId,
- MemberType: &mt,
- Status: &st,
- })
- require.NoError(t, err)
- updated, err := svcCtx.SysProductMemberModel.FindOne(ctx, mId)
- require.NoError(t, err)
- assert.Equal(t, "ADMIN", updated.MemberType)
- assert.Equal(t, int64(2), updated.Status)
- }
- // TC-0221: 无效MemberType
- func TestUpdateMember_InvalidMemberType(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- now := time.Now().Unix()
- uid := testutil.UniqueId()
- pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
- Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- pId, _ := pRes.LastInsertId()
- uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
- 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: uid, UserId: uId, MemberType: "MEMBER",
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- mId, _ := mRes.LastInsertId()
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
- testutil.CleanTable(ctx, conn, "`sys_user`", uId)
- testutil.CleanTable(ctx, conn, "`sys_product`", pId)
- })
- logic := NewUpdateMemberLogic(ctx, svcCtx)
- mt := "INVALID"
- err = logic.UpdateMember(&types.UpdateMemberReq{
- Id: mId,
- MemberType: &mt,
- })
- require.Error(t, err)
- ce, ok := err.(*response.CodeError)
- require.True(t, ok)
- assert.Equal(t, 400, ce.Code())
- assert.Equal(t, "无效的成员类型", ce.Error())
- }
- // TC-0220: 不存在
- func TestUpdateMember_NotFound(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- logic := NewUpdateMemberLogic(ctx, svcCtx)
- mt := "ADMIN"
- err := logic.UpdateMember(&types.UpdateMemberReq{
- Id: 999999999,
- MemberType: &mt,
- })
- require.Error(t, err)
- ce, ok := err.(*response.CodeError)
- require.True(t, ok)
- assert.Equal(t, 404, ce.Code())
- assert.Equal(t, "成员不存在", ce.Error())
- }
- func int64Ptr(v int64) *int64 { return &v }
- // TC-1056: 两字段同时 nil,必须 400,且不得做任何 DB 读写
- func TestUpdateMember_BothFieldsNil_RejectedWith400(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
- t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
- err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: sp.mId,
- })
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce),
- "两字段全 nil 必须命中 response.ErrBadRequest,且以 *CodeError 传递")
- assert.Equal(t, 400, ce.Code(),
- "两字段全 nil 必须 400,不得退化成 200 no-op 或 500")
- assert.Contains(t, ce.Error(), "至少提供一个",
- "错误消息应明确提示至少要传 memberType 或 status 之一,便于接入方排查空请求体")
- got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- assert.Equal(t, consts.MemberTypeMember, got.MemberType,
- "Logic 提前 400,任何 DB 落库都是回归;MemberType 必须保留原值")
- assert.Equal(t, int64(consts.StatusEnabled), got.Status,
- "Logic 提前 400 时 Status 也必须保留原值")
- }
- // TC-1057: 仅改 Status,MemberType 字段必须按原值保留;绝不回归成 ""
- func TestUpdateMember_OnlyStatus_PreservesMemberType(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
- t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
- require.NoError(t,
- NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: sp.mId,
- Status: int64Ptr(consts.StatusDisabled),
- }),
- "只改 Status 必须成功")
- got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- assert.Equal(t, int64(consts.StatusDisabled), got.Status,
- "Status 应按入参更新到禁用")
- assert.Equal(t, consts.MemberTypeMember, got.MemberType,
- "防线:旧实现若以空串当缺省值会把 memberType 改写成 '',"+
- "权限侧会当这行为非法成员吊销其全部权限,必须杜绝")
- }
- // TC-1058: 仅改 MemberType,Status 原值保留
- func TestUpdateMember_OnlyMemberType_PreservesStatus(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
- t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
- require.NoError(t,
- NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: sp.mId,
- MemberType: strPtr(consts.MemberTypeAdmin),
- }),
- "只改 MemberType 必须成功")
- got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- assert.Equal(t, consts.MemberTypeAdmin, got.MemberType)
- assert.Equal(t, int64(consts.StatusEnabled), got.Status,
- "Status 未提供时必须保留原值;若回归成 0 会让该成员瞬间冻结")
- }
- // TC-1059: DEVELOPER 成员仅改 Status 冻结,不得误触 CheckMemberTypeAssignment
- // 语义上 CheckMemberTypeAssignment 拦的是"用普通 admin 指派/变更 DEVELOPER"的动作;仅冻结
- // 一个已存在的 DEVELOPER 不属于"指派 DEVELOPER",必须放行。否则普通 admin 将永远无法管理
- // DEVELOPER 的启停,只能靠超管——属于管理面瘫痪。
- func TestUpdateMember_DeveloperStatusOnly_BypassesAssignmentCheck(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
- t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
- require.NoError(t,
- NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: sp.mId,
- Status: int64Ptr(consts.StatusDisabled),
- }),
- "DEVELOPER 只冻结 (Status=2) 时必须跳过 CheckMemberTypeAssignment;"+
- "修复前的 `if memberType == DEVELOPER` 早期校验会把这条合法请求拒成 403")
- got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- assert.Equal(t, consts.MemberTypeDeveloper, got.MemberType,
- "MemberType 在未传时必须保留 DEVELOPER")
- assert.Equal(t, int64(consts.StatusDisabled), got.Status)
- }
- // TC-1060: 无效 Status(非 1/2)必须 400,不得被"只传 Status"分支绕过校验
- func TestUpdateMember_InvalidStatusValue_Rejected(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
- t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
- for _, bad := range []int64{0, 3, -1, 999} {
- err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: sp.mId,
- Status: int64Ptr(bad),
- })
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce),
- "无效 Status 必须 *CodeError")
- assert.Equal(t, 400, ce.Code(),
- "Status=%d 必须 400 被拒,严禁靠 DB CHECK 或下游枚举触发 500", bad)
- }
- got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- assert.Equal(t, int64(consts.StatusEnabled), got.Status, "非法 Status 全部被拒,原值必须不变")
- }
- // TC-1061: 值与现状完全一致(no-op)不报错,且**不触发** DB 事务/缓存失效
- // 这里只能通过"不返回 error"以及"DB 值稳定 + updateTime 不前进"的软信号来近似验证;
- // 契约上:nextType == member.MemberType && nextStatus == member.Status 时 Logic 早退。
- func TestUpdateMember_NoOpUpdate_ReturnsNil(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
- t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
- before, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- require.NoError(t,
- NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: sp.mId,
- MemberType: strPtr(before.MemberType),
- Status: int64Ptr(before.Status),
- }),
- "no-op update 必须 nil,不得被 ErrUpdateConflict 之类误报")
- after, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- assert.Equal(t, before.MemberType, after.MemberType)
- assert.Equal(t, before.Status, after.Status)
- assert.Equal(t, before.UpdateTime, after.UpdateTime,
- "Logic 在 no-op 时应直接 return nil,不进事务,"+
- "updateTime 不应被推进;推进即说明 Logic 仍然走了一次冗余 UPDATE")
- }
- // TC-0725: 修复:不能将最后一个 ADMIN 降级为 MEMBER
- func TestUpdateMember_DemoteLastAdminRejected(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- sp := seedEnabledProductWithMember(t, svcCtx, "ADMIN")
- t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
- err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: sp.mId, MemberType: strPtr("MEMBER"),
- })
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- assert.Equal(t, 400, ce.Code())
- assert.Contains(t, ce.Error(), "最后一个管理员")
- m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
- require.NoError(t, err)
- assert.Equal(t, "ADMIN", m.MemberType, "MemberType 不应被改动")
- }
- // TC-0726: 修复:有多个 ADMIN 时可以降级其中一个
- func TestUpdateMember_DemoteAdmin_WhenMultiple_Allowed(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- 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()
- var uIds, mIds []int64
- for i := 0; i < 2; i++ {
- uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"),
- Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- uId, _ := uRes.LastInsertId()
- uIds = append(uIds, uId)
- mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
- ProductCode: code, UserId: uId, MemberType: "ADMIN",
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- mId, _ := mRes.LastInsertId()
- mIds = append(mIds, mId)
- }
- t.Cleanup(func() {
- testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
- testutil.CleanTable(ctx, conn, "`sys_user`", uIds...)
- testutil.CleanTable(ctx, conn, "`sys_product`", pId)
- })
- err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: mIds[0], MemberType: strPtr("MEMBER"),
- })
- require.NoError(t, err)
- m, err := svcCtx.SysProductMemberModel.FindOne(ctx, mIds[0])
- require.NoError(t, err)
- assert.Equal(t, "MEMBER", m.MemberType)
- }
- // TC-0727: 修复:禁用状态的 ADMIN 不计入 active admin 计数,导致剩余 0 个启用 ADMIN 时仍拒绝降级
- // 说明:CountActiveAdmins 只统计 status=1 的 ADMIN;即便 DB 里有 2 个 ADMIN,但仅 1 个启用,
- // 降级这个唯一启用的 ADMIN 仍应被拒绝。
- func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- 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()
- var uIds, mIds []int64
- statuses := []int64{1, 2} // 一个启用,一个禁用
- for _, st := range statuses {
- uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"),
- Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- uId, _ := uRes.LastInsertId()
- uIds = append(uIds, uId)
- mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
- ProductCode: code, UserId: uId, MemberType: "ADMIN",
- Status: st, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- mId, _ := mRes.LastInsertId()
- mIds = append(mIds, mId)
- }
- t.Cleanup(func() {
- testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
- testutil.CleanTable(ctx, conn, "`sys_user`", uIds...)
- testutil.CleanTable(ctx, conn, "`sys_product`", pId)
- })
- // 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝
- err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
- Id: mIds[0], MemberType: strPtr("DEVELOPER"),
- })
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- 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")
- }
|