package test import ( "RenewCMS/api/handlers/auth" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" ) func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux { hasher := &stubHasher{} loginHandler := auth.NewLoginHandler( token, useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) verificationHandler := auth.NewVerificationHandler( useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder), useCases.NewCancelRegistrationUseCase(userRepo), true, ) passwordResetHandler := auth.NewPasswordResetHandler( useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder), ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { r.Post("/register", loginHandler.Register) r.Post("/login", loginHandler.Login) r.Post("/logout", loginHandler.Logout) r.Post("/password-reset", passwordResetHandler.RequestPasswordReset) r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword) r.Group(func(r chi.Router) { r.Use(jwtauth.Verifier(token)) r.Use(jwtauth.Authenticator(token)) r.Post("/verify", verificationHandler.Verify) }) }) }) return r } func newFieldHygieneVerifiedAliceRepo() *mockUserRepo { repo := newMockUserRepo() repo.users[7] = user.User{ ID: 7, Username: "alice", Email: "alice@example.com", Password: "hashed:hunter2example12", IsVerified: true, PasswordResetCode: "hashed:some-reset-code", PasswordResetExpiration: time.Now().Add(time.Hour), VerificationCode: "hashed:some-verification-code", VerificationExpiration: time.Now().Add(time.Hour), } repo.nextID = 7 return repo } var forbiddenUserFieldKeys = []string{ "password", "password_reset_code", "verification_code", "verification_expiration", "password_reset_expiration", "token_version", } func assertNoForbiddenKeys(t *testing.T, body []byte) { t.Helper() var decoded map[string]json.RawMessage if err := json.Unmarshal(body, &decoded); err != nil { return } for _, key := range forbiddenUserFieldKeys { if _, present := decoded[key]; present { t.Errorf("expected no key %q in response body, got %s", key, string(body)) } } } func TestUserResponseFieldHygiene(t *testing.T) { validator.InitValidator() token := jwtauth.New("HS256", []byte("test-secret"), nil) t.Run("A successful verification response carries only public fields", func(t *testing.T) { repo := newMockUserRepo() repo.users[7] = user.User{ ID: 7, Username: "alice", Email: "alice@example.com", IsVerified: false, VerificationCode: "hashed:abc123", VerificationExpiration: time.Now().Add(time.Hour), } repo.nextID = 7 router := newUserFieldHygieneRouter(repo, newMockMailRepo(), token) body, _ := json.Marshal(map[string]string{"code": "abc123"}) req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body)) _, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) } var decoded map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("failed to decode response: %v", err) } expectedKeys := []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} if len(decoded) != len(expectedKeys) { t.Errorf("expected exactly %d keys, got %d: %v", len(expectedKeys), len(decoded), decoded) } for _, key := range expectedKeys { if _, present := decoded[key]; !present { t.Errorf("expected key %q in response body", key) } } 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) } if !isVerified { t.Errorf("expected is_verified true") } }) t.Run("A rejected verification attempt never echoes any user field", func(t *testing.T) { repo := newMockUserRepo() repo.users[7] = user.User{ ID: 7, Username: "alice", IsVerified: false, VerificationCode: "hashed:abc123", VerificationExpiration: time.Now().Add(time.Hour), } repo.nextID = 7 router := newUserFieldHygieneRouter(repo, newMockMailRepo(), token) body, _ := json.Marshal(map[string]string{"code": "wrong-code"}) req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body)) _, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) } if rec.Body.String() != apperr.VerificationFailedErrorMessage+"\n" { t.Errorf("expected plain-text apperr.VerificationFailedErrorMessage body, got %q", rec.Body.String()) } var decoded map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err == nil { t.Errorf("expected non-JSON body, but it decoded as JSON: %v", decoded) } }) t.Run("Registering while no verified account exists never echoes any user field", func(t *testing.T) { repo := newMockUserRepo() router := newUserFieldHygieneRouter(repo, newMockMailRepo(), token) body := `{"username":"bob","password":"hunter2example12","email":"bob@example.com"}` req := httptest.NewRequest(http.MethodPost, "/v1/auth/register", bytes.NewBufferString(body)) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) } assertNoForbiddenKeys(t, rec.Body.Bytes()) }) outlineCases := []struct { name string method string path string body string status int }{ { name: "login", method: http.MethodPost, path: "/v1/auth/login", body: `{"username":"alice","password":"hunter2example12"}`, status: http.StatusOK, }, { name: "logout", method: http.MethodPost, path: "/v1/auth/logout", body: `{}`, status: http.StatusOK, }, { name: "password-reset", method: http.MethodPost, path: "/v1/auth/password-reset", body: `{"email":"alice@example.com"}`, status: http.StatusOK, }, { name: "password-reset/confirm", method: http.MethodPost, path: "/v1/auth/password-reset/confirm", body: `{"email":"alice@example.com","code":"wrong","password":"hunter2example12"}`, status: http.StatusForbidden, }, } for _, tc := range outlineCases { t.Run("No other auth endpoint under /v1/auth leaks a user field: "+tc.name, func(t *testing.T) { repo := newFieldHygieneVerifiedAliceRepo() router := newUserFieldHygieneRouter(repo, newMockMailRepo(), token) req := httptest.NewRequest(tc.method, tc.path, bytes.NewBufferString(tc.body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tc.status { t.Fatalf("expected status %d, got %d: %s", tc.status, rec.Code, rec.Body.String()) } assertNoForbiddenKeys(t, rec.Body.Bytes()) }) } }