diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..5ba128f --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,387 @@ +# Migration Guide + +This guide covers migrating applications using the Spring User Framework between major versions. + +## Table of Contents + +- [Migration Guide](#migration-guide) + - [Table of Contents](#table-of-contents) + - [Migrating to 4.0.x (Spring Boot 4.0)](#migrating-to-40x-spring-boot-40) + - [Prerequisites](#prerequisites) + - [Step 1: Update Java Version](#step-1-update-java-version) + - [Step 2: Update Dependencies](#step-2-update-dependencies) + - [Step 3: Spring Security 7 Changes](#step-3-spring-security-7-changes) + - [URL Pattern Requirements](#url-pattern-requirements) + - [Security Configuration Changes](#security-configuration-changes) + - [Step 4: Update Test Infrastructure](#step-4-update-test-infrastructure) + - [Test Dependency Changes](#test-dependency-changes) + - [Test Annotation Import Changes](#test-annotation-import-changes) + - [Step 5: Jackson 3 Changes](#step-5-jackson-3-changes) + - [Step 6: API Changes](#step-6-api-changes) + - [Profile Update Endpoint](#profile-update-endpoint) + - [Step 7: Configuration Changes](#step-7-configuration-changes) + - [For Developers Extending the Framework](#for-developers-extending-the-framework) + - [Extending Security Configuration](#extending-security-configuration) + - [Custom User Services](#custom-user-services) + - [Custom Controllers](#custom-controllers) + - [Event Listeners](#event-listeners) + - [Troubleshooting](#troubleshooting) + - [Common Issues](#common-issues) + - [Version Compatibility Matrix](#version-compatibility-matrix) + +## Migrating to 4.0.x (Spring Boot 4.0) + +This section covers migrating from Spring User Framework 3.x (Spring Boot 3.x) to 4.x (Spring Boot 4.0). + +### Prerequisites + +Before starting the migration: + +1. Ensure your application is running on the latest 3.5.x version +2. Review your custom security configurations +3. Audit any code that extends framework classes +4. Back up your database (schema changes are minimal but recommended) + +### Step 1: Update Java Version + +**Spring Boot 4.0 requires Java 21 or higher.** + +Update your build configuration: + +**Gradle:** +```groovy +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} +``` + +**Maven:** +```xml + + 21 + +``` + +Ensure your CI/CD pipelines and deployment environments support Java 21. + +### Step 2: Update Dependencies + +Update the framework dependency version: + +**Gradle:** +```groovy +implementation 'com.digitalsanctuary:ds-spring-user-framework:4.0.0' +``` + +**Maven:** +```xml + + com.digitalsanctuary + ds-spring-user-framework + 4.0.0 + +``` + +Update Spring Boot: +```groovy +plugins { + id 'org.springframework.boot' version '4.0.0' +} +``` + +### Step 3: Spring Security 7 Changes + +Spring Boot 4.0 includes Spring Security 7, which has breaking changes from Spring Security 6.x. + +#### URL Pattern Requirements + +**All URL patterns must now start with `/`.** + +This affects: +- `user.security.unprotectedURIs` configuration +- `user.security.protectedURIs` configuration +- Any custom security matchers in your code + +**Before (3.x):** +```yaml +user: + security: + unprotectedURIs: /,/index.html,/css/**,/js/**,error,error.html +``` + +**After (4.x):** +```yaml +user: + security: + unprotectedURIs: /,/index.html,/css/**,/js/**,/error,/error.html +``` + +Note the `/error` and `/error.html` now have leading slashes. + +#### Security Configuration Changes + +If you have custom security configuration extending or working with the framework: + +**Deprecated methods removed:** +- `authorizeRequests()` → use `authorizeHttpRequests()` +- `antMatchers()` → use `requestMatchers()` +- `mvcMatchers()` → use `requestMatchers()` + +**Example migration:** + +```java +// Before (3.x) +http.authorizeRequests() + .antMatchers("/public/**").permitAll() + .anyRequest().authenticated(); + +// After (4.x) +http.authorizeHttpRequests(authz -> authz + .requestMatchers("/public/**").permitAll() + .anyRequest().authenticated()); +``` + +### Step 4: Update Test Infrastructure + +Spring Boot 4.0 introduces modular test packages. + +#### Test Dependency Changes + +Add the new modular test starters: + +**Gradle:** +```groovy +testImplementation 'org.springframework.boot:spring-boot-starter-test' +testImplementation 'org.springframework.boot:spring-boot-data-jpa-test' +testImplementation 'org.springframework.boot:spring-boot-webmvc-test' +testImplementation 'org.springframework.boot:spring-boot-starter-security-test' +testImplementation 'org.springframework.security:spring-security-test' +``` + +**Maven:** +```xml + + org.springframework.boot + spring-boot-data-jpa-test + test + + + org.springframework.boot + spring-boot-webmvc-test + test + +``` + +#### Test Annotation Import Changes + +Update imports for test annotations: + +| Annotation | Old Package (3.x) | New Package (4.x) | +|------------|-------------------|-------------------| +| `@AutoConfigureMockMvc` | `org.springframework.boot.test.autoconfigure.web.servlet` | `org.springframework.boot.webmvc.test.autoconfigure` | +| `@WebMvcTest` | `org.springframework.boot.test.autoconfigure.web.servlet` | `org.springframework.boot.webmvc.test.autoconfigure` | +| `@DataJpaTest` | `org.springframework.boot.test.autoconfigure.orm.jpa` | `org.springframework.boot.data.jpa.test.autoconfigure` | +| `@AutoConfigureTestDatabase` | `org.springframework.boot.test.autoconfigure.jdbc` | `org.springframework.boot.jdbc.test.autoconfigure` | + +**Example:** +```java +// Before (3.x) +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +// After (4.x) +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +``` + +### Step 5: Jackson 3 Changes + +Spring Boot 4.0 uses Jackson 3.x for JSON processing. + +**ObjectMapper instantiation:** +```java +// Before (Jackson 2.x) +ObjectMapper mapper = new ObjectMapper(); + +// After (Jackson 3.x) +ObjectMapper mapper = JsonMapper.builder().build(); +``` + +**Package changes:** +- Some classes moved from `com.fasterxml.jackson` to new packages +- Check any custom serializers/deserializers + +### Step 6: API Changes + +#### Profile Update Endpoint + +The `/user/updateUser` endpoint now accepts `UserProfileUpdateDto` instead of `UserDto`. + +**Before (3.x):** +```json +POST /user/updateUser +{ + "email": "user@example.com", + "firstName": "John", + "lastName": "Doe", + "password": "...", + "matchingPassword": "..." +} +``` + +**After (4.x):** +```json +POST /user/updateUser +{ + "firstName": "John", + "lastName": "Doe" +} +``` + +This change improves security by not requiring password fields for profile updates. + +**Update your frontend code** if you're calling this endpoint directly. + +### Step 7: Configuration Changes + +Review your `application.yml` for any deprecated properties: + +| Deprecated Property | Replacement | +|---------------------|-------------| +| (none currently) | - | + +Most configuration properties remain unchanged between 3.x and 4.x. + +## For Developers Extending the Framework + +If you've extended framework classes or implemented custom functionality, review these sections carefully. + +### Extending Security Configuration + +If you have a custom `WebSecurityConfig` or extend the framework's security configuration: + +1. **Ensure all URL patterns start with `/`** +2. **Update to lambda DSL style** (required in Spring Security 7) +3. **Review method security annotations** - `@PreAuthorize`, `@PostAuthorize` unchanged + +**Example custom security configuration:** +```java +@Configuration +@EnableWebSecurity +public class CustomSecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authz -> authz + // All patterns must start with / + .requestMatchers("/api/public/**").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .formLogin(form -> form + .loginPage("/user/login.html") + .permitAll() + ); + return http.build(); + } +} +``` + +### Custom User Services + +If you extend `UserService` or implement custom user management: + +1. **Method signatures unchanged** - Core service methods remain compatible +2. **Password encoding** - Still uses BCrypt, no changes required +3. **User entity** - No schema changes required + +### Custom Controllers + +If you have controllers that extend or work alongside framework controllers: + +1. **DTOs** - Update any code using `UserDto` for profile updates to use `UserProfileUpdateDto` +2. **Validation** - Bean validation works the same way +3. **Response format** - `JSONResponse` unchanged + +### Event Listeners + +Event handling remains unchanged: + +- `OnRegistrationCompleteEvent` +- `UserPreDeleteEvent` +- `AuditEvent` + +All events fire as before with the same payload structures. + +## Troubleshooting + +### Common Issues + +**Issue: `pattern must start with a /`** + +This error occurs when URL patterns in security configuration don't start with `/`. + +**Solution:** Review all entries in: +- `user.security.unprotectedURIs` +- `user.security.protectedURIs` +- Any custom `requestMatchers()` calls + +Ensure every pattern starts with `/`. + +--- + +**Issue: `ClassNotFoundException` for test annotations** + +Spring Boot 4.0 moved test annotations to new packages. + +**Solution:** +1. Add the modular test dependencies (see [Step 4](#step-4-update-test-infrastructure)) +2. Update imports to new package locations + +--- + +**Issue: `NoClassDefFoundError: com/fasterxml/jackson/...`** + +Jackson 3 has different package structures. + +**Solution:** Update ObjectMapper instantiation and check custom serializers. + +--- + +**Issue: Profile update returns validation error for password** + +The `/user/updateUser` endpoint now uses `UserProfileUpdateDto`. + +**Solution:** Update your frontend to only send `firstName` and `lastName` fields. + +--- + +**Issue: Java version incompatibility** + +Spring Boot 4.0 requires Java 21. + +**Solution:** +1. Update your JDK to 21+ +2. Update build configuration +3. Update CI/CD pipelines +4. Update deployment environments + +## Version Compatibility Matrix + +| Framework Version | Spring Boot | Spring Security | Java | Status | +|-------------------|-------------|-----------------|------|--------| +| 4.0.x | 4.0.x | 7.x | 21+ | Current | +| 3.5.x | 3.5.x | 6.x | 17+ | Maintained | +| 3.4.x | 3.4.x | 6.x | 17+ | Security fixes only | +| < 3.4 | < 3.4 | < 6 | 17+ | End of life | + +--- + +For additional help, see: +- [README](README.md) - Main documentation +- [Configuration Guide](CONFIG.md) - All configuration options +- [Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp) - Working example +- [GitHub Issues](https://github.com/devondragon/SpringUserFramework/issues) - Report problems diff --git a/README.md b/README.md index 91860d8..8db3bcb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![Maven Central](https://img.shields.io/maven-central/v/com.digitalsanctuary/ds-spring-user-framework.svg)](https://central.sonatype.com/artifact/com.digitalsanctuary/ds-spring-user-framework) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Java Version](https://img.shields.io/badge/Java-17%2B-brightgreen)](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html) +[![Spring Boot 4](https://img.shields.io/badge/Spring%20Boot-4.0-brightgreen)](https://spring.io/projects/spring-boot) +[![Spring Boot 3](https://img.shields.io/badge/Spring%20Boot-3.5-blue)](https://spring.io/projects/spring-boot) +[![Java Version](https://img.shields.io/badge/Java-17%20|%2021-brightgreen)](https://www.oracle.com/java/technologies/downloads/) A comprehensive Spring Boot User Management Framework that simplifies the implementation of robust user authentication and management features. Built on top of Spring Security, this library provides ready-to-use solutions for user registration, login, account management, and more. @@ -15,8 +17,9 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond - [Table of Contents](#table-of-contents) - [Features](#features) - [Installation](#installation) - - [Maven](#maven) - - [Gradle](#gradle) + - [Spring Boot 4.0 (Latest)](#spring-boot-40-latest) + - [Spring Boot 3.5 (Stable)](#spring-boot-35-stable) + - [Migration Guide](#migration-guide) - [Quick Start](#quick-start) - [Prerequisites](#prerequisites) - [Step 1: Add Dependencies](#step-1-add-dependencies) @@ -90,49 +93,105 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond ## Installation -### Maven +Choose the version that matches your Spring Boot version: +| Spring Boot Version | Framework Version | Java Version | Spring Security | +|---------------------|-------------------|--------------|-----------------| +| 4.0.x | 4.0.x | 21+ | 7.x | +| 3.5.x | 3.5.x | 17+ | 6.x | + +### Spring Boot 4.0 (Latest) + +Spring Boot 4.0 brings significant changes including Spring Security 7 and requires Java 21. + +**Maven:** ```xml com.digitalsanctuary ds-spring-user-framework - 3.5.1 + 4.0.0 ``` -### Gradle +**Gradle:** +```groovy +implementation 'com.digitalsanctuary:ds-spring-user-framework:4.0.0' +``` + +#### Spring Boot 4.0 Key Changes + +When upgrading to Spring Boot 4.0, be aware of these important changes: + +- **Java 21 Required**: Spring Boot 4.0 requires Java 21 or higher +- **Spring Security 7**: Includes breaking changes from Spring Security 6.x + - All URL patterns in security configuration must start with `/` + - Some deprecated APIs have been removed +- **Jackson 3**: JSON processing uses Jackson 3.x with some API changes +- **Modular Test Infrastructure**: Test annotations have moved to new packages: + - `@AutoConfigureMockMvc` → `org.springframework.boot.webmvc.test.autoconfigure` + - `@DataJpaTest` → `org.springframework.boot.data.jpa.test.autoconfigure` + - `@WebMvcTest` → `org.springframework.boot.webmvc.test.autoconfigure` + +For testing, you may need these additional dependencies: +```groovy +testImplementation 'org.springframework.boot:spring-boot-data-jpa-test' +testImplementation 'org.springframework.boot:spring-boot-webmvc-test' +testImplementation 'org.springframework.boot:spring-boot-starter-security-test' +``` + +**Upgrading from 3.x?** See the [Migration Guide](MIGRATION.md) for detailed upgrade instructions. + +### Spring Boot 3.5 (Stable) + +For projects using Spring Boot 3.5.x with Java 17+: + +**Maven:** +```xml + + com.digitalsanctuary + ds-spring-user-framework + 3.5.1 + +``` +**Gradle:** ```groovy implementation 'com.digitalsanctuary:ds-spring-user-framework:3.5.1' ``` +## Migration Guide + +If you're upgrading from a previous version, see the **[Migration Guide](MIGRATION.md)** for: + +- Step-by-step upgrade instructions +- Breaking changes and how to address them +- Spring Security 7 compatibility requirements +- Test infrastructure changes +- Guidance for developers extending the framework + ## Quick Start Follow these steps to get up and running with the Spring User Framework in your application. ### Prerequisites -- Java 17 or higher -- Spring Boot 3.0+ +- **For Spring Boot 4.0**: Java 21 or higher +- **For Spring Boot 3.5**: Java 17 or higher - A database (MariaDB, PostgreSQL, MySQL, H2, etc.) - SMTP server for email functionality (optional but recommended) ### Step 1: Add Dependencies -1. **Add the main framework dependency**: +1. **Add the main framework dependency** (see [Installation](#installation) for version selection): - Maven: - ```xml - - com.digitalsanctuary - ds-spring-user-framework - 3.3.0 - + **Spring Boot 4.0 (Java 21+):** + ```groovy + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.0.0' ``` - Gradle: + **Spring Boot 3.5 (Java 17+):** ```groovy - implementation 'com.digitalsanctuary:ds-spring-user-framework:3.3.0' + implementation 'com.digitalsanctuary:ds-spring-user-framework:3.5.1' ``` 2. **Add required dependencies**: @@ -663,6 +722,7 @@ We welcome contributions of all kinds! If you'd like to help improve SpringUserF ## Reference Documentation - [API Documentation](https://digitalSanctuary.github.io/SpringUserFramework/) +- [Migration Guide](MIGRATION.md) - [Configuration Guide](CONFIG.md) - [Security Guide](SECURITY.md) - [Customization Guide](CUSTOMIZATION.md) diff --git a/build.gradle b/build.gradle index 35d75ca..6278d5f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' version '3.5.7' + id 'org.springframework.boot' version '4.0.0' id 'io.spring.dependency-management' version '1.1.7' id 'com.github.ben-manes.versions' version '0.53.0' id 'java-library' @@ -17,13 +17,12 @@ group = 'com.digitalsanctuary' description = 'Spring User Framework' ext { - springBootVersion = '3.5.5' lombokVersion = '1.18.42' } java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } @@ -41,6 +40,8 @@ dependencies { compileOnly 'org.springframework.boot:spring-boot-starter-security' compileOnly 'org.springframework.boot:spring-boot-starter-thymeleaf' compileOnly 'org.springframework.boot:spring-boot-starter-web' + // Note: thymeleaf-extras-springsecurity6 is compatible with Spring Security 7 + // No springsecurity7 artifact exists yet - Thymeleaf team uses springsecurity6 for 6.x+ compileOnly 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.3.RELEASE' compileOnly 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.4.0' @@ -49,7 +50,7 @@ dependencies { implementation 'com.google.guava:guava:33.5.0-jre' implementation 'org.apache.commons:commons-text:1.15.0' compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1' - compileOnly 'org.springframework.retry:spring-retry' + compileOnly 'org.springframework.retry:spring-retry:2.0.12' // Lombok dependencies compileOnly "org.projectlombok:lombok:$lombokVersion" @@ -69,10 +70,16 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.boot:spring-boot-starter-thymeleaf' testImplementation 'org.springframework.security:spring-security-test' - testImplementation 'org.springframework.retry:spring-retry' + testImplementation 'org.springframework.retry:spring-retry:2.0.12' testImplementation 'jakarta.validation:jakarta.validation-api:3.1.1' + testImplementation 'org.hibernate.validator:hibernate-validator:8.0.2.Final' testImplementation 'com.h2database:h2:2.4.240' + // Spring Boot 4 test starters (modular test infrastructure) + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-jdbc-test' + // Runtime dependencies moved to test scope for library testRuntimeOnly 'org.springframework.boot:spring-boot-devtools' testRuntimeOnly 'org.mariadb.jdbc:mariadb-java-client' diff --git a/gradle.properties b/gradle.properties index 9f38744..e1c17a6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=3.5.2-SNAPSHOT +version=4.0.0-SNAPSHOT mavenCentralPublishing=true mavenCentralAutomaticPublishing=true 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 d6d0315..0aa3b9a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -21,6 +21,7 @@ import com.digitalsanctuary.spring.user.dto.PasswordResetRequestDto; import com.digitalsanctuary.spring.user.dto.SavePasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto; import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; @@ -131,24 +132,24 @@ public ResponseEntity resendRegistrationToken(@Valid @RequestBody } /** - * Updates the user's password. This is used when the user is logged in and - * wants to change their password. + * Updates the user's profile (first name, last name). This is used when the + * user is logged in and wants to update their profile information. * - * @param userDetails the authenticated user details - * @param userDto the user data transfer object containing user details - * @param request the HTTP servlet request - * @param locale the locale - * @return a ResponseEntity containing a JSONResponse with the password update + * @param userDetails the authenticated user details + * @param profileUpdateDto the profile update DTO containing first and last name + * @param request the HTTP servlet request + * @param locale the locale + * @return a ResponseEntity containing a JSONResponse with the profile update * result */ @PostMapping("/updateUser") public ResponseEntity updateUserAccount(@AuthenticationPrincipal DSUserDetails userDetails, - @Valid @RequestBody UserDto userDto, + @Valid @RequestBody UserProfileUpdateDto profileUpdateDto, HttpServletRequest request, Locale locale) { validateAuthenticatedUser(userDetails); User user = userDetails.getUser(); - user.setFirstName(userDto.getFirstName()); - user.setLastName(userDto.getLastName()); + user.setFirstName(profileUpdateDto.getFirstName()); + user.setLastName(profileUpdateDto.getLastName()); userService.saveRegisteredUser(user); logAuditEvent("ProfileUpdate", "Success", "User profile updated", user, request); diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/UserProfileUpdateDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/UserProfileUpdateDto.java new file mode 100644 index 0000000..2cf4269 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/UserProfileUpdateDto.java @@ -0,0 +1,23 @@ +package com.digitalsanctuary.spring.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * DTO for updating user profile information (first name, last name). + * This is separate from UserDto to avoid requiring password fields during profile updates. + */ +@Data +public class UserProfileUpdateDto { + + /** The first name. */ + @NotBlank(message = "First name is required") + @Size(max = 50, message = "First name must not exceed 50 characters") + private String firstName; + + /** The last name. */ + @NotBlank(message = "Last name is required") + @Size(max = 50, message = "Last name must not exceed 50 characters") + private String lastName; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 0f3488b..b201d1b 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -8,7 +8,6 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.access.expression.SecurityExpressionHandler; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; @@ -23,9 +22,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.FilterInvocation; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler; import org.springframework.security.web.session.HttpSessionEventPublisher; import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; import com.digitalsanctuary.spring.user.service.DSOAuth2UserService; @@ -279,18 +276,6 @@ public RoleHierarchy roleHierarchy() { return roleHierarchy; } - /** - * The webExpressionHandler method creates a DefaultWebSecurityExpressionHandler object and sets the roleHierarchy for the handler. - * - * @return the DefaultWebSecurityExpressionHandler object - */ - @Bean - public SecurityExpressionHandler webExpressionHandler() { - DefaultWebSecurityExpressionHandler defaultWebSecurityExpressionHandler = new DefaultWebSecurityExpressionHandler(); - defaultWebSecurityExpressionHandler.setRoleHierarchy(roleHierarchy()); - return defaultWebSecurityExpressionHandler; - } - /** * The methodSecurityExpressionHandler method creates a MethodSecurityExpressionHandler object and sets the roleHierarchy for the handler. This * ensures that method security annotations like @PreAuthorize use the configured role hierarchy. @@ -298,9 +283,9 @@ public SecurityExpressionHandler webExpressionHandler() { * @return the MethodSecurityExpressionHandler object */ @Bean - public MethodSecurityExpressionHandler methodSecurityExpressionHandler() { + static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); - expressionHandler.setRoleHierarchy(roleHierarchy()); + expressionHandler.setRoleHierarchy(roleHierarchy); return expressionHandler; } 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 22f9ddd..c151b95 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java @@ -12,6 +12,7 @@ import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto; import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; @@ -45,6 +46,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.http.HttpStatus; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import java.util.Collections; import java.util.Locale; @@ -112,6 +114,7 @@ void setUp() { testUserDto.setFirstName("Test"); testUserDto.setLastName("User"); testUserDto.setPassword("password123"); + testUserDto.setMatchingPassword("password123"); testUserDto.setRole(1); testUserDetails = new DSUserDetails(testUser); @@ -227,14 +230,12 @@ void registerUserAccount_missingEmail() throws Exception { // Given testUserDto.setEmail(null); - // When & Then + // When & Then - validation should reject null email with 400 Bad Request mockMvc.perform(post("/user/registration") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testUserDto)) .with(csrf())) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.code").value(5)); + .andExpect(status().isBadRequest()); } @Test @@ -243,14 +244,12 @@ void registerUserAccount_missingPassword() throws Exception { // Given testUserDto.setPassword(null); - // When & Then + // When & Then - validation should reject null password with 400 Bad Request mockMvc.perform(post("/user/registration") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testUserDto)) .with(csrf())) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.code").value(5)); + .andExpect(status().isBadRequest()); } @Test @@ -487,7 +486,7 @@ class UserProfileTests { @DisplayName("POST /user/updateUser - success") void updateUser_success() throws Exception { // Given - UserDto updateDto = new UserDto(); + UserProfileUpdateDto updateDto = new UserProfileUpdateDto(); updateDto.setFirstName("UpdatedFirst"); updateDto.setLastName("UpdatedLast"); @@ -533,7 +532,7 @@ public Object resolveArgument(org.springframework.core.MethodParameter parameter @DisplayName("POST /user/updateUser - not authenticated") void updateUser_notAuthenticated() throws Exception { // Given - UserDto updateDto = new UserDto(); + UserProfileUpdateDto updateDto = new UserProfileUpdateDto(); updateDto.setFirstName("UpdatedFirst"); updateDto.setLastName("UpdatedLast"); @@ -547,6 +546,215 @@ void updateUser_notAuthenticated() throws Exception { .andExpect(jsonPath("$.code").value(401)) .andExpect(jsonPath("$.messages[0]").value("User not logged in.")); } + + @Test + @DisplayName("POST /user/updateUser - validation fails with blank firstName") + void updateUser_blankFirstName_fails() throws Exception { + // Given + UserProfileUpdateDto updateDto = new UserProfileUpdateDto(); + updateDto.setFirstName(""); // Blank - should fail validation + updateDto.setLastName("UpdatedLast"); + + // Create a validator for the standalone setup + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + // Mock the principal resolver to return our test user + mockMvc = MockMvcBuilders.standaloneSetup(userAPI) + .setValidator(validator) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(DSUserDetails.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return testUserDetails; + } + }) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + + // When & Then - validation should fail + mockMvc.perform(post("/user/updateUser") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(userService, never()).saveRegisteredUser(any(User.class)); + } + + @Test + @DisplayName("POST /user/updateUser - validation fails with blank lastName") + void updateUser_blankLastName_fails() throws Exception { + // Given + UserProfileUpdateDto updateDto = new UserProfileUpdateDto(); + updateDto.setFirstName("UpdatedFirst"); + updateDto.setLastName(""); // Blank - should fail validation + + // Create a validator for the standalone setup + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + // Mock the principal resolver to return our test user + mockMvc = MockMvcBuilders.standaloneSetup(userAPI) + .setValidator(validator) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(DSUserDetails.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return testUserDetails; + } + }) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + + // When & Then - validation should fail + mockMvc.perform(post("/user/updateUser") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(userService, never()).saveRegisteredUser(any(User.class)); + } + + @Test + @DisplayName("POST /user/updateUser - validation fails with firstName exceeding 50 characters") + void updateUser_firstNameTooLong_fails() throws Exception { + // Given + UserProfileUpdateDto updateDto = new UserProfileUpdateDto(); + updateDto.setFirstName("A".repeat(51)); // 51 chars - exceeds 50 char limit + updateDto.setLastName("UpdatedLast"); + + // Create a validator for the standalone setup + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + // Mock the principal resolver to return our test user + mockMvc = MockMvcBuilders.standaloneSetup(userAPI) + .setValidator(validator) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(DSUserDetails.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return testUserDetails; + } + }) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + + // When & Then - validation should fail + mockMvc.perform(post("/user/updateUser") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(userService, never()).saveRegisteredUser(any(User.class)); + } + + @Test + @DisplayName("POST /user/updateUser - validation fails with null fields") + void updateUser_nullFields_fails() throws Exception { + // Given + UserProfileUpdateDto updateDto = new UserProfileUpdateDto(); + // Both fields are null - should fail validation + + // Create a validator for the standalone setup + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + // Mock the principal resolver to return our test user + mockMvc = MockMvcBuilders.standaloneSetup(userAPI) + .setValidator(validator) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(DSUserDetails.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return testUserDetails; + } + }) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + + // When & Then - validation should fail + mockMvc.perform(post("/user/updateUser") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(userService, never()).saveRegisteredUser(any(User.class)); + } + + @Test + @DisplayName("POST /user/updateUser - accepts maximum valid length names") + void updateUser_maxValidLength_succeeds() throws Exception { + // Given + UserProfileUpdateDto updateDto = new UserProfileUpdateDto(); + updateDto.setFirstName("A".repeat(50)); // Exactly 50 chars - should be valid + updateDto.setLastName("B".repeat(50)); // Exactly 50 chars - should be valid + + // Mock the principal resolver to return our test user + mockMvc = MockMvcBuilders.standaloneSetup(userAPI) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(DSUserDetails.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return testUserDetails; + } + }) + .setControllerAdvice(new TestExceptionHandler()) + .build(); + + when(messageSource.getMessage(eq("message.update-user.success"), any(), any(Locale.class))) + .thenReturn("Profile updated successfully"); + when(userService.saveRegisteredUser(any(User.class))).thenReturn(testUser); + + // When & Then + mockMvc.perform(post("/user/updateUser") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + verify(userService).saveRegisteredUser(any(User.class)); + } } @Nested @@ -603,21 +811,21 @@ void deleteAccount_notAuthenticated() throws Exception { class SecurityValidationTests { @Test - @DisplayName("POST /user/registration - CSRF protection") + @DisplayName("POST /user/registration - CSRF protection (standalone MockMvc limitation)") void registration_csrfProtection() throws Exception { - // Note: In standalone MockMvc setup, CSRF protection is not enabled - // This test would pass with @WebMvcTest but not with standalone setup - // For now, we skip this test for standalone unit testing - // CSRF protection should be tested in integration tests instead - - // Given - simulating missing required fields to get an error + // Note: In standalone MockMvc setup, CSRF protection is not enabled by default. + // This test verifies basic request handling. Actual CSRF protection should be + // tested in integration tests using @WebMvcTest or full Spring context. + + // Given - simulating missing required fields to trigger validation error testUserDto.setEmail(null); - - // When & Then + + // When & Then - without CSRF token, request still reaches validation + // which fails with 400 Bad Request for missing email mockMvc.perform(post("/user/registration") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testUserDto))) - .andExpect(status().is5xxServerError()); + .andExpect(status().isBadRequest()); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/DatabaseTest.java b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/DatabaseTest.java index ebb76c2..717e4b5 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/DatabaseTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/DatabaseTest.java @@ -1,8 +1,8 @@ package com.digitalsanctuary.spring.user.test.annotations; import com.digitalsanctuary.spring.user.test.config.DatabaseTestConfiguration; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/IntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/IntegrationTest.java index cd4dab5..030e918 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/IntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/IntegrationTest.java @@ -2,8 +2,8 @@ import com.digitalsanctuary.spring.user.test.app.TestApplication; import com.digitalsanctuary.spring.user.test.config.*; -import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.data.jpa.test.autoconfigure.AutoConfigureDataJpa; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/OAuth2Test.java b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/OAuth2Test.java index 9ddf4ad..e3b506d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/OAuth2Test.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/OAuth2Test.java @@ -4,7 +4,7 @@ import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; import com.digitalsanctuary.spring.user.test.config.OAuth2TestConfiguration; import com.digitalsanctuary.spring.user.test.config.SecurityTestConfiguration; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/SecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/SecurityTest.java index ef44f24..bcb3d32 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/annotations/SecurityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/annotations/SecurityTest.java @@ -3,7 +3,7 @@ import com.digitalsanctuary.spring.user.test.app.TestApplication; import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; import com.digitalsanctuary.spring.user.test.config.SecurityTestConfiguration; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener; diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/app/TestApplication.java b/src/test/java/com/digitalsanctuary/spring/user/test/app/TestApplication.java index b886f09..d3c456c 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/app/TestApplication.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/app/TestApplication.java @@ -3,7 +3,7 @@ import com.digitalsanctuary.spring.user.UserConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.persistence.autoconfigure.EntityScan; import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;