Files
RenewCMS/features/user_display_name.feature

243 lines
14 KiB
Gherkin

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