package user import ( "context" "errors" "testing" "time" permModel "perms-system-server/internal/model/perm" "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" ) // --------------------------------------------------------------------------- // 审计 L-4(第 8 轮)—— SetUserPerms 事务末 COUNT(*) 复核 sys_perm.status=1, // 把"FindByIds 通过 → 事务外某次 SyncPermissions 先把 permId 置为 DISABLED → // BatchInsertWithTx 把脏行写进 sys_user_perm"的 TOCTOU 窗口收紧。 // // 测试思路:把 SysPermModel 用一个薄装饰器替换,让 FindByIds 说谎(返回 Enabled), // 但实际上 DB 里这批 permId 其实是 Disabled。这样: // - 前置 FindByIds 校验通过; // - 进入 TransactCtx,BatchInsertWithTx 成功; // - 事务末 COUNT(*) WHERE status=1 的真实 DB 读返回 0 ≠ 1 → 回滚,返回 409; // - sys_user_perm 必须一行脏数据都不剩。 // // 如果 L-4 的复核被移除(或误改成 status != 0),COUNT 返回会≠0,脏行会被落盘, // 此测试自动失败。 // --------------------------------------------------------------------------- // lyingSysPermModel 只重写 FindByIds:不管 DB 里 status 是什么,都声称是 Enabled。 // 这是唯一一个能稳定模拟"前置 FindByIds → tx 内真实 status" 时序差的办法。 type lyingSysPermModel struct { permModel.SysPermModel lyingProductCode string } func (m *lyingSysPermModel) FindByIds(ctx context.Context, ids []int64) ([]*permModel.SysPerm, error) { real, err := m.SysPermModel.FindByIds(ctx, ids) if err != nil { return nil, err } for _, p := range real { p.ProductCode = m.lyingProductCode p.Status = 1 } return real, nil } // TC-0988: TOCTOU 复核 —— 前置检查通过但实际 Disabled,事务末 COUNT 必须触发 409 回滚。 func TestSetUserPerms_L4_TOCTOU_CountMismatch_RollsBackWith409(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) // 直接在 DB 里塞一个 status=Disabled 的 perm,模拟 SyncPermissions 已经提交 // 把这个 perm 落盘为 Disabled 的状态。 now := time.Now().Unix() res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: "test_product", Name: "l4_disabled_" + testutil.UniqueId(), Code: "l4_dis_" + testutil.UniqueId(), Status: 2, // Disabled CreateTime: now, UpdateTime: now, }) require.NoError(t, err) disabledPermId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_perm`", disabledPermId) }) // 装饰 SysPermModel:让 FindByIds 撒谎(Status=1, productCode=test_product)。 svcCtx.SysPermModel = &lyingSysPermModel{ SysPermModel: svcCtx.SysPermModel, lyingProductCode: "test_product", } err = NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{ UserId: userId, Perms: []types.UserPermItem{{PermId: disabledPermId, Effect: "ALLOW"}}, }) require.Error(t, err, "L-4:前置通过但 DB 实际 Disabled 时,事务末 COUNT 必须触发 409") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 409, ce.Code(), "L-4:TOCTOU 复核必须返回 409 Conflict;若仍是 200/4xx 说明复核 COUNT 被移除,"+ "脏 user_perm 会被真实落盘") assert.Contains(t, ce.Error(), "已被禁用", "L-4:错误文案必须明示'部分权限在提交时已被禁用',供前端判定是否重试") // 最关键的断言:脏行必须不可能落盘。 leftover := findUserPerms(t, ctx, userId) assert.Empty(t, leftover, "L-4:事务必须回滚;如果发现 sys_user_perm 有脏行,说明 COUNT 复核失效或"+ "事务隔离性被破坏,loadPerms 的 status=1 过滤能兜底但会绕开审计链") } // TC-0989: 正向基线 —— 所有 perm 真实 Enabled 时,不得被 L-4 复核误杀。 // 这条显式"不回滚"的断言防止未来有人把 COUNT 改成 "!=" 逻辑或把阈值改错。 func TestSetUserPerms_L4_AllEnabled_CountPasses(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) p1 := insertTestPerm(t, svcCtx, "test_product") p2 := insertTestPerm(t, svcCtx, "test_product") t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_perm`", p1, p2) }) err := NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{ UserId: userId, Perms: []types.UserPermItem{ {PermId: p1, Effect: "ALLOW"}, {PermId: p2, Effect: "DENY"}, }, }) require.NoError(t, err, "L-4 复核不得误杀正常写入;一旦误报会把正常管理操作变 409") rows := findUserPerms(t, ctx, userId) assert.Len(t, rows, 2, "两条 user_perm 必须落盘") }