diff --git a/backend/dto/admin/email.go b/backend/dto/admin/email.go index 96335279a..76fae683d 100644 --- a/backend/dto/admin/email.go +++ b/backend/dto/admin/email.go @@ -26,3 +26,9 @@ func FromEmailModel(email *models.Email) *Email { UpdatedAt: email.UpdatedAt, } } + +type CreateEmail struct { + Address string `json:"address" validate:"required,email"` + IsPrimary bool `json:"is_primary"` + IsVerified bool `json:"is_verified"` +} diff --git a/backend/dto/admin/user.go b/backend/dto/admin/user.go index 06b675c15..75d5f18db 100644 --- a/backend/dto/admin/user.go +++ b/backend/dto/admin/user.go @@ -33,3 +33,9 @@ func FromUserModel(model models.User) User { UpdatedAt: model.UpdatedAt, } } + +type CreateUser struct { + ID uuid.UUID `json:"id"` + Emails []CreateEmail `json:"emails" validate:"required,gte=1,unique=Address,dive"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/dto/validator.go b/backend/dto/validator.go index 20c8f3632..11717fcf2 100644 --- a/backend/dto/validator.go +++ b/backend/dto/validator.go @@ -46,6 +46,10 @@ func (cv *CustomValidator) Validate(i interface{}) error { vErrs[i] = fmt.Sprintf("%s must be a valid uuid4", err.Field()) case "url": vErrs[i] = fmt.Sprintf("%s must be a valid URL", err.Field()) + case "gte": + vErrs[i] = fmt.Sprintf("length of %s must be greater or equal to %v", err.Field(), err.Param()) + case "unique": + vErrs[i] = fmt.Sprintf("%s entries are not unique", err.Field()) default: vErrs[i] = fmt.Sprintf("something wrong on %s; %s", err.Field(), err.Tag()) } diff --git a/backend/handler/admin_router.go b/backend/handler/admin_router.go index 21f611230..a7bb37864 100644 --- a/backend/handler/admin_router.go +++ b/backend/handler/admin_router.go @@ -46,6 +46,7 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh user := g.Group("/users") user.GET("", userHandler.List) + user.POST("", userHandler.Create) user.GET("/:id", userHandler.Get) user.DELETE("/:id", userHandler.Delete) diff --git a/backend/handler/user_admin.go b/backend/handler/user_admin.go index 1b1e10fa1..78c20517a 100644 --- a/backend/handler/user_admin.go +++ b/backend/handler/user_admin.go @@ -2,16 +2,22 @@ package handler import ( "fmt" + "github.com/go-sql-driver/mysql" + "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" + "github.com/jackc/pgconn" "github.com/labstack/echo/v4" + "github.com/pkg/errors" "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/dto/admin" "github.com/teamhanko/hanko/backend/pagination" "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" "net/http" "net/url" "strconv" "strings" + "time" ) type UserHandlerAdmin struct { @@ -130,3 +136,119 @@ func (h *UserHandlerAdmin) Get(c echo.Context) error { return c.JSON(http.StatusOK, admin.FromUserModel(*user)) } + +func (h *UserHandlerAdmin) Create(c echo.Context) error { + var body admin.CreateUser + if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil { + return dto.ToHttpError(err) + } + + if err := c.Validate(body); err != nil { + return dto.ToHttpError(err) + } + + // if no userID is provided, create a new one + if body.ID.IsNil() { + userId, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("failed to create new userId: %w", err) + } + body.ID = userId + } + + // check that only one email is marked as primary + primaryEmails := 0 + for _, email := range body.Emails { + if email.IsPrimary { + primaryEmails++ + } + } + + if primaryEmails == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "at least one primary email must be provided") + } else if primaryEmails > 1 { + return echo.NewHTTPError(http.StatusBadRequest, "only one primary email is allowed") + } + + err := h.persister.GetConnection().Transaction(func(tx *pop.Connection) error { + u := models.User{ + ID: body.ID, + CreatedAt: body.CreatedAt, + } + + err := tx.Create(&u) + if err != nil { + var pgErr *pgconn.PgError + var mysqlErr *mysql.MySQLError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" { + return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create user with id '%v': %w", u.ID, fmt.Errorf("user already exists"))) + } + } else if errors.As(err, &mysqlErr) { + if mysqlErr.Number == 1062 { + return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create user with id '%v': %w", u.ID, fmt.Errorf("user already exists"))) + } + } + return fmt.Errorf("failed to create user with id '%v': %w", u.ID, err) + } + + now := time.Now() + for _, email := range body.Emails { + emailId, _ := uuid.NewV4() + mail := models.Email{ + ID: emailId, + UserID: &u.ID, + Address: strings.ToLower(email.Address), + Verified: email.IsVerified, + CreatedAt: now, + UpdatedAt: now, + } + + err := tx.Create(&mail) + if err != nil { + var pgErr *pgconn.PgError + var mysqlErr *mysql.MySQLError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" { + return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create email '%s' for user '%v': %w", mail.Address, u.ID, fmt.Errorf("email already exists"))) + } + } else if errors.As(err, &mysqlErr) { + if mysqlErr.Number == 1062 { + return echo.NewHTTPError(http.StatusConflict, fmt.Errorf("failed to create email '%s' for user '%v': %w", mail.Address, u.ID, fmt.Errorf("email already exists"))) + } + } + return fmt.Errorf("failed to create email '%s' for user '%v': %w", mail.Address, u.ID, err) + } + + if email.IsPrimary { + primary := models.PrimaryEmail{ + UserID: u.ID, + EmailID: mail.ID, + } + err := tx.Create(&primary) + if err != nil { + return fmt.Errorf("failed to set email '%s' as primary for user '%v': %w", mail.Address, u.ID, err) + } + } + } + return nil + }) + + if httpError, ok := err.(*echo.HTTPError); ok { + return httpError + } else if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err) + } + + p := h.persister.GetUserPersister() + user, err := p.Get(body.ID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "user not found") + } + + return c.JSON(http.StatusOK, admin.FromUserModel(*user)) +} diff --git a/backend/handler/user_admin_test.go b/backend/handler/user_admin_test.go index 5d45f6f3e..1295d665b 100644 --- a/backend/handler/user_admin_test.go +++ b/backend/handler/user_admin_test.go @@ -9,6 +9,7 @@ import ( "github.com/teamhanko/hanko/backend/test" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -153,3 +154,96 @@ func (s *userAdminSuite) TestUserHandlerAdmin_List_InvalidPaginationParam() { s.Equal(http.StatusBadRequest, rec.Code) } + +func (s *userAdminSuite) TestUserHandlerAdmin_Create() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + body string + expectedStatusCode int + }{ + { + name: "success", + body: `{"emails": [{"address": "test@test.com", "is_primary": true}]}`, + expectedStatusCode: http.StatusOK, + }, + { + name: "success with user id", + body: `{"id": "98a46ea2-51f6-4e30-bd29-8272de77c8c8", "emails": [{"address": "test@test.com", "is_primary": true}]}`, + expectedStatusCode: http.StatusOK, + }, + { + name: "success with multiple emails", + body: `{"emails": [{"address": "test@test.com", "is_primary": true}, {"address": "test2@test.com"}]}`, + expectedStatusCode: http.StatusOK, + }, + { + name: "success with created_at", + body: `{"emails": [{"address": "test@test.com", "is_primary": true}], "created_at": "2023-06-07T13:42:49.369489Z"}`, + expectedStatusCode: http.StatusOK, + }, + { + name: "with already existing user id", + body: `{"id": "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", "emails": [{"address": "test@test.com", "is_primary": true}]}`, + expectedStatusCode: http.StatusConflict, + }, + { + name: "with non uuid v4 id", + body: `{"id": "customId", "emails": [{"address": "test@test.com", "is_primary": true}]}`, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "with no emails", + body: `{"id": "98a46ea2-51f6-4e30-bd29-8272de77c8c8", "emails": []}`, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "with missing emails", + body: `{"id": "98a46ea2-51f6-4e30-bd29-8272de77c8c8"}`, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "with no primary email", + body: `{"emails": [{"address": "test@test.com"}]}`, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "with multiple primary emails", + body: `{"emails": [{"address": "test@test.com", "is_primary": true"}, {"address": "test2@test.com", "is_primary": true"}]}`, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "with non unique emails", + body: `{"emails": [{"address": "test@test.com", "is_primary": true"}, {"address": "test@test.com"}]}`, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "with already existing email", + body: `{"emails": [{"address": "john.doe@example.com", "is_primary": true}]}`, + expectedStatusCode: http.StatusConflict, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + s.Require().NoError(s.Storage.MigrateUp()) + + err := s.LoadFixtures("../test/fixtures/user_admin") + s.Require().NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(currentTest.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Equal(currentTest.expectedStatusCode, rec.Code) + s.Require().NoError(s.Storage.MigrateDown(-1)) + }) + } +} diff --git a/docs/static/spec/admin.yaml b/docs/static/spec/admin.yaml index 3308123dd..27ffbdefa 100644 --- a/docs/static/spec/admin.yaml +++ b/docs/static/spec/admin.yaml @@ -105,6 +105,55 @@ paths: $ref: '#/components/responses/InternalServerError' '400': $ref: '#/components/responses/BadRequest' + post: + summary: Create a new user + operationId: createUser + tags: + - User Management + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + description: The ID of the new user + allOf: + - $ref: '#/components/schemas/UUID4' + emails: + description: The email addresses of the new user + type: array + items: + type: object + properties: + address: + type: string + is_primary: + type: boolean + is_verified: + type: boolean + required: + - address + - is_primary + created_at: + description: Time of creation of the user + type: string + format: date-time + required: + - emails + responses: + '200': + description: 'Details of the newly created user' + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' /users/{id}: delete: summary: 'Delete a user by ID' @@ -265,6 +314,15 @@ components: example: code: 400 message: Bad Request + Conflict: + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 409 + message: Conflict InternalServerError: description: Internal server error content: @@ -296,7 +354,7 @@ components: allOf: - $ref: '#/components/schemas/UUID4' created_at: - description: Time of creation of the the user + description: Time of creation of the user type: string format: date-time updated_at: @@ -421,7 +479,7 @@ components: type: string format: email created_at: - description: Time of creation of the the audit log + description: Time of creation of the audit log type: string format: date-time example: 2022-09-14T12:15:09.788784Z