diff --git a/build.gradle b/build.gradle
index 46404f2..13a77f8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -39,7 +39,7 @@ repositories {
dependencies {
// DigitalSanctuary Spring User Framework
- implementation 'com.digitalsanctuary:ds-spring-user-framework:4.3.1'
+ implementation 'com.digitalsanctuary:ds-spring-user-framework:4.4.0'
// WebAuthn support (Passkey authentication)
implementation 'org.springframework.security:spring-security-webauthn'
@@ -80,7 +80,9 @@ dependencies {
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
// Utility libraries
- implementation 'org.passay:passay:1.6.6'
+ // (passay is provided transitively by ds-spring-user-framework at the version it requires; the demo
+ // app does not use passay directly, so declaring/pinning it here previously forced a conflicting
+ // downgrade of the library's required passay version.)
implementation 'com.google.guava:guava:33.5.0-jre'
implementation 'org.hibernate.validator:hibernate-validator'
diff --git a/src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java
deleted file mode 100644
index 02edae9..0000000
--- a/src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.digitalsanctuary.spring.demo.config;
-
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication;
-
-/**
- * Enables Spring Security 7's MFA filter support when {@code user.mfa.enabled=true}.
- *
- *
- * {@code @EnableMultiFactorAuthentication} registers a {@code BeanPostProcessor} that sets {@code mfaEnabled} on the
- * authentication filters. With it, a second login in the same session (e.g. a WebAuthn assertion after a password
- * login) merges the new authentication's authorities with the current ones, accumulating
- * {@code FactorGrantedAuthority}s. Without it, the passkey verification replaces the session authentication,
- * dropping the PASSWORD factor — the user can then never satisfy both factors and bounces between the two challenge
- * pages forever.
- *
- *
- *
- * The {@code authorities} attribute is left empty on purpose: the Spring User Framework already configures the
- * required-factor authorization rules from the {@code user.mfa.factors} property.
- *
- */
-@Configuration
-@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false)
-@EnableMultiFactorAuthentication(authorities = {})
-public class MfaSecurityConfig {
-}
diff --git a/src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java b/src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java
index 41b7854..98e8083 100644
--- a/src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java
+++ b/src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java
@@ -12,6 +12,7 @@
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.mail.MailService;
import com.digitalsanctuary.spring.user.service.SessionInvalidationService;
+import com.digitalsanctuary.spring.user.service.TokenHasher;
import com.digitalsanctuary.spring.user.service.UserEmailService;
import com.digitalsanctuary.spring.user.service.UserVerificationService;
@@ -36,8 +37,9 @@ public CustomUserEmailService(
UserVerificationService userVerificationService,
PasswordResetTokenRepository passwordTokenRepository,
ApplicationEventPublisher eventPublisher,
- SessionInvalidationService sessionInvalidationService) {
- super(mailService, userVerificationService, passwordTokenRepository, eventPublisher, sessionInvalidationService);
+ SessionInvalidationService sessionInvalidationService,
+ TokenHasher tokenHasher) {
+ super(mailService, userVerificationService, passwordTokenRepository, eventPublisher, sessionInvalidationService, tokenHasher);
}
@Override
diff --git a/src/main/resources/application-mfa.yml b/src/main/resources/application-mfa.yml
index f5142c5..cac2213 100644
--- a/src/main/resources/application-mfa.yml
+++ b/src/main/resources/application-mfa.yml
@@ -7,9 +7,9 @@
# ./gradlew bootRun --args='--spring.profiles.active=local,mfa'
#
# Notes:
-# - The challenge page (user.mfa.webauthnEntryPointUri) must be in unprotectedURIs. The framework
-# redirects partially-authenticated users to it; if the page itself required full authentication,
-# the redirect would loop forever.
+# - The challenge page (user.mfa.webauthnEntryPointUri) is auto-unprotected by the framework: it
+# unprotects the configured factor entry-point URIs so the partial-auth redirect cannot loop. (No
+# need to list it in unprotectedURIs by hand.)
# - The passkey registration endpoints (/webauthn/register/options, /webauthn/register) are also
# unprotected here so that a partially-authenticated user can enroll their first passkey. Spring
# Security still requires an authenticated principal to register a credential; this only relaxes
@@ -19,4 +19,4 @@ user:
mfa:
enabled: true
security:
- unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/user/mfa/webauthn-challenge.html,/webauthn/register/options,/webauthn/register
+ unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/webauthn/register/options,/webauthn/register
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 15a17f8..0df987f 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -136,7 +136,7 @@ user:
bcryptStrength: 12 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31.
testHashTime: true # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value.
defaultAction: deny # The default action for all requests. This can be either deny or allow.
- unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/user/mfa/webauthn-challenge.html # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny.
+ unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny.
protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow.
disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token.
diff --git a/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java b/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java
index d8d428e..d6bb002 100644
--- a/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java
@@ -12,10 +12,11 @@
/**
* Guards the consistency of the MFA configuration files themselves.
*
- * The MFA entry point pages must be listed in {@code user.security.unprotectedURIs}: the framework's
- * access-denied handler redirects partially-authenticated users to the entry point URI, and if that page is itself
- * protected the redirect loops forever. The framework only auto-unprotects {@code /user/mfa/status}, not the entry
- * point pages, so the demo config has to keep these two settings in sync by hand.
+ * As of the framework release containing the #313 fix, the framework auto-unprotects the configured MFA factor
+ * entry-point URIs ({@code user.mfa.passwordEntryPointUri} / {@code user.mfa.webauthnEntryPointUri}), so the demo no
+ * longer has to list the WebAuthn challenge page in {@code user.security.unprotectedURIs} by hand. These tests now only
+ * assert what is still the demo's own responsibility: MFA must be opt-in (disabled by default), and the passkey
+ * enrollment endpoints (which are not factor entry points) must be reachable by partially-authenticated users.
*/
@DisplayName("MFA Config Consistency Tests")
class MfaConfigConsistencyTest {
@@ -44,19 +45,6 @@ private static List unprotectedUris(Map yaml) {
return Arrays.stream(uris.toString().split(",")).map(String::trim).toList();
}
- @Test
- @DisplayName("application.yml unprotects the configured WebAuthn challenge page")
- void baseConfigUnprotectsWebauthnEntryPoint() {
- Map yaml = loadYaml("/application.yml");
- Map mfa = section(yaml, "user", "mfa");
-
- String webauthnEntryPoint = String.valueOf(mfa.get("webauthnEntryPointUri"));
- assertThat(webauthnEntryPoint).as("user.mfa.webauthnEntryPointUri should be configured").isNotEqualTo("null");
- assertThat(unprotectedUris(yaml))
- .as("the WebAuthn challenge page must be unprotected or MFA redirects loop forever")
- .contains(webauthnEntryPoint);
- }
-
@Test
@DisplayName("application.yml leaves MFA disabled by default (opt-in via the mfa profile)")
void baseConfigLeavesMfaDisabled() {
@@ -67,16 +55,16 @@ void baseConfigLeavesMfaDisabled() {
}
@Test
- @DisplayName("mfa profile enables MFA and unprotects the challenge page and passkey enrollment endpoints")
- void mfaProfileEnablesMfaAndUnprotectsEntryPoint() {
+ @DisplayName("mfa profile enables MFA and unprotects the passkey enrollment endpoints")
+ void mfaProfileEnablesMfaAndUnprotectsEnrollmentEndpoints() {
Map yaml = loadYaml("/application-mfa.yml");
Map mfa = section(yaml, "user", "mfa");
assertThat(mfa.get("enabled")).isEqualTo(Boolean.TRUE);
+ // The WebAuthn challenge page (the configured factor entry point) is now auto-unprotected by the
+ // framework, so it no longer needs to be listed here. The passkey ENROLLMENT endpoints are not factor
+ // entry points, so partially-authenticated users still need them unprotected to enroll a first passkey.
List uris = unprotectedUris(yaml);
- assertThat(uris).contains("/user/mfa/webauthn-challenge.html");
- // Partially-authenticated users need to be able to enroll their first passkey, otherwise new
- // accounts can never satisfy the WEBAUTHN factor.
assertThat(uris).contains("/webauthn/register/options", "/webauthn/register");
}
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiIntegrationTestFixed.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiIntegrationTestFixed.java
index 6001f75..cf58cfb 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiIntegrationTestFixed.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiIntegrationTestFixed.java
@@ -5,6 +5,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -16,12 +17,16 @@
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionTemplate;
import com.digitalsanctuary.spring.demo.UserDemoApplication;
import com.digitalsanctuary.spring.user.dto.UserDto;
import com.digitalsanctuary.spring.user.mail.MailService;
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
+import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository;
import com.digitalsanctuary.spring.user.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.EntityManager;
@@ -50,24 +55,61 @@ class UserApiIntegrationTestFixed {
@Autowired
private UserService userService;
+ @Autowired
+ private VerificationTokenRepository verificationTokenRepository;
+
+ @Autowired
+ private PlatformTransactionManager transactionManager;
+
@MockitoBean
private MailService mailService;
@PersistenceContext
private EntityManager entityManager;
+ private static final String TEST_EMAIL = "test@example.com";
+
private UserDto testUserDto;
@BeforeEach
void setUp() {
+ // Start from a clean slate. Registration (via the API and via UserService.registerNewUserAccount)
+ // commits the new user in its own transaction as of 4.4.0 — it does NOT roll back with the test's
+ // @Transactional. Without a committed cleanup, the user persisted by one test method collides with
+ // the next (UserAlreadyExistException / 409 Conflict).
+ deleteTestUserCommitted();
+
testUserDto = new UserDto();
testUserDto.setFirstName("Test");
testUserDto.setLastName("User");
- testUserDto.setEmail("test@example.com");
+ testUserDto.setEmail(TEST_EMAIL);
testUserDto.setPassword("SecurePass123!");
testUserDto.setMatchingPassword("SecurePass123!");
}
+ @AfterEach
+ void tearDown() {
+ // Remove the user committed by this test so it cannot leak into other tests.
+ deleteTestUserCommitted();
+ }
+
+ /**
+ * Deletes the test user (and any verification token) in its own committed transaction. A
+ * REQUIRES_NEW transaction is required because the class is {@code @Transactional}: a plain delete here
+ * would roll back with the test and never actually remove the committed registration row.
+ */
+ private void deleteTestUserCommitted() {
+ TransactionTemplate tx = new TransactionTemplate(transactionManager);
+ tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+ tx.executeWithoutResult(status -> {
+ User existing = userRepository.findByEmail(TEST_EMAIL);
+ if (existing != null) {
+ verificationTokenRepository.deleteByUser(existing);
+ userRepository.delete(existing);
+ }
+ });
+ }
+
@Test
@DisplayName("Should successfully register new user")
void shouldRegisterNewUser() throws Exception {
diff --git a/src/test/java/com/digitalsanctuary/spring/user/integration/AuthorityServiceIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/integration/AuthorityServiceIntegrationTest.java
index db447c7..f837612 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/integration/AuthorityServiceIntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/integration/AuthorityServiceIntegrationTest.java
@@ -139,9 +139,9 @@ void getAuthoritiesFromUser_persistedUser_loadsAuthoritiesWithLazyLoading() {
Collection extends GrantedAuthority> authorities = authorityService
.getAuthoritiesFromUser(fetchedUser);
- // Then
- assertThat(authorities).hasSize(3).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
- "LOGIN_PRIVILEGE",
+ // Then - as of 4.4.0 the role name itself is also granted (needed for hasRole() checks)
+ assertThat(authorities).hasSize(4).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
+ "ROLE_USER", "LOGIN_PRIVILEGE",
"UPDATE_OWN_USER_PRIVILEGE", "RESET_OWN_PASSWORD_PRIVILEGE");
}
@@ -159,9 +159,10 @@ void getAuthoritiesFromUser_multipleRoles_combinesAllPrivileges() {
Collection extends GrantedAuthority> authorities = authorityService
.getAuthoritiesFromUser(multiRoleUser);
- // Then
- assertThat(authorities).hasSize(6) // 3 from user role + 3 from manager role
- .extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder("LOGIN_PRIVILEGE",
+ // Then - 6 privileges + the two role names (ROLE_USER, ROLE_MANAGER) granted as of 4.4.0
+ assertThat(authorities).hasSize(8) // 3 user + 3 manager privileges + 2 role names
+ .extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder("ROLE_USER",
+ "ROLE_MANAGER", "LOGIN_PRIVILEGE",
"UPDATE_OWN_USER_PRIVILEGE",
"RESET_OWN_PASSWORD_PRIVILEGE", "ADD_USER_TO_TEAM_PRIVILEGE",
"REMOVE_USER_FROM_TEAM_PRIVILEGE",
@@ -180,9 +181,9 @@ void getAuthoritiesFromUser_adminUser_hasAllAdminPrivileges() {
// When
Collection extends GrantedAuthority> authorities = authorityService.getAuthoritiesFromUser(adminUser);
- // Then
- assertThat(authorities).hasSize(5).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
- "ADMIN_PRIVILEGE",
+ // Then - 5 admin privileges + the ROLE_ADMIN role name granted as of 4.4.0
+ assertThat(authorities).hasSize(6).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
+ "ROLE_ADMIN", "ADMIN_PRIVILEGE",
"INVITE_USER_PRIVILEGE", "READ_USER_PRIVILEGE", "ASSIGN_MANAGER_PRIVILEGE",
"RESET_ANY_USER_PASSWORD_PRIVILEGE");
}
@@ -206,9 +207,9 @@ void getAuthoritiesFromRoles_sharedPrivileges_deduplicatesCorrectly() {
Collection extends GrantedAuthority> authorities = authorityService
.getAuthoritiesFromRoles(Arrays.asList(role1, role2));
- // Then - Should only have 4 unique privileges (3 from user + 1 shared)
- assertThat(authorities).hasSize(4).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
- "LOGIN_PRIVILEGE",
+ // Then - 4 unique privileges (3 from user + 1 shared) + the two role names granted as of 4.4.0
+ assertThat(authorities).hasSize(6).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
+ "ROLE_TEST1", "ROLE_TEST2", "LOGIN_PRIVILEGE",
"UPDATE_OWN_USER_PRIVILEGE", "RESET_OWN_PASSWORD_PRIVILEGE", "SHARED_PRIVILEGE");
}
@@ -247,9 +248,11 @@ void getAuthoritiesFromRoles_separatelyLoadedRoles_worksCorrectly() {
Collection extends GrantedAuthority> authorities = authorityService.getAuthoritiesFromRoles(
Arrays.asList(loadedUserRole, loadedManagerRole, loadedAdminRole));
- // Then - Should have all unique privileges from all three roles
- assertThat(authorities).hasSize(11) // 3 + 3 + 5 unique privileges
+ // Then - all unique privileges from all three roles, plus the three role names granted as of 4.4.0
+ assertThat(authorities).hasSize(14) // 3 + 3 + 5 privileges + 3 role names
.extracting(GrantedAuthority::getAuthority).contains(
+ // Role names
+ "ROLE_USER", "ROLE_MANAGER", "ROLE_ADMIN",
// User privileges
"LOGIN_PRIVILEGE", "UPDATE_OWN_USER_PRIVILEGE",
"RESET_OWN_PASSWORD_PRIVILEGE",
diff --git a/src/test/java/com/digitalsanctuary/spring/user/integration/DSUserDetailsServiceIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/integration/DSUserDetailsServiceIntegrationTest.java
index ce69815..6d06a36 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/integration/DSUserDetailsServiceIntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/integration/DSUserDetailsServiceIntegrationTest.java
@@ -13,8 +13,11 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import com.digitalsanctuary.spring.user.persistence.model.Privilege;
@@ -40,6 +43,10 @@
* - Account unlock functionality
*/
@IntegrationTest
+// A positive lockout duration makes auto-unlock deterministic: a user locked longer ago than this window
+// is eligible to be unlocked on load, while one just locked stays locked. (The framework reads
+// user.security.accountLockoutDuration; >= 0 enables time-based auto-unlock.)
+@TestPropertySource(properties = "user.security.accountLockoutDuration=30")
@DisplayName("DSUserDetailsService Integration Tests")
class DSUserDetailsServiceIntegrationTest {
@@ -129,6 +136,7 @@ void loadUserByUsername_autoUnlocksEligibleUser() {
.withEmail("autounlock@test.com")
.withLockedDate(oldLockDate)
.withFailedLoginAttempts(5)
+ .verified() // enabled, so that once auto-unlocked the account is fully usable
.withId(null)
.build();
lockedUser.setRoles(new ArrayList<>(Arrays.asList(userRole)));
@@ -137,14 +145,14 @@ void loadUserByUsername_autoUnlocksEligibleUser() {
// Verify user is initially locked
assertThat(lockedUser.isLocked()).isTrue();
- // When
+ // When - the lock is older than accountLockoutDuration (30m), so loading auto-unlocks the account
DSUserDetails result = dsUserDetailsService.loadUserByUsername("autounlock@test.com");
- // Then - Depending on configuration, user might be unlocked
- // Note: This behavior depends on accountLockoutDuration configuration
- // If configured to auto-unlock after certain time, the user should be unlocked
+ // Then - the user is unlocked on load and authentication succeeds
assertThat(result).isNotNull();
assertThat(result.getUsername()).isEqualTo("autounlock@test.com");
+ assertThat(result.isAccountNonLocked()).isTrue();
+ assertThat(userRepository.findByEmail("autounlock@test.com").isLocked()).isFalse();
}
@Test
@@ -247,8 +255,8 @@ void loadUserByUsername_mapsAllUserDetailsProperties() {
@Test
@Transactional
- @DisplayName("Should handle disabled user correctly")
- void loadUserByUsername_disabledUser_returnsWithCorrectStatus() {
+ @DisplayName("Should reject loading a disabled user")
+ void loadUserByUsername_disabledUser_throwsDisabledException() {
// Given
User disabledUser = UserTestDataBuilder.anUnverifiedUser()
.withEmail("disabled@test.com")
@@ -257,27 +265,24 @@ void loadUserByUsername_disabledUser_returnsWithCorrectStatus() {
disabledUser.setRoles(new ArrayList<>(Arrays.asList(userRole)));
userRepository.save(disabledUser);
- // When
- DSUserDetails result = dsUserDetailsService.loadUserByUsername("disabled@test.com");
-
- // Then
- assertThat(result).isNotNull();
- assertThat(result.isEnabled()).isFalse();
- assertThat(result.isAccountNonLocked()).isTrue();
- assertThat(result.getUsername()).isEqualTo("disabled@test.com");
+ // When & Then - as of 4.4.0 the login helper enforces account status on every auth path, so loading
+ // a disabled (unverified) account throws rather than returning a principal with isEnabled()==false.
+ assertThatThrownBy(() -> dsUserDetailsService.loadUserByUsername("disabled@test.com"))
+ .isInstanceOf(DisabledException.class);
}
@Test
@Transactional
- @DisplayName("Should handle currently locked user correctly")
- void loadUserByUsername_currentlyLockedUser_returnsWithLockedStatus() {
- // Given - Create a recently locked user (should remain locked)
+ @DisplayName("Should reject loading a currently locked user")
+ void loadUserByUsername_currentlyLockedUser_throwsLockedException() {
+ // Given - a user locked just now: well inside the accountLockoutDuration window, so it must NOT
+ // auto-unlock and stays locked.
Date recentLockDate = new Date();
User lockedUser = UserTestDataBuilder.aLockedUser()
.withEmail("locked@test.com")
.withLockedDate(recentLockDate)
- .verified() // Make the user enabled but locked
+ .verified() // enabled but locked, to prove lock (not disabled) is what rejects the load
.withId(null)
.build();
lockedUser.setRoles(new ArrayList<>(Arrays.asList(userRole)));
@@ -286,26 +291,9 @@ void loadUserByUsername_currentlyLockedUser_returnsWithLockedStatus() {
// Verify user is initially locked
assertThat(lockedUser.isLocked()).isTrue();
- // When
- DSUserDetails result = dsUserDetailsService.loadUserByUsername("locked@test.com");
-
- // Then
- assertThat(result).isNotNull();
- assertThat(result.isEnabled()).isTrue(); // Locked users can still be enabled
-
- // Check the actual lock status - it depends on configuration
- // If accountLockoutDuration is 0, the user might be unlocked immediately
- // If it's > 0, the user should remain locked since we just locked them
- User userAfterLoad = userRepository.findByEmail("locked@test.com");
-
- // The test should verify the actual behavior based on configuration
- // If the user is still locked, isAccountNonLocked should be false
- // If the user was unlocked by the service, isAccountNonLocked should be true
- if (userAfterLoad.isLocked()) {
- assertThat(result.isAccountNonLocked()).isFalse();
- } else {
- // User was unlocked by the service
- assertThat(result.isAccountNonLocked()).isTrue();
- }
+ // When & Then - loading a still-locked account throws LockedException (checked before disabled status)
+ assertThatThrownBy(() -> dsUserDetailsService.loadUserByUsername("locked@test.com"))
+ .isInstanceOf(LockedException.class);
+ assertThat(userRepository.findByEmail("locked@test.com").isLocked()).isTrue();
}
}
\ No newline at end of file
diff --git a/src/test/java/com/digitalsanctuary/spring/user/integration/EventSystemIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/integration/EventSystemIntegrationTest.java
index b76470f..ab5bbc7 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/integration/EventSystemIntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/integration/EventSystemIntegrationTest.java
@@ -67,7 +67,10 @@ public TestEventCapture testEventCapture() {
@BeforeEach
void setUp() {
- testUser = UserTestDataBuilder.aUser().withId(1L).withEmail("test@example.com").withFirstName("Test").withLastName("User").enabled().build();
+ // The user must be UNVERIFIED (disabled): as of 4.4.0 RegistrationListener skips sending the
+ // verification email when the user is already enabled, which is the realistic state for a brand-new
+ // registration. An enabled user here would make the registration-email assertions never fire.
+ testUser = UserTestDataBuilder.aUser().withId(1L).withEmail("test@example.com").withFirstName("Test").withLastName("User").unverified().build();
eventCapture.clear();
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java
index 4c23a74..f769481 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java
@@ -5,6 +5,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -25,12 +26,12 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import com.digitalsanctuary.spring.demo.UserDemoApplication;
-import com.digitalsanctuary.spring.user.dto.UserDto;
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
import com.digitalsanctuary.spring.user.service.LoginAttemptService;
import com.digitalsanctuary.spring.user.service.UserService;
import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest;
+import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
import jakarta.persistence.EntityManager;
/**
@@ -79,19 +80,15 @@ void setUp() {
}
entityManager.flush();
- // Create test user
- UserDto userDto = new UserDto();
- userDto.setFirstName("Lockout");
- userDto.setLastName("Test");
- userDto.setEmail(TEST_EMAIL);
- userDto.setPassword(TEST_PASSWORD);
- userDto.setMatchingPassword(TEST_PASSWORD);
-
- userService.registerNewUserAccount(userDto);
-
- // Enable the user
- entityManager.createNativeQuery("UPDATE user_account SET enabled = true WHERE email = :email").setParameter("email", TEST_EMAIL)
- .executeUpdate();
+ // Create the test user directly via the repository so it lives inside the test's transaction and
+ // rolls back cleanly. As of 4.4.0, UserService.registerNewUserAccount runs with
+ // Propagation.NOT_SUPPORTED and commits the new user in its own transaction, which would survive
+ // the rollback and leak the same email into the next test method (UserAlreadyExistException). The
+ // lockout tests only need a persisted, enabled user row; they exercise LoginAttemptService directly.
+ User user = UserTestDataBuilder.aUser().withEmail(TEST_EMAIL).withFirstName("Lockout").withLastName("Test")
+ .withPassword(TEST_PASSWORD).verified().withId(null).build();
+ user.setRoles(new ArrayList<>());
+ userRepository.save(user);
entityManager.flush();
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseSimpleTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseSimpleTest.java
index 78532f1..14bb390 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseSimpleTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseSimpleTest.java
@@ -108,7 +108,7 @@ void testExpiredTokenRejection() throws Exception {
}
@Test
- @DisplayName("Valid token should enable user")
+ @DisplayName("Valid token enables the user and is consumed (single-use)")
void testValidToken() throws Exception {
// Create valid token
VerificationToken validToken = new VerificationToken();
@@ -123,8 +123,14 @@ void testValidToken() throws Exception {
assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
- // Verify token still exists (validation doesn't consume it, confirmation does)
- assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNotNull();
+ // The user is now enabled...
+ User updatedUser = userRepository.findById(testUser.getId()).orElse(null);
+ assertThat(updatedUser).isNotNull();
+ assertThat(updatedUser.isEnabled()).isTrue();
+
+ // ...and the token is atomically consumed on the valid path: validation is now single-use
+ // (it both enables the user and deletes the token in one transaction), so it cannot be replayed.
+ assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNull();
}
@Test
@@ -233,16 +239,24 @@ void testCrossUserTokenSecurity() {
otherUserToken.setExpiryDate(Date.from(Instant.now().plus(24, ChronoUnit.HOURS)));
verificationTokenRepository.saveAndFlush(otherUserToken);
- // Token should be valid (it exists and isn't expired)
- UserService.TokenValidationResult result = userVerificationService
- .validateVerificationToken(otherUserToken.getToken());
- assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
-
- // But it should be associated with the correct user
+ // The token resolves to the correct user (read it before validation consumes the token).
User userFromToken = userVerificationService.getUserByVerificationToken(otherUserToken.getToken());
assertThat(userFromToken).isNotNull();
assertThat(userFromToken.getEmail()).isEqualTo(otherEmail);
assertThat(userFromToken.getEmail()).isNotEqualTo(testEmail);
+
+ // Validating it enables ONLY its owner, never the original test user.
+ UserService.TokenValidationResult result = userVerificationService
+ .validateVerificationToken(otherUserToken.getToken());
+ assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
+
+ User enabledOther = userRepository.findById(otherUser.getId()).orElse(null);
+ assertThat(enabledOther).isNotNull();
+ assertThat(enabledOther.isEnabled()).as("the token's owner is enabled").isTrue();
+
+ User stillDisabled = userRepository.findById(testUser.getId()).orElse(null);
+ assertThat(stillDisabled).isNotNull();
+ assertThat(stillDisabled.isEnabled()).as("a different user is NOT enabled by another user's token").isFalse();
}
@Test
@@ -273,20 +287,21 @@ void testServiceVerificationFlow() {
validToken.setExpiryDate(Date.from(Instant.now().plus(24, ChronoUnit.HOURS)));
verificationTokenRepository.saveAndFlush(validToken);
- // Validate token
- UserService.TokenValidationResult result = userVerificationService
- .validateVerificationToken(validToken.getToken());
- assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
-
- // Get user from token
+ // The token resolves to the correct user (read it before validation consumes the token).
User userFromToken = userVerificationService.getUserByVerificationToken(validToken.getToken());
assertThat(userFromToken).isNotNull();
assertThat(userFromToken.getEmail()).isEqualTo(testEmail);
- // Check user status - the service may return the current state
- // The important part is we can retrieve the correct user by token
+ // Validation enables the user and consumes the token in a single transaction.
+ UserService.TokenValidationResult result = userVerificationService
+ .validateVerificationToken(validToken.getToken());
+ assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
+
+ User updatedUser = userRepository.findById(testUser.getId()).orElse(null);
+ assertThat(updatedUser).isNotNull();
+ assertThat(updatedUser.isEnabled()).isTrue();
- // Token still exists (validation doesn't consume it)
- assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNotNull();
+ // The token is consumed (single-use), so it no longer resolves.
+ assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNull();
}
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java
deleted file mode 100644
index c90c82a..0000000
--- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java
+++ /dev/null
@@ -1,107 +0,0 @@
-package com.digitalsanctuary.spring.user.service;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-import java.util.Date;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import com.digitalsanctuary.spring.user.persistence.model.User;
-import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
-
-@ExtendWith(MockitoExtension.class)
-class LoginAttemptServiceTest {
-
- @Mock
- private UserRepository userRepository;
-
- private LoginAttemptService loginAttemptService;
-
- private final int failedLoginAttempts = 10; // Assuming these are the values in your application.properties
- private final int accountLockoutDuration = 1; // Assuming these are the values in your application.properties
-
- private User testUser;
-
- @BeforeEach
- void setUp() {
- // Initialize your test user here
- testUser = new User();
- testUser.setEmail("test@example.com");
- testUser.setFailedLoginAttempts(0);
- testUser.setLocked(false);
-
- // Manually construct the service with mocked dependencies
- loginAttemptService = new LoginAttemptService(userRepository);
- loginAttemptService.setMaxFailedLoginAttempts(failedLoginAttempts);
- loginAttemptService.setAccountLockoutDuration(accountLockoutDuration);
- }
-
- @Test
- void loginSucceeded_resetsFailedAttempts() {
- when(userRepository.findByEmail(anyString())).thenReturn(testUser);
-
- loginAttemptService.loginSucceeded(testUser.getEmail());
-
- assertEquals(0, testUser.getFailedLoginAttempts());
- assertFalse(testUser.isLocked());
- assertNull(testUser.getLockedDate());
- verify(userRepository).save(testUser);
- }
-
- @Test
- void loginFailed_incrementsFailedAttempts() {
- when(userRepository.findByEmail(anyString())).thenReturn(testUser);
-
- for (int i = 1; i <= failedLoginAttempts; i++) {
- loginAttemptService.loginFailed(testUser.getEmail());
- }
-
- assertEquals(failedLoginAttempts, testUser.getFailedLoginAttempts());
- assertTrue(testUser.isLocked());
- assertNotNull(testUser.getLockedDate());
- verify(userRepository, times(failedLoginAttempts)).save(testUser);
- }
-
- @Test
- void isLocked_returnsTrueWhenUserIsLocked() {
- testUser.setLocked(true);
- testUser.setLockedDate(new Date());
-
- when(userRepository.findByEmail(anyString())).thenReturn(testUser);
-
- assertTrue(loginAttemptService.isLocked(testUser.getEmail()));
- }
-
- @Test
- void isLocked_returnsFalseWhenUserIsNotLocked() {
- when(userRepository.findByEmail(anyString())).thenReturn(testUser);
-
- assertFalse(loginAttemptService.isLocked(testUser.getEmail()));
- }
-
- @Test
- void isLocked_unlocksUserAfterLockoutDuration() {
- // Set the user as locked with a lock date before the lockout duration
- testUser.setLocked(true);
- testUser.setLockedDate(new Date(System.currentTimeMillis() - (accountLockoutDuration + 1) * 60 * 1000));
-
- when(userRepository.findByEmail(anyString())).thenReturn(testUser);
-
- assertFalse(loginAttemptService.isLocked(testUser.getEmail()));
- assertFalse(testUser.isLocked());
- assertNull(testUser.getLockedDate());
- assertEquals(0, testUser.getFailedLoginAttempts());
- verify(userRepository).save(testUser);
- }
-
- // Additional tests can be written for edge cases and exception handling
-}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java
deleted file mode 100644
index 9a5ad1d..0000000
--- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java
+++ /dev/null
@@ -1,115 +0,0 @@
-package com.digitalsanctuary.spring.user.service;
-
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.when;
-import java.util.Collections;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.security.core.session.SessionRegistry;
-import org.springframework.security.crypto.password.PasswordEncoder;
-import com.digitalsanctuary.spring.user.dto.UserDto;
-import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException;
-import com.digitalsanctuary.spring.user.persistence.model.Role;
-import com.digitalsanctuary.spring.user.persistence.model.User;
-import com.digitalsanctuary.spring.user.persistence.repository.PasswordHistoryRepository;
-import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository;
-import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository;
-import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
-import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository;
-
-@ExtendWith(MockitoExtension.class)
-public class UserServiceTest {
-
- private static final String USER_ROLE_NAME = "ROLE_USER";
- @Mock
- private UserRepository userRepository;
- @Mock
- private VerificationTokenRepository tokenRepository;
- @Mock
- private PasswordResetTokenRepository passwordTokenRepository;
- @Mock
- private PasswordEncoder passwordEncoder;
- @Mock
- private RoleRepository roleRepository;
- @Mock
- private SessionRegistry sessionRegistry;
- @Mock
- public UserEmailService userEmailService;
- @Mock
- public UserVerificationService userVerificationService;
-
- @Mock
- private ApplicationEventPublisher eventPublisher;
-
- @Mock
- public AuthorityService authorityService;
-
- @Mock
- private DSUserDetailsService dsUserDetailsService;
-
- @Mock
- private PasswordHistoryRepository passwordHistoryRepository;
-
- @Mock
- private SessionInvalidationService sessionInvalidationService;
-
- private UserService userService;
- private User testUser;
- private UserDto testUserDto;
-
- @BeforeEach
- void setUp() {
- testUser = new User();
- testUser.setEmail("test@example.com");
- testUser.setFirstName("testFirstName");
- testUser.setLastName("testLastName");
- testUser.setPassword("testPassword");
- testUser.setRoles(Collections.singletonList(new Role("ROLE_USER")));
- testUser.setEnabled(true);
-
- testUserDto = new UserDto();
- testUserDto.setEmail("test@example.com");
- testUserDto.setFirstName("testFirstName");
- testUserDto.setLastName("testLastName");
- testUserDto.setPassword("testPassword");
- testUserDto.setRole(1);
-
- userService = new UserService(userRepository, tokenRepository, passwordTokenRepository, passwordEncoder,
- roleRepository, sessionRegistry, userEmailService, userVerificationService, authorityService,
- dsUserDetailsService, eventPublisher, passwordHistoryRepository, sessionInvalidationService);
- }
-
- @Test
- void registerNewUserAccount_returnsUserWhenUserIsNew() {
- when(passwordEncoder.encode(testUser.getPassword())).thenReturn(testUser.getPassword());
- when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(new Role(USER_ROLE_NAME));
- when(userRepository.save(testUser)).thenReturn(testUser);
- User saved = userService.registerNewUserAccount(testUserDto);
- Assertions.assertEquals(saved, testUser);
- }
-
- @Test
- void registerNewUserAccount_throwsExceptionWhenUserExist() {
- when(userRepository.findByEmail(testUser.getEmail())).thenReturn(testUser);
- Assertions.assertThrows(UserAlreadyExistException.class, () -> userService.registerNewUserAccount(testUserDto));
- }
-
- @Test
- void findByEmail_returnsUserWhenEmailExist() {
- when(userRepository.findByEmail(testUser.getEmail())).thenReturn(testUser);
- User found = userService.findUserByEmail(testUser.getEmail());
- Assertions.assertEquals(found, testUser);
- }
-
- @Test
- void checkIfValidOldPassword_returnTrueIfValid() {
- when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);
- Assertions.assertTrue(userService.checkIfValidOldPassword(testUser, testUser.getPassword()));
- }
-
-}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java
deleted file mode 100644
index 9f1e9fc..0000000
--- a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java
+++ /dev/null
@@ -1,96 +0,0 @@
-package com.digitalsanctuary.spring.user.service;
-
-import com.digitalsanctuary.spring.user.persistence.model.User;
-import com.digitalsanctuary.spring.user.persistence.model.VerificationToken;
-import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
-import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.util.Calendar;
-import java.util.Date;
-
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-@ExtendWith(MockitoExtension.class)
-public class UserVerificationServiceTest {
-
- private UserVerificationService userVerificationService;
- @Mock
- private UserRepository userRepository;
- @Mock
- private VerificationTokenRepository verificationTokenRepository;
- private User testUser;
- private VerificationToken testToken;
-
-
- @BeforeEach
- void setUp() {
- testUser = new User();
- testUser.setEmail("test@example.com");
- testUser.setFirstName("testFirstName");
- testUser.setLastName("testLastName");
- testUser.setPassword("testPassword");
-
- testToken = new VerificationToken();
- testToken.setUser(testUser);
-
- userVerificationService = new UserVerificationService(userRepository, verificationTokenRepository);
- }
-
- @Test
- void getUserByVerificationToken_returnsUserIfTokenExist() {
- when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken);
- User found = userVerificationService.getUserByVerificationToken(anyString());
- Assertions.assertEquals(found, testUser);
- }
-
- @Test
- void validateVerificationToken_returnsValidIfTokenValid() {
- testToken.setExpiryDate(getExpirationDate(1));
- when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken);
- UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(anyString());
- Assertions.assertEquals(result, UserService.TokenValidationResult.VALID);
- }
-
- @Test
- public void testValidateVerificationToken_ExpiredToken() {
- String token = "expiredToken";
- VerificationToken verificationToken = new VerificationToken();
- verificationToken.setToken(token);
- verificationToken.setUser(new User());
-
- Calendar cal = Calendar.getInstance();
- cal.add(Calendar.DATE, -1); // Set expiry date to yesterday
- verificationToken.setExpiryDate(cal.getTime());
-
- when(verificationTokenRepository.findByToken(token)).thenReturn(verificationToken);
-
- UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(token);
- Assertions.assertEquals(result, UserService.TokenValidationResult.EXPIRED);
- verify(verificationTokenRepository, times(1)).delete(verificationToken);
- }
-
- @Test
- void validateVerificationToken_returnInvalidTokenIfTokenNotFound() {
- when(verificationTokenRepository.findByToken(anyString())).thenReturn(null);
- UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(anyString());
- Assertions.assertEquals(result, UserService.TokenValidationResult.INVALID_TOKEN);
- }
-
- private Date getExpirationDate(int amount) {
- Date dt = new Date();
- Calendar c = Calendar.getInstance();
- c.setTime(dt);
- c.add(Calendar.DATE, amount);
- return c.getTime();
- }
-
-}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
index 1a18723..0e34235 100644
--- a/src/test/resources/application-test.properties
+++ b/src/test/resources/application-test.properties
@@ -5,7 +5,7 @@ user.security.protectedURIs=/protected.html
# Used if default is deny
# The MFA challenge page must stay unprotected: MFA tests redirect partially-authenticated users to
# it, and a protected challenge page would redirect back to itself forever.
-user.security.unprotectedURIs=/,/index.html,/css/*,/js/*,/img/*,/register.html,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/login,/user/mfa/webauthn-challenge.html
+user.security.unprotectedURIs=/,/index.html,/css/*,/js/*,/img/*,/register.html,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/login
user.security.loginPageURI=/login.html
@@ -22,12 +22,19 @@ user.audit.logEvents=true
# H2 Database Configuration
spring.datasource.driver-class-name=org.h2.Driver
#spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1;MODE=MariaDB;DATABASE_TO_LOWER=TRUE
-spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1;
+# Per-context unique DB name (${random.uuid}) so each Spring context gets its own isolated in-memory
+# database. The framework's 4.4.0 security work made registerNewUserAccount/loginAttempt mutations commit
+# in their own transactions (NOT_SUPPORTED / @Transactional), so they survive a test's @Transactional
+# rollback. With a single shared DB name those committed rows leaked across test classes and collided with
+# other classes' deleteAll()/registration (UserAlreadyExist + FK_VERIFY_USER violations). The placeholder
+# resolves once per context, so all connections within a context share one DB while distinct contexts stay
+# isolated. Mirrors the library's own test isolation fix.
+spring.datasource.url=jdbc:h2:mem:testdb-${random.uuid};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.username=sa
spring.datasource.password=sa
# JPA Configuration
-spring.jpa.hibernate.ddl-auto=update
+spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=none