package user import ( "context" "database/sql" "errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "math" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" deptModel "perms-system-server/internal/model/dept" 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/testutil/mocks" "perms-system-server/internal/types" "strings" "sync" "testing" "time" "github.com/zeromicro/go-zero/core/stores/sqlx" "go.uber.org/mock/gomock" ) func insertTestUser(t *testing.T, ctx context.Context, username, password string) int64 { t.Helper() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) now := time.Now().Unix() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: password, Nickname: "test", Avatar: sql.NullString{}, Email: username + "@test.com", Phone: "13800000000", Remark: "", DeptId: 0, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } func insertTestUserFull(t *testing.T, ctx context.Context, u *userModel.SysUser) int64 { t.Helper() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) now := time.Now().Unix() if u.CreateTime == 0 { u.CreateTime = now } if u.UpdateTime == 0 { u.UpdateTime = now } res, err := svcCtx.SysUserModel.Insert(ctx, u) require.NoError(t, err) id, _ := res.LastInsertId() return id } func strPtr(s string) *string { return &s } func int64Ptr(i int64) *int64 { return &i } // TC-0134: 正常创建 func TestCreateUser_Success(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() logic := NewCreateUserLogic(ctx, svcCtx) resp, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Nickname: "测试用户", Email: username + "@test.com", Phone: "13800138000", Remark: "集成测试", DeptId: 0, }) require.NoError(t, err) require.NotNil(t, resp) assert.Greater(t, resp.Id, int64(0)) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, username, user.Username) assert.Equal(t, "测试用户", user.Nickname) assert.Equal(t, int64(1), user.Status) assert.Equal(t, int64(2), user.IsSuperAdmin) } // TC-0135: 用户名已存在(预检) func TestCreateUser_UsernameExists(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() hashed := testutil.HashPassword("pass123") userId := insertTestUser(t, ctx, username, hashed) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass456789", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 409, codeErr.Code()) assert.Equal(t, "用户名已存在", codeErr.Error()) } // TC-0137: 非法email格式 func TestCreateUser_InvalidEmail(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "Pass123456", Email: "not-an-email", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "邮箱格式不正确", codeErr.Error()) } // TC-0138: 合法email func TestCreateUser_ValidEmail(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() logic := NewCreateUserLogic(ctx, svcCtx) resp, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Email: username + "@example.com", }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, username+"@example.com", user.Email) } // TC-0139: email为空(可选) func TestCreateUser_EmptyEmailSkipsValidation(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() logic := NewCreateUserLogic(ctx, svcCtx) resp, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Email: "", }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, "", user.Email) } // TC-0140: 非法phone格式 func TestCreateUser_InvalidPhone(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "Pass123456", Phone: "abc", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "手机号格式不正确", codeErr.Error()) } // TC-0141: 合法phone(国际) func TestCreateUser_ValidPhone(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() logic := NewCreateUserLogic(ctx, svcCtx) resp, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Phone: "13900139000", }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, "13900139000", user.Phone) } // TC-0142: phone为空(可选) func TestCreateUser_EmptyPhoneSkipsValidation(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() logic := NewCreateUserLogic(ctx, svcCtx) resp, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Phone: "", }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, "", user.Phone) } // TC-0143: 并发同username(TOCTOU) func TestCreateUser_ConcurrentSameUsername(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", username) }) var wg sync.WaitGroup results := make(chan error, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Nickname: "并发测试用户", }) results <- err }() } wg.Wait() close(results) var errs []error for err := range results { errs = append(errs, err) } require.Len(t, errs, 2) successCount := 0 failCount := 0 for _, err := range errs { if err == nil { successCount++ } else { failCount++ var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr), "error should be CodeError, got: %v", err) assert.Equal(t, 409, codeErr.Code()) assert.Equal(t, "用户名已存在", codeErr.Error()) } } assert.Equal(t, 1, successCount) assert.Equal(t, 1, failCount) } // TC-0141: 合法phone(国际) func TestCreateUser_ValidInternationalPhone(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() logic := NewCreateUserLogic(ctx, svcCtx) resp, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Phone: "+8613800138000", }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, "+8613800138000", user.Phone) } // TC-0145: 密码少于8字符 func TestCreateUser_PasswordTooShort(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "Pas1234", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error()) } // TC-0146: 密码缺少大写字母 func TestCreateUser_PasswordNoUppercase(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) longPwd := "A" + strings.Repeat("a", 71) + "1" _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: longPwd, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码长度不能超过72个字符", codeErr.Error()) } // TC-0147: 密码缺少小写字母 func TestCreateUser_PasswordNoLowercase(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "PASS123456", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码必须包含大写字母、小写字母和数字", codeErr.Error()) } // TC-0148: 密码缺少数字 func TestCreateUser_PasswordNoDigit(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "Passpasspass", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码必须包含大写字母、小写字母和数字", codeErr.Error()) } // TC-0149: 密码超过72字符 func TestCreateUser_PasswordTooLong(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) longPwd := strings.Repeat("a", 73) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: longPwd, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码长度不能超过72个字符", codeErr.Error()) } // TC-0537: createUser非管理员拒绝 func TestCreateUser_MemberRejected(t *testing.T) { ctx := ctxhelper.MemberCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{Username: "test", Password: "Pass123456"}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } // TC-0150: 用户名含特殊字符被拒绝 func TestCreateUser_UsernameInvalidChars(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: "user@name!", Password: "Pass123456", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error()) } // TC-0151: 用户名太短(1字符)被拒绝 func TestCreateUser_UsernameTooShort(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: "a", Password: "Pass123456", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error()) } // TC-0152: 用户名太长(65字符)被拒绝 func TestCreateUser_UsernameTooLong(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: strings.Repeat("a", 65), Password: "Pass123456", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error()) } // TC-0153: 部门不存在被拒绝 func TestCreateUser_DeptNotExists(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "Pass123456", DeptId: 999999999, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) // CreateUser 已把部门存在性校验从"事务外 cached FindOne"下沉到"事务内 FindOneForShareTx // + S 锁"(H-R17-1),不存在的 deptId 在 FOR SHARE 读路径上会返 sqlx.ErrNotFound, // 统一落到 "部门不存在或已删除" 的文案,以覆盖"并发 DeleteDept 胜出"场景。 assert.Equal(t, "部门不存在或已删除", codeErr.Error()) } // TC-0154: 昵称超过64字符被拒绝 func TestCreateUser_NicknameTooLong(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "Pass123456", Nickname: strings.Repeat("n", 65), }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "昵称长度不能超过64个字符", codeErr.Error()) } // TC-0155: 备注超过255字符被拒绝 func TestCreateUser_RemarkTooLong(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewCreateUserLogic(ctx, svcCtx) _, err := logic.CreateUser(&types.CreateUserReq{ Username: testutil.UniqueId(), Password: "Pass123456", Remark: strings.Repeat("r", 256), }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "备注长度不能超过255个字符", codeErr.Error()) } // TC-0136: 带完整可选字段 func TestCreateUser_AllOptionalFields(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() deptRes, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: 0, Name: "tc0103_dept_" + testutil.UniqueId(), Path: "/", Sort: 1, DeptType: "NORMAL", Remark: "", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) deptId, err := deptRes.LastInsertId() require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) username := testutil.UniqueId() logic := NewCreateUserLogic(ctx, svcCtx) resp, err := logic.CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", Nickname: "全字段用户", Email: username + "@example.com", Phone: "13900001111", Remark: "TC-0136完整字段", DeptId: deptId, }) require.NoError(t, err) require.NotNil(t, resp) assert.Greater(t, resp.Id, int64(0)) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, username, user.Username) assert.Equal(t, "全字段用户", user.Nickname) assert.Equal(t, username+"@example.com", user.Email) assert.Equal(t, "13900001111", user.Phone) assert.Equal(t, "TC-0136完整字段", user.Remark) assert.Equal(t, deptId, user.DeptId) assert.Equal(t, int64(1), user.Status) assert.Equal(t, int64(2), user.IsSuperAdmin) } func callerAdminCtx(callerUserId, deptId int64, deptPath string) context.Context { return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: callerUserId, Username: "prod_admin_caller", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: deptId, DeptPath: deptPath, MinPermsLevel: math.MaxInt64, }) } // TC-0994: 产品 ADMIN 为非自己管辖部门创建用户必须 403。 func TestCreateUser_MN4_AdminCannotCreateOutsideDeptSubtree(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_caller", "/100/") outsideDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_outside", "/999/") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, outsideDeptId) }) adminCtx := callerAdminCtx(777771, callerDeptId, "/100/") _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{ Username: "mn4_seed_" + testutil.UniqueId(), Password: "Pass123456", DeptId: outsideDeptId, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "产品 ADMIN 跨部门树创建用户必须 403,防止占用关键用户名等 AddMember 合谋挂进产品") assert.Contains(t, ce.Error(), "无权在非自己管辖的部门下创建用户") } // TC-0995: 正向 —— 产品 ADMIN 在自己子树下的部门创建用户放行。 func TestCreateUser_MN4_AdminCanCreateInsideDeptSubtree(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_ok_caller", "/200/") childDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_ok_child", "/200/1/") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, childDeptId) }) adminCtx := callerAdminCtx(777772, callerDeptId, "/200/") username := "mn4ok_" + testutil.UniqueId() resp, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", DeptId: childDeptId, }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", resp.Id) }) user, err := svcCtx.SysUserModel.FindOne(bootstrap, resp.Id) require.NoError(t, err) assert.Equal(t, username, user.Username) assert.Equal(t, childDeptId, user.DeptId, "用户必须真实落在指定子部门") } // TC-0996: SuperAdmin 可跨一切部门(含 DeptId=0),继续允许创建系统级账号。 func TestCreateUser_MN4_SuperAdminCanCreateAnywhere(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() randomDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_sa", "/1000/") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", randomDeptId) }) // A) 超管创建在任意部门 usernameDept := "mn4sa_dept_" + testutil.UniqueId() resp, err := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{ Username: usernameDept, Password: "Pass123456", DeptId: randomDeptId, }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", resp.Id) }) // B) 超管创建 DeptId=0 的系统级账号(历史跨组织账号语义保留) usernameZero := "mn4sa_zero_" + testutil.UniqueId() respZero, err := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{ Username: usernameZero, Password: "Pass123456", DeptId: 0, }) require.NoError(t, err) require.NotNil(t, respZero) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", respZero.Id) }) } // TC-0997: 非超管 caller 的 DeptPath 为空时必须 403,不得在部门树外开口。 func TestCreateUser_MN4_EmptyCallerDeptPathRejected(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_empty", "/500/") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", dstDeptId) }) // caller 是产品 ADMIN 但历史账号 DeptPath=="" —— 必须 403 "未归属任何部门" adminCtx := callerAdminCtx(777773, 0, "") _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{ Username: "mn4empty_" + testutil.UniqueId(), Password: "Pass123456", DeptId: dstDeptId, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "caller.DeptPath=='' 属于 legacy 账号,fail-close 不允许创建用户") assert.Contains(t, ce.Error(), "您未归属任何部门") } // TC-0998: 非超管 caller 传 DeptId=0 必须 400(禁止非超管创建"无部门"账号)。 func TestCreateUser_MN4_NonSuperAdminMustSpecifyDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_mustspec", "/300/") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId) }) adminCtx := callerAdminCtx(777774, callerDeptId, "/300/") _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{ Username: "mn4must_" + testutil.UniqueId(), Password: "Pass123456", DeptId: 0, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "非超管 CreateUser 必须显式指定部门,禁止在部门树外创建账号") assert.Contains(t, ce.Error(), "必须指定部门") } // TC-0999: 目标部门已停用时 CreateUser 必须 400 "目标部门已停用",与 UpdateDept 闭环。 func TestCreateUser_LN2_TargetDeptDisabled(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() disRes, err := svcCtx.SysDeptModel.Insert(bootstrap, &deptModel.SysDept{ ParentId: 0, Name: "ln2_dis_" + testutil.UniqueId(), Path: "/900/", Sort: 0, DeptType: "NORMAL", Remark: "", Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) disabledId, _ := disRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", disabledId) }) // 超管也必须被拒绝: 的规则针对"所有调用方",防止 disabled 部门被意外重新填人 _, err = NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{ Username: "ln2_" + testutil.UniqueId(), Password: "Pass123456", DeptId: disabledId, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Equal(t, "目标部门已停用", ce.Error(), "目标部门 status!=Enabled 必须拒绝,与 UpdateDept 禁用语义闭环") } // TC-1100: DeptId 负值(-1 / MinInt64)在所有类型 caller 面前都必须被 400 拒绝, // 禁止构造 "sys_user.deptId = 负数" 的僵尸账号(FindOne 永远 NotFound,部门树永远查不到)。 func TestCreateUser_NegativeDeptIdRejected(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cases := []struct { name string ctx context.Context deptId int64 wantMsg string }{ {"super_admin_negative_one", ctxhelper.SuperAdminCtx(), -1, "部门ID必须为非负整数"}, {"super_admin_min_int64", ctxhelper.SuperAdminCtx(), math.MinInt64, "部门ID必须为非负整数"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { _, err := NewCreateUserLogic(tc.ctx, svcCtx).CreateUser(&types.CreateUserReq{ Username: "neg_dept_" + testutil.UniqueId(), Password: "Pass123456", DeptId: tc.deptId, }) require.Error(t, err, "负值 DeptId 必须被拒绝") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "必须 400 而不是 5xx / 404:让调用方知道是入参不合法,不是 DB/权限问题") assert.Equal(t, tc.wantMsg, ce.Error()) }) } } func TestCreateUser_DefaultsMustChangePasswordToYes(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := "lcp_" + testutil.UniqueId() resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{ Username: username, Password: "InitPass@123", Nickname: "初始口令校验", }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, int64(consts.MustChangePasswordYes), u.MustChangePassword, "管理员代填初始密码的用户必须被强制下次登录改密,落盘为 Yes") } // TC-1122: H-R14-1 —— 非超管调用方在 CreateUser 中指定 DEV 部门必须 403。 // // 与 UpdateUser 的 TC-1124 对偶:CreateUser 侧通过"预埋 admin_* 键位账号"也能构成跨产品 // 升级链路(若 ADMIN 能在 DEV 部门直接创建账号,新账号一旦被 AddMember 进别的产品,立刻 // 在新产品内全权),因此收敛口径必须与 UpdateUser 完全一致。 // // 注意:这里 DeptPath 特意让 adminPath 恰好是 devDept 的祖先,排除"被 DeptPath 前缀校验 // 顺手拦掉"的干扰——本用例必须靠 DeptType==DEV 这条显式护栏拦住,才能证明独立生效。 func TestCreateUser_H_R14_1_AdminCannotCreateInDevDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_admin", "/9100/", consts.DeptTypeNormal) devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_dev", "/9100/1/", consts.DeptTypeDev) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, devDeptId) }) adminCtx := callerAdminCtx(888881, adminDeptId, "/9100/") _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{ Username: "h_r14_1_c_" + testutil.UniqueId(), Password: "Pass123456", DeptId: devDeptId, }) require.Error(t, err, "ADMIN 在 DEV 部门创建用户必须被拒绝") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "H-R14-1 创建侧必须 403,防止'先创建后挂载'的跨产品权限升级链路") assert.Contains(t, ce.Error(), "仅超级管理员可将用户调入研发部门", "错误文案须与 UpdateUser 一致,方便 SRE/审计从单一关键字聚合所有 DEV 护栏告警") // 关键:403 发生在 Insert 之前,sys_user 表不得被污染。由于入参 username 每次 UniqueId, // 这里不额外查 DB 来验证——断言 403 + req.Username 随机 + t.Cleanup 里也没 LastInsertId // 可删,就隐式证明了"零 DB 副作用"。 } // TC-1123: H-R14-1 对偶正向 —— SuperAdmin 在 DEV 部门下创建用户必须成功。 // 确保护栏只拦非超管;不得误伤 SuperAdmin 的合法运维动作。 func TestCreateUser_H_R14_1_SuperAdminCanCreateInDevDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_su_dev", "/9200/", consts.DeptTypeDev) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId) }) superCtx := ctxhelper.SuperAdminCtx() username := "h_r14_1_c_su_" + testutil.UniqueId() resp, err := NewCreateUserLogic(superCtx, svcCtx).CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", DeptId: devDeptId, }) require.NoError(t, err, "SuperAdmin 在 DEV 部门创建用户必须允许") require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", resp.Id) }) u, err := svcCtx.SysUserModel.FindOne(bootstrap, resp.Id) require.NoError(t, err) assert.Equal(t, devDeptId, u.DeptId, "SuperAdmin 路径必须真的把 DeptId 写到 DEV,证明护栏未误伤 SuperAdmin 的合法运维链路") } // TC-1192: L-R17-1 —— 非超管不得以保留前缀创建用户名。 // // 场景:产品 ADMIN 预知"下一批上线的产品 code = acme",试图提前以 `admin_acme` 这个用户名 // 抢注(沿着 UNIQUE(username) 锁住槽位)。若不拦,超管将来 CreateProduct(code=acme) 时 // auto-provision 的 admin_acme 会撞 1062,compensateCreatedRows 把产品 / 角色 / 权限一并 // 回滚,造成"新产品一直上线不了"的业务 DoS。 // // 对每个保留前缀都跑一次小子例:`admin_` / `svc_` / `root_` / `sys_`;大小写变体用 `Admin_` // 也必须被 `strings.ToLower` 规约后拦截,避免混入 SMTP/SQL 过滤视为"只查小写"的惯性漏洞。 func TestCreateUser_L_R17_1_ReservedUsernamePrefix_NonSuperAdmin(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r17_1_caller", "/1700/") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId) }) adminCtx := callerAdminCtx(1771701, callerDeptId, "/1700/") cases := []struct { name string username string wantPref string }{ {"admin_prefix", "admin_acme", "admin_"}, {"svc_prefix", "svc_bot", "svc_"}, {"root_prefix", "root_ops", "root_"}, {"sys_prefix", "sys_health", "sys_"}, {"mixed_case_Admin", "Admin_acme", "admin_"}, {"mixed_case_SVC", "SVC_Ingest", "svc_"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{ Username: tc.username, Password: "Pass123456", DeptId: callerDeptId, }) require.Error(t, err, "非超管以保留前缀创建用户必须被拒绝") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "必须 400 而不是 409 —— 不暴露'该用户名是否存在',也不引导攻击者重试其他空位") assert.Contains(t, ce.Error(), tc.wantPref, "错误文案必须点名具体前缀,便于调用方立刻知道该改哪个前缀") assert.Contains(t, ce.Error(), "系统账号保留", "统一关键字,告警 / 审计可按单一关键字聚合此类事件") }) } } // TC-1193: L-R17-1 正向回归 —— SuperAdmin 可以以保留前缀创建用户。 // // 防回归:修复方案如果误把 SuperAdmin 也拦住,会让运维路径无法显式建 svc_ / root_ 账号, // 退化到"用 auto-provision 顺带带出"的隐式路径;超管豁免必须留有出口。 func TestCreateUser_L_R17_1_ReservedUsernamePrefix_SuperAdminAllowed(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := "svc_" + testutil.UniqueId() resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", DeptId: 0, }) require.NoError(t, err, "SuperAdmin 不受保留前缀约束") require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, username, u.Username) } // TC-1194: L-R17-2 —— 成功创建后 sys_user.Avatar 显式落 NULL。 // // logic 在 `SysUser{...}` 字面量里必须显式 `Avatar: sql.NullString{Valid: false}`。 // 行为上 Go 结构体零值也是 `{Valid:false, String:""}`——它确实写 NULL;但显式写出来的 // 实际意义是"未来如有人把 SysUser.Avatar 改成 string 类型,该字面量立刻编译失败, // 避免零值依赖在字段迁移时静默漂移到落 `''` 空串,与历史 NULL 行并存产生脏数据"。 func TestCreateUser_L_R17_2_AvatarExplicitNull(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := "l_r17_2_" + testutil.UniqueId() resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{ Username: username, Password: "Pass123456", DeptId: 0, }) require.NoError(t, err) require.NotNil(t, resp) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) }) u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.False(t, u.Avatar.Valid, "CreateUser 未指定 Avatar 时必须落 NULL(Valid=false),不得落空字符串(Valid=true, String=\"\")") assert.Equal(t, "", u.Avatar.String, "一致性断言:Valid=false 时 String 字段必须保持零值空串") } // TC-1195: H-R17-1 —— CreateUser 必须把 sys_dept S 锁读与 Insert 封在同一事务里。 // // 用 mock 驱动,观察以下调用契约: // 1. SysUserModel.TransactCtx 被调用且其闭包被执行; // 2. 闭包内对目标 deptId 调用了 SysDeptModel.FindOneForShareTx(而不是非锁读 FindOne); // 3. 闭包内对 sys_user 走 InsertWithTx(而不是无事务 Insert); // 4. 三者发生在同一 tx 闭包内。 // // 若实现回退为"tx 外 FindOne + tx 外 Insert",FindOneForShareTx 不会被打到 → gomock // 的 "expected call" 未满足导致 FAIL;反之,如果 Insert 漏掉 WithTx,本用例的 InsertWithTx // EXPECT 也会报 "got 0 calls"。 func TestCreateUser_H_R17_1_InsertRunsInsideTxWithSharedDeptLock(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const targetDeptId = int64(17701) const callerDeptId = int64(17702) const callerUserId = int64(9911) mockUser := mocks.NewMockSysUserModel(ctrl) mockDept := mocks.NewMockSysDeptModel(ctrl) var ( findOneForShareCalled bool insertWithTxCalled bool findOneForShareOrder int insertWithTxOrder int stepCounter int ) mockUser.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), targetDeptId). DoAndReturn(func(ctx context.Context, _ sqlx.Session, _ int64) (*deptModel.SysDept, error) { findOneForShareCalled = true stepCounter++ findOneForShareOrder = stepCounter return &deptModel.SysDept{ Id: targetDeptId, Name: "target_dept", Path: "/17702/17701/", // 保证 HasPrefix(callerPath="/17702/") 成立 DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled, }, nil }) // 关键护栏:必须走 InsertWithTx,不得走 Insert。 // 未声明 `mockUser.EXPECT().Insert(...)`,任何对 Insert 的调用都会让 mock FAIL。 mockUser.EXPECT().InsertWithTx(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&userModel.SysUser{})). DoAndReturn(func(ctx context.Context, _ sqlx.Session, u *userModel.SysUser) (sql.Result, error) { insertWithTxCalled = true stepCounter++ insertWithTxOrder = stepCounter assert.Equal(t, targetDeptId, u.DeptId) assert.False(t, u.Avatar.Valid, "L-R17-2:Avatar 必须显式 Valid=false") return stubInsertResult{id: 77}, nil }) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser, Dept: mockDept}) ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{ UserId: callerUserId, Username: "h_r17_1_caller", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/17702/", MinPermsLevel: math.MaxInt64, }) resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{ Username: "h_r17_1_" + testutil.UniqueId(), Password: "Pass123456", DeptId: targetDeptId, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(77), resp.Id) assert.True(t, findOneForShareCalled, "必须在事务内对 sys_dept 走 FindOneForShareTx(S 锁)") assert.True(t, insertWithTxCalled, "sys_user 落盘必须走 InsertWithTx(同一 tx)") assert.Less(t, findOneForShareOrder, insertWithTxOrder, "锁序契约:先 FindOneForShareTx(sys_dept) 后 InsertWithTx(sys_user),"+ "与 DeleteDept 的 `sys_dept FOR UPDATE → sys_user FOR SHARE` 对偶,"+ "彻底关闭 future-user vs delete-dept 的 TOCTOU 窗口") } // TC-1196: H-R17-1 —— 事务内 FindOneForShareTx 返回 ErrNotFound 时必须 400 "部门不存在或已删除"。 // // 时序等价于:并发 DeleteDept 已先行提交,S 锁读已看不到该 dept 行;此时 Insert 必须 // 被拒绝,彻底避免"sys_user.deptId 指向已死 sys_dept.id"的 orphan 行。 func TestCreateUser_H_R17_1_DeptConcurrentlyDeletedRejected(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const targetDeptId = int64(17710) const callerDeptId = int64(17711) mockUser := mocks.NewMockSysUserModel(ctrl) mockDept := mocks.NewMockSysDeptModel(ctrl) mockUser.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), targetDeptId). Return(nil, sqlx.ErrNotFound) // InsertWithTx 绝不应该被调用——未声明即 Times(0),调用必 FAIL。 svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser, Dept: mockDept}) ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{ UserId: 9922, Username: "h_r17_1_caller2", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/17712/", MinPermsLevel: math.MaxInt64, }) _, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{ Username: "h_r17_1_del_" + testutil.UniqueId(), Password: "Pass123456", DeptId: targetDeptId, }) 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(), "部门不存在或已删除", "必须给出"+"已删除"+"提示,调用方才能分辨'真不存在'与'并发删掉'两条路径") } // stubInsertResult 最小实现 sql.Result 供 mock 返回 LastInsertId。 type stubInsertResult struct{ id int64 } func (r stubInsertResult) LastInsertId() (int64, error) { return r.id, nil } func (r stubInsertResult) RowsAffected() (int64, error) { return 1, nil }