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