243 lines
14 KiB
Gherkin
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) |
|