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
@@ -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.
|
||||
Reference in new issue
Block a user