feat(user): add CreateBotUser

This commit is contained in:
kolaente 2026-04-05 19:54:43 +02:00 committed by kolaente
parent 506bfa2549
commit 1637ecd0c7
2 changed files with 140 additions and 12 deletions

View File

@ -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)

View File

@ -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()