From a3777075da8656f779c5576d4a8330860810190c Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 17 Aug 2026 21:39:11 -0600 Subject: [PATCH 1/3] fix: bind email-only DTO on resendRegistrationToken (#361) POST /user/resendRegistrationToken bound the registration UserDto, whose firstName, lastName, password, and matchingPassword fields are all @NotBlank. A resend request carries only an email, so validation always failed and the endpoint returned HTTP 400 without sending mail. Bind a new ResendVerificationDto carrying just an @NotBlank @Email email. The handler body already used only the email, and the generic anti-enumeration response is unchanged. Clients still posting the full registration payload keep working, since the extra fields are ignored rather than rejected. POST /user/resetPassword was checked for the same problem: it already binds PasswordResetRequestDto, so no change was needed there. Closes #361 --- .../spring/user/api/UserAPI.java | 7 +- .../user/dto/ResendVerificationDto.java | 26 ++++++ .../spring/user/api/UserAPIUnitTest.java | 81 ++++++++++++++++++- 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/dto/ResendVerificationDto.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java index 69ccc963..c2334a6c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -22,6 +22,7 @@ import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.PasswordResetRequestDto; import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; +import com.digitalsanctuary.spring.user.dto.ResendVerificationDto; import com.digitalsanctuary.spring.user.dto.SavePasswordDto; import com.digitalsanctuary.spring.user.dto.SetPasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; @@ -183,19 +184,19 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User * Resends the registration token. This is used when the user did not receive * the initial registration email. * - * @param userDto the user data transfer object containing user details + * @param resendVerificationDto the DTO containing the email address to resend the verification email to * @param request the HTTP servlet request * @return a ResponseEntity containing a JSONResponse with the registration * result */ @PostMapping("/resendRegistrationToken") - public ResponseEntity resendRegistrationToken(@Valid @RequestBody UserDto userDto, + public ResponseEntity resendRegistrationToken(@Valid @RequestBody ResendVerificationDto resendVerificationDto, HttpServletRequest request) { // Anti-enumeration: this endpoint ALWAYS returns the same generic 200 response, regardless of // whether the email is unknown, already verified, or genuinely awaiting verification. Internally // we only send the verification email when the account exists AND is still unverified. The true // outcome is recorded server-side via audit/log events so operators retain visibility. - User user = userService.findUserByEmail(userDto.getEmail()); + User user = userService.findUserByEmail(resendVerificationDto.getEmail()); if (user == null) { log.info("Resend verification requested for unknown email; returning generic response."); logAuditEvent("Resend Reg Token", "Failure", "Unknown Email", null, request); diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/ResendVerificationDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/ResendVerificationDto.java new file mode 100644 index 00000000..dfbb8f85 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/ResendVerificationDto.java @@ -0,0 +1,26 @@ +package com.digitalsanctuary.spring.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * Data Transfer Object for resending a registration verification email. + *

+ * Contains only the email address needed to resend the verification email. Binding this instead of + * the registration {@link UserDto} keeps the endpoint from requiring name and password fields a + * resend request has no reason to carry. + *

+ * + * @author Devon Hillard + */ +@Data +public class ResendVerificationDto { + + /** The email address to resend the verification email to. */ + @NotBlank(message = "Email is required") + @Email(message = "Please provide a valid email address") + @Size(max = 100, message = "Email must not exceed 100 characters") + private String email; +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java index 33036366..13a76ed8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java @@ -26,6 +26,7 @@ import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.SetPasswordDto; +import com.digitalsanctuary.spring.user.dto.ResendVerificationDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto; import com.digitalsanctuary.spring.user.security.StepUpService; @@ -343,7 +344,7 @@ void resendRegistrationToken_success() throws Exception { // When & Then mockMvc.perform(post("/user/resendRegistrationToken") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(testUserDto)) + .content(resendJson(testUserDto.getEmail())) .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) @@ -363,7 +364,7 @@ void resendRegistrationToken_alreadyVerified_returnsUniformResponse() throws Exc // When & Then - same response as the unverified case, and no email is sent mockMvc.perform(post("/user/resendRegistrationToken") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(testUserDto)) + .content(resendJson(testUserDto.getEmail())) .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) @@ -383,7 +384,7 @@ void resendRegistrationToken_unknownEmail_returnsUniformResponse() throws Except // When & Then - same uniform 200 response; nothing leaks existence mockMvc.perform(post("/user/resendRegistrationToken") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(testUserDto)) + .content(resendJson(testUserDto.getEmail())) .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) @@ -393,6 +394,80 @@ void resendRegistrationToken_unknownEmail_returnsUniformResponse() throws Except verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString()); } + + @Test + @DisplayName("POST /user/resendRegistrationToken - email-only body with no name or password is accepted") + void resendRegistrationToken_emailOnlyBody_isAccepted() throws Exception { + // Given - the body the resend page actually posts: just an email, no name or password fields + User unverifiedUser = UserTestDataBuilder.aUser() + .withEmail(testUserDto.getEmail()) + .disabled() + .build(); + when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(unverifiedUser); + when(appUrlResolver.resolveAppUrl(any())).thenReturn("http://localhost:8080"); + + // When & Then - no 400 from registration-only validation constraints + mockMvc.perform(post("/user/resendRegistrationToken") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + testUserDto.getEmail() + "\"}") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + verify(userEmailService).sendRegistrationVerificationEmail(eq(unverifiedUser), anyString()); + } + + @Test + @DisplayName("POST /user/resendRegistrationToken - blank email is rejected with 400 and sends no email") + void resendRegistrationToken_blankEmail_returnsBadRequest() throws Exception { + mockMvc.perform(post("/user/resendRegistrationToken") + .contentType(MediaType.APPLICATION_JSON) + .content(resendJson("")) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString()); + } + + @Test + @DisplayName("POST /user/resendRegistrationToken - malformed email is rejected with 400 and sends no email") + void resendRegistrationToken_malformedEmail_returnsBadRequest() throws Exception { + mockMvc.perform(post("/user/resendRegistrationToken") + .contentType(MediaType.APPLICATION_JSON) + .content(resendJson("not-an-email")) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString()); + } + + @Test + @DisplayName("POST /user/resendRegistrationToken - a legacy full registration payload is still accepted") + void resendRegistrationToken_legacyFullUserDtoPayload_isAccepted() throws Exception { + // Given - clients built against the old signature still post the whole registration UserDto + User unverifiedUser = UserTestDataBuilder.aUser() + .withEmail(testUserDto.getEmail()) + .disabled() + .build(); + when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(unverifiedUser); + when(appUrlResolver.resolveAppUrl(any())).thenReturn("http://localhost:8080"); + + // When & Then - the extra fields are ignored rather than rejected + mockMvc.perform(post("/user/resendRegistrationToken") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUserDto)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + verify(userEmailService).sendRegistrationVerificationEmail(eq(unverifiedUser), anyString()); + } + + private String resendJson(String email) throws Exception { + ResendVerificationDto dto = new ResendVerificationDto(); + dto.setEmail(email); + return objectMapper.writeValueAsString(dto); + } } @Nested From 119d8992597cd52d09989390549c3cab137ef688 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 17 Aug 2026 21:43:28 -0600 Subject: [PATCH 2/3] style: order new test import alphabetically --- .../com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java index 13a76ed8..e15ce051 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java @@ -25,8 +25,8 @@ import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.dto.PasswordDto; -import com.digitalsanctuary.spring.user.dto.SetPasswordDto; import com.digitalsanctuary.spring.user.dto.ResendVerificationDto; +import com.digitalsanctuary.spring.user.dto.SetPasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto; import com.digitalsanctuary.spring.user.security.StepUpService; From b7a13e9d9ce19c2be2e7b9a1c6c45245086468a9 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 17 Aug 2026 21:45:18 -0600 Subject: [PATCH 3/3] fix: address PR review feedback on resend endpoint - Build the email-only test payload with ObjectMapper instead of hand-concatenated JSON, so an email needing escaping can't break the test. - Correct the resendRegistrationToken @return javadoc: it describes a generic resend response, not a registration result. --- .../java/com/digitalsanctuary/spring/user/api/UserAPI.java | 4 ++-- .../com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java index c2334a6c..5360e1eb 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -186,8 +186,8 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User * * @param resendVerificationDto the DTO containing the email address to resend the verification email to * @param request the HTTP servlet request - * @return a ResponseEntity containing a JSONResponse with the registration - * result + * @return a ResponseEntity containing a generic JSONResponse that is identical whether or not a + * verification email was actually resent */ @PostMapping("/resendRegistrationToken") public ResponseEntity resendRegistrationToken(@Valid @RequestBody ResendVerificationDto resendVerificationDto, diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java index e15ce051..702b7b5d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java @@ -22,6 +22,7 @@ import ch.qos.logback.core.read.ListAppender; import java.util.Collections; import java.util.Locale; +import java.util.Map; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.dto.PasswordDto; @@ -409,7 +410,7 @@ void resendRegistrationToken_emailOnlyBody_isAccepted() throws Exception { // When & Then - no 400 from registration-only validation constraints mockMvc.perform(post("/user/resendRegistrationToken") .contentType(MediaType.APPLICATION_JSON) - .content("{\"email\":\"" + testUserDto.getEmail() + "\"}") + .content(objectMapper.writeValueAsString(Map.of("email", testUserDto.getEmail()))) .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true));