diff --git a/pkg/user/user_create.go b/pkg/user/user_create.go index 25e5d4e82..917ab4454 100644 --- a/pkg/user/user_create.go +++ b/pkg/user/user_create.go @@ -121,12 +121,81 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) { return newUserOut, err } +// CreateBotUser creates a bot user owned by the given owner. +// Bots have no email or password and cannot authenticate interactively. +// It intentionally bypasses checkIfUserIsValid / checkIfUserExists because +// those enforce email+password and would flag duplicate empty emails. +func CreateBotUser(s *xorm.Session, bot *User, owner *User) (*User, error) { + if owner == nil || owner.ID == 0 { + return nil, ErrNoUsernamePassword{} + } + if owner.IsBot() { + return nil, &ErrBotNotOwned{UserID: owner.ID} + } + + // Reuse the same username format rules as regular user creation + if err := checkUsernameFormat(bot.Username); err != nil { + return nil, err + } + if !strings.HasPrefix(bot.Username, "bot-") { + return nil, &ErrBotUsernameMustHavePrefix{Username: bot.Username} + } + + if _, err := GetUserByUsername(s, bot.Username); err == nil { + return nil, ErrUsernameExists{Username: bot.Username} + } else if !IsErrUserDoesNotExist(err) { + return nil, err + } + + bot.ID = 0 + bot.BotOwnerID = owner.ID + bot.Status = StatusActive + bot.Issuer = IssuerLocal + bot.Password = "" + bot.Email = "" + + if _, err := s.Insert(bot); err != nil { + return nil, err + } + + newBot, err := GetUserByID(s, bot.ID) + if err != nil { + return nil, err + } + + events.DispatchOnCommit(s, &CreatedEvent{User: newBot}) + return newBot, nil +} + // HashPassword hashes a password func HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), config.ServiceBcryptRounds.GetInt()) return string(bytes), err } +// checkUsernameFormat validates username format rules shared by regular and bot users. +func checkUsernameFormat(username string) error { + if username == "" { + return ErrNoUsernamePassword{} + } + + if strings.Contains(username, " ") { + return &ErrUsernameMustNotContainSpaces{ + Username: username, + } + } + + // Check if username matches the reserved link-share pattern + linkSharePattern := regexp.MustCompile(`^link-share-\d+$`) + if linkSharePattern.MatchString(username) { + return ErrUsernameReserved{ + Username: username, + } + } + + return nil +} + func checkIfUserIsValid(user *User) error { if user.Email == "" || (user.Issuer != IssuerLocal && user.Subject == "") || @@ -135,18 +204,8 @@ func checkIfUserIsValid(user *User) error { return ErrNoUsernamePassword{} } - if strings.Contains(user.Username, " ") { - return &ErrUsernameMustNotContainSpaces{ - Username: user.Username, - } - } - - // Check if username matches the reserved link-share pattern - linkSharePattern := regexp.MustCompile(`^link-share-\d+$`) - if linkSharePattern.MatchString(user.Username) { - return ErrUsernameReserved{ - Username: user.Username, - } + if err := checkUsernameFormat(user.Username); err != nil { + return err } // Reserve the bot- prefix for bot users (created via CreateBotUser) diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index b8e5eb22f..776a60b5d 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -25,6 +25,75 @@ import ( "github.com/stretchr/testify/require" ) +func TestCreateBotUser(t *testing.T) { + t.Run("success", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + owner, err := GetUserByID(s, 1) + require.NoError(t, err) + + bot, err := CreateBotUser(s, &User{Username: "bot-reviewer"}, owner) + require.NoError(t, err) + assert.True(t, bot.IsBot()) + assert.Equal(t, owner.ID, bot.BotOwnerID) + assert.Equal(t, StatusActive, bot.Status) + assert.Empty(t, bot.Email) + assert.Empty(t, bot.Password) + }) + t.Run("empty username", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + owner, err := GetUserByID(s, 1) + require.NoError(t, err) + _, err = CreateBotUser(s, &User{Username: ""}, owner) + require.Error(t, err) + }) + t.Run("username with spaces", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + owner, err := GetUserByID(s, 1) + require.NoError(t, err) + _, err = CreateBotUser(s, &User{Username: "bot- name"}, owner) + require.Error(t, err) + assert.True(t, IsErrUsernameMustNotContainSpaces(err)) + }) + t.Run("missing bot- prefix", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + owner, err := GetUserByID(s, 1) + require.NoError(t, err) + _, err = CreateBotUser(s, &User{Username: "reviewer"}, owner) + require.Error(t, err) + assert.True(t, IsErrBotUsernameMustHavePrefix(err)) + }) + t.Run("duplicate username", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + owner, err := GetUserByID(s, 1) + require.NoError(t, err) + _, err = CreateBotUser(s, &User{Username: "bot-dup"}, owner) + require.NoError(t, err) + _, err = CreateBotUser(s, &User{Username: "bot-dup"}, owner) + require.Error(t, err) + assert.True(t, IsErrUsernameExists(err)) + }) + t.Run("bot cannot create bot", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + botOwner := &User{ID: 999, BotOwnerID: 1} + _, err := CreateBotUser(s, &User{Username: "bot-child"}, botOwner) + require.Error(t, err) + assert.True(t, IsErrBotNotOwned(err)) + }) +} + func TestCreateUser_RejectsBotPrefix(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession()