diff --git a/models/user/user.go b/models/user/user.go index 0a43de74354..7e896e26dab 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -640,6 +640,11 @@ func CreateUser(u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err e u.IsRestricted = setting.Service.DefaultUserIsRestricted u.IsActive = !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm) + // Ensure consistency of the dates. + if u.UpdatedUnix < u.CreatedUnix { + u.UpdatedUnix = u.CreatedUnix + } + // overwrite defaults if set if len(overwriteDefault) != 0 && overwriteDefault[0] != nil { overwrite := overwriteDefault[0] @@ -717,7 +722,15 @@ func CreateUser(u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err e return err } - if err = db.Insert(ctx, u); err != nil { + if u.CreatedUnix == 0 { + // Caller expects auto-time for creation & update timestamps. + err = db.Insert(ctx, u) + } else { + // Caller sets the timestamps themselves. They are responsible for ensuring + // both `CreatedUnix` and `UpdatedUnix` are set appropriately. + _, err = db.GetEngine(ctx).NoAutoTime().Insert(u) + } + if err != nil { return err } diff --git a/models/user/user_test.go b/models/user/user_test.go index 525da531f23..7a58d2f822c 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -4,9 +4,11 @@ package user_test import ( + "context" "math/rand" "strings" "testing" + "time" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -14,6 +16,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" @@ -252,6 +255,58 @@ func TestCreateUserEmailAlreadyUsed(t *testing.T) { assert.True(t, user_model.IsErrEmailAlreadyUsed(err)) } +func TestCreateUserCustomTimestamps(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Add new user with a custom creation timestamp. + var creationTimestamp timeutil.TimeStamp = 12345 + user.Name = "testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + user.Email = "unique@example.com" + user.CreatedUnix = creationTimestamp + err := user_model.CreateUser(user) + assert.NoError(t, err) + + fetched, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + assert.Equal(t, creationTimestamp, fetched.CreatedUnix) + assert.Equal(t, creationTimestamp, fetched.UpdatedUnix) +} + +func TestCreateUserWithoutCustomTimestamps(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // There is no way to use a mocked time for the XORM auto-time functionality, + // so use the real clock to approximate the expected timestamp. + timestampStart := time.Now().Unix() + + // Add new user without a custom creation timestamp. + user.Name = "Testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + user.Email = "unique@example.com" + user.CreatedUnix = 0 + user.UpdatedUnix = 0 + err := user_model.CreateUser(user) + assert.NoError(t, err) + + timestampEnd := time.Now().Unix() + + fetched, err := user_model.GetUserByID(context.Background(), user.ID) + assert.NoError(t, err) + + assert.LessOrEqual(t, timestampStart, fetched.CreatedUnix) + assert.LessOrEqual(t, fetched.CreatedUnix, timestampEnd) + + assert.LessOrEqual(t, timestampStart, fetched.UpdatedUnix) + assert.LessOrEqual(t, fetched.UpdatedUnix, timestampEnd) +} + func TestGetUserIDsByNames(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index 0739653eea4..4d679c81d00 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -4,6 +4,8 @@ package structs +import "time" + // CreateUserOption create user options type CreateUserOption struct { SourceID int64 `json:"source_id"` @@ -20,6 +22,11 @@ type CreateUserOption struct { SendNotify bool `json:"send_notify"` Restricted *bool `json:"restricted"` Visibility string `json:"visibility" binding:"In(,public,limited,private)"` + + // For explicitly setting the user creation timestamp. Useful when users are + // migrated from other systems. When omitted, the user's creation timestamp + // will be set to "now". + Created *time.Time `json:"created_at"` } // EditUserOption edit user options diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 4ee1a320ccb..75d5520a0ee 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/password" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" @@ -120,6 +121,14 @@ func CreateUser(ctx *context.APIContext) { overwriteDefault.Visibility = &visibility } + // Update the user creation timestamp. This can only be done after the user + // record has been inserted into the database; the insert intself will always + // set the creation timestamp to "now". + if form.Created != nil { + u.CreatedUnix = timeutil.TimeStamp(form.Created.Unix()) + u.UpdatedUnix = u.CreatedUnix + } + if err := user_model.CreateUser(u, overwriteDefault); err != nil { if user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err) || diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e096faf3f3b..00fc3b60c45 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -15809,6 +15809,12 @@ "password" ], "properties": { + "created_at": { + "description": "For explicitly setting the user creation timestamp. Useful when users are\nmigrated from other systems. When omitted, the user's creation timestamp\nwill be set to \"now\".", + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, "email": { "type": "string", "format": "email",