feat(API): optional display name on users
This commit is contained in:
1 parent
792aba433d
commit
7aae704d0a
22 files changed
+1051
-40
No files matched your search
@@ -33,6 +33,7 @@ type UseCases struct {
|
||||
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
|
||||
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
|
||||
ResetPasswordUseCase *useCases.ResetPasswordUseCase
|
||||
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
|
||||
ListUsersUseCase *useCases.ListUsersUseCase
|
||||
SendMailUseCase *useCases.SendMailUseCase
|
||||
CreateImageUseCase *useCases.CreateImageUseCase
|
||||
@@ -88,6 +89,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
|
||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/publicuser"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
@@ -37,7 +38,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(newPublicUser(loadedUser))
|
||||
body, err := json.Marshal(publicuser.New(loadedUser))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -3,13 +3,13 @@ package auth
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/publicuser"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VerificationHandler struct {
|
||||
@@ -22,26 +22,6 @@ type VerifyCredentials struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
type PublicUser struct {
|
||||
ID uint32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func newPublicUser(u user.User) PublicUser {
|
||||
return PublicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
IsVerified: u.IsVerified,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NewVerificationHandler(verify *useCases.VerifyUserUseCase, cancel *useCases.CancelRegistrationUseCase, isProduction bool) *VerificationHandler {
|
||||
return &VerificationHandler{
|
||||
VerifyUseCase: verify,
|
||||
@@ -84,7 +64,7 @@ func (h *VerificationHandler) Verify(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(newPublicUser(verifiedUser))
|
||||
body, err := json.Marshal(publicuser.New(verifiedUser))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/api/pkg/publicuser"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
UpdateProfileUseCase *useCases.UpdateUserProfileUseCase
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
DisplayName *string `json:"display_name" validate:"required"`
|
||||
}
|
||||
|
||||
func NewHandler(updateProfile *useCases.UpdateUserProfileUseCase) *Handler {
|
||||
return &Handler{
|
||||
UpdateProfileUseCase: updateProfile,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
var request UpdateProfileRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&request)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = validator.Validate.Struct(request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
updatedUser, err := h.UpdateProfileUseCase.UpdateDisplayName(userId, *request.DisplayName)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrInvalidDisplayName) {
|
||||
http.Error(w, apperr.DisplayNameErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(publicuser.New(updatedUser))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const BodyErrorMessage = "The request cannot be processed due to a mismatch in t
|
||||
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
|
||||
const ArticleNotFoundErrorMessage = "Article not found"
|
||||
const ImageNotFoundErrorMessage = "Image not found"
|
||||
const DisplayNameErrorMessage = "Display name must be 60 characters or fewer."
|
||||
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
|
||||
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
|
||||
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package publicuser
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PublicUser struct {
|
||||
ID uint32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func New(u user.User) PublicUser {
|
||||
return PublicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
IsVerified: u.IsVerified,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
DisplayName: u.DisplayName,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
"RenewCMS/api/handlers/image"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
"RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"encoding/json"
|
||||
@@ -87,6 +88,19 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
return r
|
||||
}
|
||||
|
||||
func NewUserRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Patch("/me", userHandler.UpdateMe)
|
||||
return r
|
||||
}
|
||||
|
||||
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -99,6 +113,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Mount("/article", NewArticleRouter(container))
|
||||
})
|
||||
r.Mount("/user", NewUserRouter(container))
|
||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
|
||||
return r
|
||||
|
||||
@@ -9,9 +9,10 @@ Feature: A logged-in user fetches their own public profile without triggering th
|
||||
without being throttled like a login attempt
|
||||
|
||||
Background:
|
||||
Given the existing PublicUser struct and newPublicUser(u user.User) converter in
|
||||
api/handlers/auth/verificationHandler.go, exactly the JSON shape POST /v1/auth/verify already
|
||||
returns: id, username, email, is_verified, created_at, updated_at
|
||||
Given the public user struct and its converter, shared by the auth and user handlers from a
|
||||
package under api/pkg (features/user_display_name.feature), exactly the JSON shape
|
||||
POST /v1/auth/verify returns: id, username, email, is_verified, created_at, updated_at and
|
||||
display_name
|
||||
And this slice adds GET /v1/auth/me, handled by a new MeHandler (api/handlers/auth/meHandler.go)
|
||||
built with NewMeHandler(container.GetUserUseCase), the same GetUserUseCase every other
|
||||
protected handler already gets from api/dependecyInjection.go; no new use case or domain
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
Feature: A verified user can set an optional display name that replaces their username for display only
|
||||
|
||||
As one of several admins with equal access to a RenewCMS workspace
|
||||
I want to give my account an optional display name, up to 60 characters, and have the API
|
||||
return it wherever my own account is returned
|
||||
So that people are named by a human name instead of a username, while logging in still uses
|
||||
the username and no secret field ever reaches a response
|
||||
|
||||
Background:
|
||||
Given internal/domain/user gains the field DisplayName, json "display_name", with no gorm tag,
|
||||
and the sentinel user.ErrInvalidDisplayName
|
||||
And user.Repository gains UpdateDisplayName(userId uint32, displayName string) (User, error),
|
||||
returning user.ErrUserNotFound when the user does not exist
|
||||
And the persistence model stores the display name in a column whose default is the empty
|
||||
string, so rows created before this slice still load with DisplayName ""
|
||||
And a new use case, UpdateUserProfile, exposes UpdateDisplayName(userId uint32, displayName
|
||||
string) (user.User, error) and depends only on user.Repository
|
||||
And UpdateUserProfile trims the value with strings.TrimSpace, then counts what remains with
|
||||
utf8.RuneCountInString (runes, not bytes), returns user.ErrInvalidDisplayName without calling
|
||||
the repository when the count is above 60, and otherwise hands the trimmed value to
|
||||
UpdateDisplayName; an empty or whitespace-only value therefore stores "" and clears the name
|
||||
And api/pkg/apperr defines DisplayNameErrorMessage as "Display name must be 60 characters or
|
||||
fewer."
|
||||
And PATCH /v1/user/me is served by a new handler in api/handlers/user/, mounted by a new
|
||||
/v1/user router in api/router.go inside a group carrying jwtauth.Verifier,
|
||||
jwtauth.Authenticator, middleware.RequireCurrentToken and middleware.RequireVerifiedUser, in
|
||||
that order, so an unauthenticated caller is refused with 401, a stale token with 401
|
||||
apperr.UnauthorizedErrorMessage, and an unverified caller with 403
|
||||
apperr.VerificationRequiredErrorMessage before the handler runs
|
||||
And the request body is JSON {"display_name": <string>}; the request struct field is a
|
||||
*string tagged validate:"required" and is checked with validator.Validate.Struct; a body that
|
||||
is not valid JSON, a display_name that is not a string, or a missing or null display_name
|
||||
answers 400
|
||||
And the handler maps user.ErrInvalidDisplayName to 400 with body
|
||||
apperr.DisplayNameErrorMessage, user.ErrUserNotFound to 401 apperr.UnauthorizedErrorMessage,
|
||||
and any other error to 500 "Internal server error", all as plain text from http.Error
|
||||
And the public user struct and its converter move out of api/handlers/auth into a shared
|
||||
package under api/pkg, imported by both the auth and user handlers, so neither handler
|
||||
imports the other
|
||||
And on success the response is 200 and the body is the updated user in that public shape:
|
||||
exactly id, username, email, is_verified, created_at, updated_at and display_name
|
||||
And GET /v1/auth/me and POST /v1/auth/verify use that same public shape, so display_name is
|
||||
present in both, as "" when unset
|
||||
And password, password_reset_code, verification_code, verification_expiration,
|
||||
password_reset_expiration and token_version never appear in any response
|
||||
(features/user_response_field_hygiene.feature)
|
||||
And POST /v1/auth/login still authenticates with the username only; display_name is neither
|
||||
accepted nor matched there
|
||||
And changing the display name does not change token_version, so existing sessions stay valid
|
||||
|
||||
Scenario: A verified user sets a display name
|
||||
Given a user repository containing user id 7, username "marta", email "marta@renew.example",
|
||||
is_verified true, DisplayName "", TokenVersion 0
|
||||
And a valid jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta Alves"}
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object with exactly the keys id, username, email, is_verified,
|
||||
created_at, updated_at and display_name
|
||||
And the response body's id is 7, username "marta", display_name "Marta Alves"
|
||||
And the user repository now holds DisplayName "Marta Alves" for user id 7
|
||||
And the user repository still holds username "marta"
|
||||
|
||||
Scenario Outline: The value is trimmed before it is stored and returned
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": <sent>}
|
||||
Then the response status is 200
|
||||
And the response body's display_name is <returned>
|
||||
And the user repository holds DisplayName <returned> for user id 7
|
||||
|
||||
Examples:
|
||||
| sent | returned |
|
||||
| " Marta Alves " | "Marta Alves" |
|
||||
| "\tMarta Alves\n" | "Marta Alves" |
|
||||
| "Marta Alves" | "Marta Alves" |
|
||||
|
||||
Scenario Outline: An empty or whitespace-only value clears the display name
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
|
||||
"Marta Alves"
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": <sent>}
|
||||
Then the response status is 200
|
||||
And the response body's display_name is ""
|
||||
And the response body's username is "marta"
|
||||
And the user repository holds DisplayName "" for user id 7
|
||||
|
||||
Examples:
|
||||
| sent |
|
||||
| "" |
|
||||
| " " |
|
||||
| "\t \n" |
|
||||
|
||||
Scenario Outline: The limit is 60 Unicode characters, counted as runes
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and a display_name of <count> repetitions
|
||||
of <unit>
|
||||
Then the response status is <status>
|
||||
And <effect>
|
||||
|
||||
Examples:
|
||||
| count | unit | status | effect |
|
||||
| 60 | "a" | 200 | the repository holds that 60-character value |
|
||||
| 61 | "a" | 400 | the body is apperr.DisplayNameErrorMessage and DisplayName is still "" |
|
||||
| 60 | "😀" | 200 | the repository holds that 60-rune value (240 bytes) |
|
||||
| 61 | "😀" | 400 | the body is apperr.DisplayNameErrorMessage and DisplayName is still "" |
|
||||
| 60 | "é" | 200 | the repository holds that 60-rune value (120 bytes) |
|
||||
|
||||
Scenario: The limit applies after trimming, not before
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and a display_name made of 5 spaces,
|
||||
60 "a" characters and 5 spaces (70 characters in total)
|
||||
Then the response status is 200
|
||||
And the response body's display_name is the 60 "a" characters
|
||||
|
||||
Scenario: Control characters inside the name are kept
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName ""
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Ma\nrta"}
|
||||
Then the response status is 200
|
||||
And the response body's display_name is "Ma\nrta"
|
||||
|
||||
Scenario: A rejected value leaves the existing display name untouched
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
|
||||
"Marta Alves"
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and a display_name of 61 "a" characters
|
||||
Then the response status is 400
|
||||
And the repository's UpdateDisplayName is never called
|
||||
And the user repository still holds DisplayName "Marta Alves" for user id 7
|
||||
|
||||
Scenario Outline: A malformed body is refused with 400
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
|
||||
"Marta Alves"
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and <body>
|
||||
Then the response status is 400
|
||||
And the repository's UpdateDisplayName is never called
|
||||
And the user repository still holds DisplayName "Marta Alves" for user id 7
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| the raw text not json |
|
||||
| {} |
|
||||
| {"display_name": null} |
|
||||
| {"display_name": 42} |
|
||||
| {"displayName": "Marta"} |
|
||||
|
||||
Scenario: The password, token version and login username are unaffected by a display name
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, a stored
|
||||
password hash, DisplayName "", TokenVersion 0
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta Alves"}
|
||||
Then the response status is 200
|
||||
And the stored password hash for user id 7 is unchanged
|
||||
And the stored TokenVersion for user id 7 is unchanged
|
||||
And the same jwt cookie still gets 200 from GET /v1/auth/me
|
||||
|
||||
Scenario: GET /v1/auth/me returns the display name once set
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, DisplayName
|
||||
"Marta Alves"
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends GET /v1/auth/me with that cookie
|
||||
Then the response status is 200
|
||||
And the response body has display_name "Marta Alves" and username "marta"
|
||||
|
||||
Scenario: GET /v1/auth/me returns an empty display_name, not a missing key, when none is set
|
||||
Given a user repository containing user id 8, username "bob", is_verified false, DisplayName ""
|
||||
And a valid jwt cookie for user id 8
|
||||
When the client sends GET /v1/auth/me with that cookie
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object with exactly the keys id, username, email, is_verified,
|
||||
created_at, updated_at and display_name
|
||||
And the response body's display_name is ""
|
||||
|
||||
Scenario: Logging in still uses the username only
|
||||
Given a user repository containing verified user "marta" with a known password and DisplayName
|
||||
"Ines Duarte"
|
||||
When the client sends POST /v1/auth/login with username "Ines Duarte" and the correct password
|
||||
Then the response status is 403
|
||||
And the response body is apperr.LogsErrorMessage
|
||||
When the client sends POST /v1/auth/login with username "marta" and the correct password
|
||||
Then the response status is 200
|
||||
|
||||
Scenario: An unverified user is refused
|
||||
Given a user repository containing user id 8, username "bob", is_verified false, DisplayName ""
|
||||
And a valid jwt cookie for user id 8
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Bob"}
|
||||
Then the response status is 403
|
||||
And the response body is the apperr.VerificationRequiredErrorMessage text
|
||||
And the user repository still holds DisplayName "" for user id 8
|
||||
|
||||
Scenario: No jwt cookie is refused
|
||||
Given a user repository containing user id 7, username "marta", is_verified true
|
||||
When the client sends PATCH /v1/user/me with no jwt cookie and body {"display_name": "Marta"}
|
||||
Then the response status is 401
|
||||
And the repository's UpdateDisplayName is never called
|
||||
|
||||
Scenario: A stale jwt is refused
|
||||
Given a user repository containing user id 7, username "marta", is_verified true, TokenVersion 1
|
||||
And a jwt cookie for user id 7 encoding token_version 0
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
And the repository's UpdateDisplayName is never called
|
||||
|
||||
Scenario: A jwt minted for a since-deleted account is refused
|
||||
Given a user repository containing no user with id 999
|
||||
And a valid jwt cookie for user id 999
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
|
||||
Scenario: A repository failure while saving answers 500
|
||||
Given a user repository containing user id 7, username "marta", is_verified true
|
||||
And the user repository fails on its next call to UpdateDisplayName
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
|
||||
Then the response status is 500
|
||||
And the response body is the text "Internal server error"
|
||||
|
||||
Scenario: The user is deleted between authentication and the save
|
||||
Given a user repository whose UpdateDisplayName returns user.ErrUserNotFound for user id 7
|
||||
And a valid jwt cookie for user id 7 that passes the middleware
|
||||
When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"}
|
||||
Then the response status is 401
|
||||
And the response body is the apperr.UnauthorizedErrorMessage text
|
||||
|
||||
Scenario Outline: The use case enforces the same rule without HTTP
|
||||
Given a fake user repository containing user id 7 with DisplayName "Old"
|
||||
When UpdateUserProfile.UpdateDisplayName is called for user id 7 with <input>
|
||||
Then the result is <outcome>
|
||||
|
||||
Examples:
|
||||
| input | outcome |
|
||||
| " Marta " | the user with DisplayName "Marta" |
|
||||
| "" | the user with DisplayName "" |
|
||||
| " " | the user with DisplayName "" |
|
||||
| 60 "😀" runes | the user with those 60 runes |
|
||||
| 61 "😀" runes | user.ErrInvalidDisplayName (errors.Is), repository not called |
|
||||
| "Marta" for unknown user id 99 | user.ErrUserNotFound (errors.Is) |
|
||||
@@ -0,0 +1,127 @@
|
||||
# QA procedure: user_display_name
|
||||
|
||||
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and
|
||||
following the router wiring pattern in `test/get_current_user_http_test.go`. No live server, no real
|
||||
SQLite file, no real SMTP. Files: `test/user_display_name_test.go` (use case and HTTP).
|
||||
|
||||
## Test infrastructure additions needed first
|
||||
|
||||
1. Add `UpdateDisplayName(userId uint32, displayName string) (user.User, error)` to `mockUserRepo`:
|
||||
call `m.fail.trigger("UpdateDisplayName")` first, return `user.ErrUserNotFound` when the id is
|
||||
absent, otherwise store `DisplayName` on the map entry and return it. Add fields
|
||||
`updateDisplayNameCalls int` and `lastUpdateDisplayName string`.
|
||||
2. Use `failNext("UpdateDisplayName")` for the 500 case. For the "deleted between auth and save"
|
||||
case, add `failNextWith("UpdateDisplayName", user.ErrUserNotFound)` (as planned in
|
||||
`features/article_title_rules.qa.md` step 1), or seed the user, then delete it from the fake after
|
||||
the middleware step via a hook.
|
||||
3. Any other type implementing `user.Repository` in `test/` must gain the method so the package
|
||||
compiles.
|
||||
|
||||
## Setup shared by every HTTP case
|
||||
|
||||
4. Call `validator.InitValidator()`.
|
||||
5. `getUser := useCases.NewGetUserUseCase(userRepo)`, `update :=
|
||||
useCases.NewUpdateUserProfileUseCase(userRepo)`, `userHandler := user.NewHandler(update)` (import
|
||||
`RenewCMS/api/handlers/user` under an alias that does not collide with the domain `user`
|
||||
package), `meHandler := auth.NewMeHandler(getUser)`.
|
||||
6. Fresh `chi.NewRouter()` mirroring `api/router.go`, with `token := jwtauth.New("HS256",
|
||||
[]byte("test-secret"), nil)`:
|
||||
```go
|
||||
r.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Patch("/me", userHandler.UpdateMe)
|
||||
})
|
||||
r.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
```
|
||||
Add the public `/v1/auth/login` route (real `auth.NewLoginHandler` over the fakes, as in the
|
||||
existing login tests) for the login case. The handler method names above are indicative; use
|
||||
whatever the handlers expose.
|
||||
7. Mint cookies with `token.Encode(map[string]any{"user_id": uint32(id), "token_version":
|
||||
uint32(v)})` set as the `jwt` cookie.
|
||||
8. Decode success bodies into `map[string]json.RawMessage` (not `user.User`) so key sets can be
|
||||
asserted. Error bodies are plain text from `http.Error` and carry a trailing `"\n"`.
|
||||
|
||||
## Use-case checks (fake repo, no HTTP)
|
||||
|
||||
9. `useCases.NewUpdateUserProfileUseCase(repo)` with user id 7 seeded, `DisplayName: "Old"`:
|
||||
- `" Marta "` -> `DisplayName "Marta"` returned and stored.
|
||||
- `""` and `" "` -> `DisplayName ""` stored (cleared).
|
||||
- `strings.Repeat("😀", 60)` -> success. `strings.Repeat("😀", 61)` -> `errors.Is(err,
|
||||
user.ErrInvalidDisplayName)`, `updateDisplayNameCalls == 0`, stored value still `"Old"`.
|
||||
- Unknown id 99 -> `errors.Is(err, user.ErrUserNotFound)`.
|
||||
- Repository failure via `failNext("UpdateDisplayName")` -> the error is returned unchanged.
|
||||
|
||||
## HTTP checks
|
||||
|
||||
10. Seed user 7 (`marta`, `marta@renew.example`, `IsVerified: true`, `DisplayName: ""`,
|
||||
`TokenVersion: 0`) with a non-empty `Password` hash. `PATCH /v1/user/me`
|
||||
`{"display_name":"Marta Alves"}` -> `200`. Body keys are exactly `{id, username, email,
|
||||
is_verified, created_at, updated_at, display_name}`. `display_name` is `"Marta Alves"`,
|
||||
`username` is `"marta"`. The repo holds `"Marta Alves"`. The stored `Password` and
|
||||
`TokenVersion` are unchanged. The same cookie then gets `200` from `GET /v1/auth/me`.
|
||||
11. Trim table: `" Marta Alves "` and `"\tMarta Alves\n"` -> `"Marta Alves"`; `"Marta Alves"`
|
||||
keeps its interior spaces.
|
||||
12. Clear table (seed `DisplayName: "Marta Alves"`): `""`, `" "`, `"\t \n"` -> `200`, response and
|
||||
repo `display_name ""`, `username` still `"marta"`.
|
||||
13. Length table (seed `""`): 60 x `"a"` -> `200`; 61 x `"a"` -> `400` with body
|
||||
`apperr.DisplayNameErrorMessage + "\n"`; 60 x `"😀"` -> `200`; 61 x `"😀"` -> `400`; 60 x `"é"`
|
||||
-> `200`. Assert `updateDisplayNameCalls == 0` on every `400` and that the stored value is
|
||||
unchanged.
|
||||
14. Trim-before-count: `strings.Repeat(" ", 5) + strings.Repeat("a", 60) + strings.Repeat(" ", 5)`
|
||||
-> `200`, returned value is the 60 `"a"`.
|
||||
15. Control characters: `{"display_name":"Ma\nrta"}` -> `200`, returned and stored value is
|
||||
`"Ma\nrta"`.
|
||||
16. Malformed table (seed `DisplayName: "Marta Alves"`): raw text `not json`, `{}`,
|
||||
`{"display_name":null}`, `{"display_name":42}`, `{"displayName":"Marta"}` -> each `400`,
|
||||
`updateDisplayNameCalls == 0`, stored value unchanged. Do not assert an exact body for these.
|
||||
17. `GET /v1/auth/me` for user 7 with `DisplayName "Marta Alves"` -> `200`, `display_name` is
|
||||
`"Marta Alves"`. For user 8 (`IsVerified: false`, `DisplayName ""`) -> key set is exactly the
|
||||
seven keys and `display_name` is `""`, not absent.
|
||||
18. Login: seed verified user `marta` with a stub-hashed password and `DisplayName: "Ines Duarte"`.
|
||||
`POST /v1/auth/login` with username `"Ines Duarte"` -> `403`, body
|
||||
`apperr.LogsErrorMessage + "\n"`. With username `"marta"` -> `200`. Confirm the login handler's
|
||||
real status for an unknown username before asserting `403`.
|
||||
19. Unverified user 8 with matching cookie -> `PATCH` -> `403`, body
|
||||
`apperr.VerificationRequiredErrorMessage + "\n"`, `updateDisplayNameCalls == 0`.
|
||||
20. No cookie -> `401` (do not assert the body; produced by `jwtauth.Authenticator`),
|
||||
`updateDisplayNameCalls == 0`.
|
||||
21. User 7 with `TokenVersion: 1`, cookie encoding `token_version 0` -> `401`, body
|
||||
`apperr.UnauthorizedErrorMessage + "\n"`, `updateDisplayNameCalls == 0`.
|
||||
22. Cookie for user 999 that does not exist -> `401`, same body.
|
||||
23. `failNext("UpdateDisplayName")` (generic error) with a valid verified user -> `500`, body
|
||||
`"Internal server error\n"`.
|
||||
24. Repository returns `user.ErrUserNotFound` from `UpdateDisplayName` only (middleware passes) ->
|
||||
`401`, body `apperr.UnauthorizedErrorMessage + "\n"`.
|
||||
25. Sweep for secrets: on every `200` PATCH body and on `GET /v1/auth/me`, reuse
|
||||
`assertNoForbiddenKeys` from `test/user_response_field_hygiene_test.go`. Add `token_version` to
|
||||
that helper's forbidden keys if it does not check it yet.
|
||||
|
||||
## Edits to existing tests required by the six to seven field change
|
||||
|
||||
26. `test/user_response_field_hygiene_test.go` (`expectedKeys` in the verify test, around line 127):
|
||||
add `"display_name"`. The count check then expects seven. The seeded user 7 has no display
|
||||
name, so also assert `display_name` decodes to `""`.
|
||||
27. `test/get_current_user_http_test.go` decodes into the public user struct; update its import to
|
||||
the shared package. Step 17 is the authoritative shape check for `/me`.
|
||||
|
||||
## Manual verification (not exercised by the Go test)
|
||||
|
||||
- Start an existing dev SQLite file created before this slice, boot the server so `AutoMigrate`
|
||||
runs, and confirm `GET /v1/auth/me` for the pre-existing user returns `display_name: ""` and that
|
||||
login still works. Then `PATCH`, restart, and confirm the value persisted.
|
||||
- Confirm by inspection that the `internal/domain/user` struct has no `gorm` tag on `DisplayName`,
|
||||
the GORM tag with the empty-string default sits in `persistence/models`, `persistence/mappers`
|
||||
maps both directions, and the model list in `api/dependecyInjection.go` needs no new entry (same
|
||||
model, new column).
|
||||
- Confirm that the new handler package constructs nothing and only receives `UpdateUserProfile`
|
||||
via `NewHandler(...)`, that `dependecyInjection.go` wires the use case into the `*UseCases`
|
||||
container, and that neither handler package imports the other.
|
||||
- No SMTP or real embedded frontend is involved. The SPA is out of scope.
|
||||
@@ -7,11 +7,11 @@ Feature: API responses never expose a user's secret fields
|
||||
credential or a still-valid reset/verification secret
|
||||
|
||||
Background:
|
||||
Given the public shape of a user in any API response is exactly six fields: id, username,
|
||||
email, is_verified, created_at and updated_at
|
||||
And password, password_reset_code, verification_code, verification_expiration and
|
||||
password_reset_expiration never appear in any response body, under any key name, regardless
|
||||
of their value or whether they are set
|
||||
Given the public shape of a user in any API response is exactly seven fields: id, username,
|
||||
email, is_verified, created_at, updated_at and display_name
|
||||
And password, password_reset_code, verification_code, verification_expiration,
|
||||
password_reset_expiration and token_version never appear in any response body, under any key
|
||||
name, regardless of their value or whether they are set
|
||||
|
||||
Scenario: A successful verification response carries only public fields
|
||||
Given a user repository containing user id 7, username "alice", email "alice@example.com",
|
||||
@@ -21,10 +21,11 @@ Feature: API responses never expose a user's secret fields
|
||||
When the client sends POST /v1/auth/verify with body {"code": "abc123"}
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object with exactly the keys id, username, email,
|
||||
is_verified, created_at and updated_at
|
||||
is_verified, created_at, updated_at and display_name
|
||||
And the response body does not contain the keys password, password_reset_code,
|
||||
verification_code, verification_expiration or password_reset_expiration
|
||||
verification_code, verification_expiration, password_reset_expiration or token_version
|
||||
And the response body's is_verified is true
|
||||
And the response body's display_name is ""
|
||||
|
||||
Scenario: A rejected verification attempt never echoes any user field
|
||||
Given a user repository containing user id 7, username "alice", is_verified false, whose
|
||||
|
||||
@@ -21,10 +21,11 @@ Package: `test` (black-box), extending `test/account_verification_http_test.go`'
|
||||
user id 7. Decode the response body into `map[string]json.RawMessage` (not into `user.User`)
|
||||
and assert:
|
||||
- the key set is exactly `{"id", "username", "email", "is_verified", "created_at",
|
||||
"updated_at"}`;
|
||||
"updated_at", "display_name"}`;
|
||||
- none of `password`, `password_reset_code`, `verification_code`,
|
||||
`verification_expiration`, `password_reset_expiration` is present;
|
||||
- `is_verified` unmarshals to `true`.
|
||||
`verification_expiration`, `password_reset_expiration`, `token_version` is present;
|
||||
- `is_verified` unmarshals to `true`;
|
||||
- `display_name` unmarshals to `""`.
|
||||
5. Rejected verify: same seed, `{"code":"wrong-code"}` → assert status `403` and that the raw
|
||||
response body bytes, treated as a string, contain none of the substrings `password`,
|
||||
`verification_code`, `password_reset_code`, `verification_expiration`,
|
||||
@@ -55,6 +56,6 @@ Package: `test` (black-box), extending `test/account_verification_http_test.go`'
|
||||
render; confirm by hand that no SPA code reads `password`, `verification_code` or
|
||||
`password_reset_code` off the verify response, since removing them from the wire format could
|
||||
otherwise silently break a screen that isn't covered by this Go test suite.
|
||||
- Confirm the new response type lives in `api/handlers/auth` (not a change to
|
||||
- Confirm the public response type lives in the shared package under `api/pkg` (not a change to
|
||||
`internal/domain/user/userDomain.go`'s json tags), per the project's stated preference to keep
|
||||
the domain struct's tags intact and shape the public response in the handler layer instead.
|
||||
@@ -13,6 +13,7 @@ var ErrVerificationCodeExpired = errors.New("verification code expired")
|
||||
var ErrAlreadyVerified = errors.New("user already verified")
|
||||
var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
|
||||
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
|
||||
var ErrInvalidDisplayName = errors.New("invalid display name")
|
||||
|
||||
type User struct {
|
||||
ID uint32 `json:"id"`
|
||||
@@ -20,6 +21,7 @@ type User struct {
|
||||
Password string `json:"password"`
|
||||
PasswordResetCode string `json:"password_reset_code"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
VerificationCode string `json:"verification_code"`
|
||||
VerificationExpiration time.Time `json:"verification_expiration"`
|
||||
|
||||
@@ -14,4 +14,5 @@ type Repository interface {
|
||||
UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (User, error)
|
||||
ClearVerificationCode(userId uint32) (User, error)
|
||||
ResetPassword(userId uint32, hashedPassword string) (User, error)
|
||||
UpdateDisplayName(userId uint32, displayName string) (User, error)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func UserToDomain(m models.User) user.User {
|
||||
Password: m.Password,
|
||||
PasswordResetCode: nullableStringToDomain(m.PasswordResetCode),
|
||||
Email: m.Email,
|
||||
DisplayName: m.DisplayName,
|
||||
IsVerified: m.IsVerified,
|
||||
VerificationCode: nullableStringToDomain(m.VerificationCode),
|
||||
VerificationExpiration: m.VerificationExpiration,
|
||||
@@ -42,6 +43,7 @@ func UserToModel(a user.User) models.User {
|
||||
Password: a.Password,
|
||||
PasswordResetCode: DomainToNullableString(a.PasswordResetCode),
|
||||
Email: a.Email,
|
||||
DisplayName: a.DisplayName,
|
||||
IsVerified: a.IsVerified,
|
||||
VerificationCode: DomainToNullableString(a.VerificationCode),
|
||||
VerificationExpiration: a.VerificationExpiration,
|
||||
|
||||
@@ -10,6 +10,7 @@ type User struct {
|
||||
Password string `gorm:"not null"`
|
||||
PasswordResetCode *string `gorm:"unique"`
|
||||
Email string `gorm:"unique;not null"`
|
||||
DisplayName string `gorm:"not null;default:''"`
|
||||
IsVerified bool `gorm:"default=false;not null"`
|
||||
VerificationCode *string `gorm:"unique"`
|
||||
VerificationExpiration time.Time
|
||||
|
||||
@@ -169,4 +169,20 @@ func (u *UserRepository) ResetPassword(userId uint32, hashedPassword string) (do
|
||||
return mappers.UserToDomain(localUser), nil
|
||||
}
|
||||
|
||||
func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (domainUser.User, error) {
|
||||
var localUser entity.User
|
||||
err := u.db.Model(&entity.User{}).First(&localUser, userId).Error
|
||||
if err != nil {
|
||||
return domainUser.User{}, mapNotFoundErr(err, domainUser.ErrUserNotFound)
|
||||
}
|
||||
|
||||
localUser.DisplayName = displayName
|
||||
err = u.db.Save(&localUser).Error
|
||||
if err != nil {
|
||||
return domainUser.User{}, err
|
||||
}
|
||||
|
||||
return mappers.UserToDomain(localUser), nil
|
||||
}
|
||||
|
||||
var _ domainUser.Repository = &UserRepository{}
|
||||
@@ -0,0 +1,25 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const maxDisplayNameRunes = 60
|
||||
|
||||
type UpdateUserProfileUseCase struct {
|
||||
userRepository user.Repository
|
||||
}
|
||||
|
||||
func NewUpdateUserProfileUseCase(userRepository user.Repository) *UpdateUserProfileUseCase {
|
||||
return &UpdateUserProfileUseCase{userRepository}
|
||||
}
|
||||
|
||||
func (u *UpdateUserProfileUseCase) UpdateDisplayName(userId uint32, displayName string) (user.User, error) {
|
||||
trimmed := strings.TrimSpace(displayName)
|
||||
if utf8.RuneCountInString(trimmed) > maxDisplayNameRunes {
|
||||
return user.User{}, user.ErrInvalidDisplayName
|
||||
}
|
||||
return u.userRepository.UpdateDisplayName(userId, trimmed)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/publicuser"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
@@ -81,7 +82,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("expected Cache-Control no-store, got %q", got)
|
||||
}
|
||||
var body auth.PublicUser
|
||||
var body publicuser.PublicUser
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("failed to unmarshal body: %v", err)
|
||||
}
|
||||
@@ -111,7 +112,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("expected Cache-Control no-store, got %q", got)
|
||||
}
|
||||
var body auth.PublicUser
|
||||
var body publicuser.PublicUser
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("failed to unmarshal body: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
userhandler "RenewCMS/api/handlers/user"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
|
||||
|
||||
func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
getUser := useCases.NewGetUserUseCase(userRepo)
|
||||
update := useCases.NewUpdateUserProfileUseCase(userRepo)
|
||||
handler := userhandler.NewHandler(update)
|
||||
meHandler := auth.NewMeHandler(getUser)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
true,
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/user", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Use(middleware.RequireVerifiedUser(getUser))
|
||||
r.Patch("/me", handler.UpdateMe)
|
||||
})
|
||||
r.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Post("/login", loginHandler.Login)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func displayNameCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
t.Helper()
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
return &http.Cookie{Name: "jwt", Value: tokenString}
|
||||
}
|
||||
|
||||
func sendDisplayNameRequest(router *chi.Mux, method, target, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func displayNameBody(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(map[string]string{"display_name": value})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode body: %v", err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func seededDisplayNameRepo(displayName string) *mockUserRepo {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[7] = user.User{
|
||||
ID: 7,
|
||||
Username: "marta",
|
||||
Email: "marta@renew.example",
|
||||
Password: "hashed:correct-horse",
|
||||
IsVerified: true,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func decodeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatalf("failed to decode body %q: %v", string(body), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
decoded := decodeKeys(t, body)
|
||||
if len(decoded) != len(displayNameResponseKeys) {
|
||||
t.Errorf("expected exactly %d keys, got %d: %s", len(displayNameResponseKeys), len(decoded), string(body))
|
||||
}
|
||||
for _, key := range displayNameResponseKeys {
|
||||
if _, present := decoded[key]; !present {
|
||||
t.Errorf("expected key %q in body %s", key, string(body))
|
||||
}
|
||||
}
|
||||
assertNoForbiddenKeys(t, body)
|
||||
return decoded
|
||||
}
|
||||
|
||||
func decodedString(t *testing.T, decoded map[string]json.RawMessage, key string) string {
|
||||
t.Helper()
|
||||
var value string
|
||||
if err := json.Unmarshal(decoded[key], &value); err != nil {
|
||||
t.Fatalf("failed to decode %s: %v", key, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestUpdateUserProfileUseCase(t *testing.T) {
|
||||
newRepo := func() *mockUserRepo {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[7] = user.User{ID: 7, DisplayName: "Old"}
|
||||
return repo
|
||||
}
|
||||
|
||||
successCases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"trims surrounding whitespace", " Marta ", "Marta"},
|
||||
{"empty clears", "", ""},
|
||||
{"whitespace only clears", " ", ""},
|
||||
{"60 emoji runes", strings.Repeat("😀", 60), strings.Repeat("😀", 60)},
|
||||
}
|
||||
for _, tc := range successCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
got, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.DisplayName != tc.want || repo.users[7].DisplayName != tc.want {
|
||||
t.Errorf("expected %q, got %q stored %q", tc.want, got.DisplayName, repo.users[7].DisplayName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("61 emoji runes are refused without calling the repository", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
_, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, strings.Repeat("😀", 61))
|
||||
if !errors.Is(err, user.ErrInvalidDisplayName) {
|
||||
t.Fatalf("expected ErrInvalidDisplayName, got %v", err)
|
||||
}
|
||||
if repo.updateDisplayNameCalls != 0 || repo.users[7].DisplayName != "Old" {
|
||||
t.Errorf("repository must be untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown user", func(t *testing.T) {
|
||||
_, err := useCases.NewUpdateUserProfileUseCase(newRepo()).UpdateDisplayName(99, "Marta")
|
||||
if !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repository failure is returned unchanged", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
repo.fail.failNext("UpdateDisplayName")
|
||||
_, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, "Marta")
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserDisplayNameHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
|
||||
patch := func(t *testing.T, repo *mockUserRepo, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
return sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", body, displayNameCookie(t, token, 7, 0))
|
||||
}
|
||||
|
||||
t.Run("A verified user sets a display name", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
cookie := displayNameCookie(t, token, 7, 0)
|
||||
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta Alves"}`, cookie)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
decoded := assertSevenKeys(t, rec.Body.Bytes())
|
||||
var id uint32
|
||||
_ = json.Unmarshal(decoded["id"], &id)
|
||||
if id != 7 || decodedString(t, decoded, "username") != "marta" || decodedString(t, decoded, "display_name") != "Marta Alves" {
|
||||
t.Errorf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
if repo.users[7].DisplayName != "Marta Alves" || repo.users[7].Username != "marta" {
|
||||
t.Errorf("unexpected stored user: %+v", repo.users[7])
|
||||
}
|
||||
})
|
||||
|
||||
trimCases := []struct{ name, sent, returned string }{
|
||||
{"spaces", " Marta Alves ", "Marta Alves"},
|
||||
{"tab and newline", "\tMarta Alves\n", "Marta Alves"},
|
||||
{"interior spaces kept", "Marta Alves", "Marta Alves"},
|
||||
}
|
||||
for _, tc := range trimCases {
|
||||
t.Run("The value is trimmed before it is stored and returned: "+tc.name, func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
rec := patch(t, repo, displayNameBody(t, tc.sent))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "display_name"); got != tc.returned {
|
||||
t.Errorf("expected %q, got %q", tc.returned, got)
|
||||
}
|
||||
if repo.users[7].DisplayName != tc.returned {
|
||||
t.Errorf("expected stored %q, got %q", tc.returned, repo.users[7].DisplayName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, sent := range []string{"", " ", "\t \n"} {
|
||||
t.Run("An empty or whitespace-only value clears the display name: "+displayNameBody(t, sent), func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("Marta Alves")
|
||||
rec := patch(t, repo, displayNameBody(t, sent))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
if decodedString(t, decoded, "display_name") != "" || decodedString(t, decoded, "username") != "marta" {
|
||||
t.Errorf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
if repo.users[7].DisplayName != "" {
|
||||
t.Errorf("expected cleared, got %q", repo.users[7].DisplayName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
limitCases := []struct {
|
||||
name string
|
||||
count int
|
||||
unit string
|
||||
status int
|
||||
}{
|
||||
{"60 a", 60, "a", http.StatusOK},
|
||||
{"61 a", 61, "a", http.StatusBadRequest},
|
||||
{"60 emoji", 60, "😀", http.StatusOK},
|
||||
{"61 emoji", 61, "😀", http.StatusBadRequest},
|
||||
{"60 e-acute", 60, "é", http.StatusOK},
|
||||
}
|
||||
for _, tc := range limitCases {
|
||||
t.Run("The limit is 60 Unicode characters, counted as runes: "+tc.name, func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
value := strings.Repeat(tc.unit, tc.count)
|
||||
rec := patch(t, repo, displayNameBody(t, value))
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("expected %d, got %d: %s", tc.status, rec.Code, rec.Body.String())
|
||||
}
|
||||
if tc.status == http.StatusOK {
|
||||
if repo.users[7].DisplayName != value {
|
||||
t.Errorf("value not stored")
|
||||
}
|
||||
return
|
||||
}
|
||||
if rec.Body.String() != apperr.DisplayNameErrorMessage+"\n" {
|
||||
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||
}
|
||||
if repo.users[7].DisplayName != "" || repo.updateDisplayNameCalls != 0 {
|
||||
t.Errorf("repository must be untouched")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("The limit applies after trimming, not before", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
core := strings.Repeat("a", 60)
|
||||
rec := patch(t, repo, displayNameBody(t, strings.Repeat(" ", 5)+core+strings.Repeat(" ", 5)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "display_name"); got != core {
|
||||
t.Errorf("unexpected display_name %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Control characters inside the name are kept", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
rec := patch(t, repo, `{"display_name": "Ma\nrta"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := decodedString(t, decodeKeys(t, rec.Body.Bytes()), "display_name"); got != "Ma\nrta" {
|
||||
t.Errorf("unexpected display_name %q", got)
|
||||
}
|
||||
if repo.users[7].DisplayName != "Ma\nrta" {
|
||||
t.Errorf("unexpected stored %q", repo.users[7].DisplayName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A rejected value leaves the existing display name untouched", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("Marta Alves")
|
||||
rec := patch(t, repo, displayNameBody(t, strings.Repeat("a", 61)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
if repo.updateDisplayNameCalls != 0 || repo.users[7].DisplayName != "Marta Alves" {
|
||||
t.Errorf("repository must be untouched")
|
||||
}
|
||||
})
|
||||
|
||||
malformedBodies := []string{"not json", `{}`, `{"display_name": null}`, `{"display_name": 42}`, `{"displayName": "Marta"}`}
|
||||
for _, body := range malformedBodies {
|
||||
t.Run("A malformed body is refused with 400: "+body, func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("Marta Alves")
|
||||
rec := patch(t, repo, body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if repo.updateDisplayNameCalls != 0 || repo.users[7].DisplayName != "Marta Alves" {
|
||||
t.Errorf("repository must be untouched")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("The password, token version and login username are unaffected by a display name", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
cookie := displayNameCookie(t, token, 7, 0)
|
||||
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta Alves"}`, cookie)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if repo.users[7].Password != "hashed:correct-horse" || repo.users[7].TokenVersion != 0 {
|
||||
t.Errorf("password or token version changed: %+v", repo.users[7])
|
||||
}
|
||||
me := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", cookie)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 from /me, got %d", me.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /v1/auth/me returns the display name once set", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("Marta Alves")
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 7, 0))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
decoded := assertSevenKeys(t, rec.Body.Bytes())
|
||||
if decodedString(t, decoded, "display_name") != "Marta Alves" || decodedString(t, decoded, "username") != "marta" {
|
||||
t.Errorf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /v1/auth/me returns an empty display_name, not a missing key, when none is set", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 8, 0))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
decoded := assertSevenKeys(t, rec.Body.Bytes())
|
||||
if decodedString(t, decoded, "display_name") != "" {
|
||||
t.Errorf("expected empty display_name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Logging in still uses the username only", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("Ines Duarte")
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
|
||||
rec := sendDisplayNameRequest(router, http.MethodPost, "/v1/auth/login", `{"username":"Ines Duarte","password":"correct-horse"}`, nil)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.LogsErrorMessage+"\n" {
|
||||
t.Errorf("expected 403 logs error, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = sendDisplayNameRequest(router, http.MethodPost, "/v1/auth/login", `{"username":"marta","password":"correct-horse"}`, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unverified user is refused", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Bob"}`, displayNameCookie(t, token, 8, 0))
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
|
||||
t.Errorf("expected 403 verification required, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if repo.updateDisplayNameCalls != 0 || repo.users[8].DisplayName != "" {
|
||||
t.Errorf("repository must be untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No jwt cookie is refused", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta"}`, nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
if repo.updateDisplayNameCalls != 0 {
|
||||
t.Errorf("repository must be untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A stale jwt is refused", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
u := repo.users[7]
|
||||
u.TokenVersion = 1
|
||||
repo.users[7] = u
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta"}`, displayNameCookie(t, token, 7, 0))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("expected 401 unauthorized, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if repo.updateDisplayNameCalls != 0 {
|
||||
t.Errorf("repository must be untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
router := newDisplayNameRouter(repo, token)
|
||||
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Marta"}`, displayNameCookie(t, token, 999, 0))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("expected 401 unauthorized, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A repository failure while saving answers 500", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
repo.fail.failNext("UpdateDisplayName")
|
||||
rec := patch(t, repo, `{"display_name": "Marta"}`)
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
|
||||
t.Errorf("expected 500, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The user is deleted between authentication and the save", func(t *testing.T) {
|
||||
repo := seededDisplayNameRepo("")
|
||||
repo.fail.failNextWith("UpdateDisplayName", user.ErrUserNotFound)
|
||||
rec := patch(t, repo, `{"display_name": "Marta"}`)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("expected 401 unauthorized, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -76,6 +76,7 @@ var forbiddenUserFieldKeys = []string{
|
||||
"verification_code",
|
||||
"verification_expiration",
|
||||
"password_reset_expiration",
|
||||
"token_version",
|
||||
}
|
||||
|
||||
func assertNoForbiddenKeys(t *testing.T, body []byte) {
|
||||
@@ -124,7 +125,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at"}
|
||||
expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name"}
|
||||
if len(decoded) != len(expectedKeys) {
|
||||
t.Errorf("expected exactly %d keys, got %d: %v", len(expectedKeys), len(decoded), decoded)
|
||||
}
|
||||
@@ -135,6 +136,11 @@ func TestUserResponseFieldHygiene(t *testing.T) {
|
||||
}
|
||||
assertNoForbiddenKeys(t, rec.Body.Bytes())
|
||||
|
||||
var displayName string
|
||||
if err := json.Unmarshal(decoded["display_name"], &displayName); err != nil || displayName != "" {
|
||||
t.Errorf("expected display_name to decode to an empty string, got %q (%v)", displayName, err)
|
||||
}
|
||||
|
||||
var isVerified bool
|
||||
if err := json.Unmarshal(decoded["is_verified"], &isVerified); err != nil {
|
||||
t.Fatalf("failed to decode is_verified: %v", err)
|
||||
|
||||
@@ -44,6 +44,8 @@ type mockUserRepo struct {
|
||||
resetPasswordCalls int
|
||||
lastResetPasswordUserId uint32
|
||||
lastResetPasswordHash string
|
||||
updateDisplayNameCalls int
|
||||
lastUpdateDisplayName string
|
||||
}
|
||||
|
||||
func newMockUserRepo() *mockUserRepo {
|
||||
@@ -210,6 +212,21 @@ func (m *mockUserRepo) ResetPassword(userId uint32, hashedPassword string) (user
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) UpdateDisplayName(userId uint32, displayName string) (user.User, error) {
|
||||
m.updateDisplayNameCalls++
|
||||
m.lastUpdateDisplayName = displayName
|
||||
if err := m.fail.trigger("UpdateDisplayName"); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
u, ok := m.users[userId]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
u.DisplayName = displayName
|
||||
m.users[userId] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
var _ user.Repository = &mockUserRepo{}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user