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