Files
RenewCMS/test/user_display_name_test.go
T

472 lines
17 KiB
Go

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", "role"}
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, discardRecorder),
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())
}
})
}