trustedHosts) {
+ return new AppUrlResolver(appUrl, trustedHosts);
+ }
}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/AuthorityService.java b/src/main/java/com/digitalsanctuary/spring/user/service/AuthorityService.java
index f992f07f..fe9a49e4 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/AuthorityService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/AuthorityService.java
@@ -25,7 +25,7 @@
*/
@Slf4j
@RequiredArgsConstructor
-@Service
+@Service("dsAuthorityService")
@Transactional
public class AuthorityService {
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java
index 3cd10f5a..176dc64f 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java
@@ -90,7 +90,7 @@ public User handleOAuthLoginSuccess(String registrationId, OAuth2User oAuth2User
"Unable to retrieve email address from " + registrationId + ". Please ensure you have granted email permissions.");
}
log.debug("handleOAuthLoginSuccess: looking up user with email: {}", user.getEmail());
- User existingUser = userRepository.findByEmail(user.getEmail().toLowerCase());
+ User existingUser = userRepository.findWithRolesByEmail(user.getEmail().toLowerCase());
log.debug("handleOAuthLoginSuccess: existingUser: {}", existingUser);
if (existingUser != null && registrationId != null) {
log.debug("handleOAuthLoginSuccess: existingUser.getProvider(): {}", existingUser.getProvider());
@@ -145,7 +145,8 @@ private User registerNewOAuthUser(String registrationId, User user) {
// RegistrationListener intentionally skips sending them a verification email; the event still fires.
// No HttpServletRequest is available here, so locale defaults and appUrl is null (only the verification
// email, which is skipped for enabled users, would have used appUrl).
- eventPublisher.publishEvent(new OnRegistrationCompleteEvent(savedUser, Locale.getDefault(), null));
+ eventPublisher.publishEvent(OnRegistrationCompleteEvent.builder().userId(savedUser.getId()).userEmail(savedUser.getEmail())
+ .userEnabled(savedUser.isEnabled()).locale(Locale.getDefault()).appUrl(null).build());
return savedUser;
}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java
index 676c2e25..b70f97f6 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java
@@ -96,7 +96,7 @@ public User handleOidcLoginSuccess(String registrationId, OidcUser oidcUser) {
// but we normalize again here defensively in case additional sources are added.
String normalizedEmail = user.getEmail().trim().toLowerCase(Locale.ROOT);
log.debug("handleOidcLoginSuccess: looking up user with email: {}", normalizedEmail);
- User existingUser = userRepository.findByEmail(normalizedEmail);
+ User existingUser = userRepository.findWithRolesByEmail(normalizedEmail);
log.debug("handleOidcLoginSuccess: existingUser: {}", existingUser);
if (existingUser != null && registrationId != null) {
log.debug("handleOidcLoginSuccess: existingUser.getProvider(): {}", existingUser.getProvider());
@@ -151,7 +151,8 @@ private User registerNewOidcUser(String registrationId, User user) {
// RegistrationListener intentionally skips sending them a verification email; the event still fires.
// No HttpServletRequest is available here, so locale defaults and appUrl is null (only the verification
// email, which is skipped for enabled users, would have used appUrl).
- eventPublisher.publishEvent(new OnRegistrationCompleteEvent(savedUser, Locale.getDefault(), null));
+ eventPublisher.publishEvent(OnRegistrationCompleteEvent.builder().userId(savedUser.getId()).userEmail(savedUser.getEmail())
+ .userEnabled(savedUser.isEnabled()).locale(Locale.getDefault()).appUrl(null).build());
return savedUser;
}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java
index 30574462..41d0d8fd 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java
@@ -20,7 +20,7 @@
*/
@Slf4j
@RequiredArgsConstructor
-@Service
+@Service("dsUserDetailsService")
@Transactional
public class DSUserDetailsService implements UserDetailsService {
@@ -42,7 +42,9 @@ public class DSUserDetailsService implements UserDetailsService {
@Override
public DSUserDetails loadUserByUsername(final String email) throws UsernameNotFoundException {
log.debug("DSUserDetailsService.loadUserByUsername: called with username: {}", email);
- User dbUser = userRepository.findByEmail(email);
+ // Use the entity-graph finder so roles and privileges are initialized in a single query before the
+ // principal is detached, allowing AuthorityService to build authorities without a LazyInitializationException.
+ User dbUser = userRepository.findWithRolesByEmail(email);
if (dbUser == null) {
throw new UsernameNotFoundException("No user found with email/username: " + email);
}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java
index abb00212..81c89232 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java
@@ -26,7 +26,7 @@
*/
@Slf4j
@RequiredArgsConstructor
-@Service
+@Service("dsLoginAttemptService")
@Data
public class LoginAttemptService {
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java
index 3fab1958..a62362ef 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java
@@ -54,7 +54,7 @@
*/
@Slf4j
@RequiredArgsConstructor
-@Service
+@Service("dsPasswordPolicyService")
public class PasswordPolicyService {
@Value("${user.security.password.enabled}")
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java
index a3ae6c32..65bf24f1 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java
@@ -35,7 +35,7 @@
*/
@Slf4j
@RequiredArgsConstructor
-@Service
+@Service("dsSessionInvalidationService")
public class SessionInvalidationService {
private final SessionRegistry sessionRegistry;
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java
index cfe4e488..c08f7fa7 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java
@@ -22,6 +22,7 @@
import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken;
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository;
+import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -42,7 +43,7 @@
*/
@Slf4j
@RequiredArgsConstructor
-@Service
+@Service("dsUserEmailService")
public class UserEmailService {
/** The mail service. */
@@ -54,6 +55,9 @@ public class UserEmailService {
/** The password token repository. */
private final PasswordResetTokenRepository passwordTokenRepository;
+ /** The user repository, used to reload a User by id for async-dispatched registration emails. */
+ private final UserRepository userRepository;
+
/** The event publisher. */
private final ApplicationEventPublisher eventPublisher;
@@ -135,6 +139,32 @@ public void sendRegistrationVerificationEmail(final User user, final String appU
mailService.sendTemplateMessage(user.getEmail(), "Registration Confirmation", variables, "mail/registration-token.html");
}
+ /**
+ * Sends the registration verification email for the user with the given id.
+ *
+ * This overload reloads the {@link User} from the repository inside its own transaction. It is used by the
+ * {@code @Async} registration listener, which (as of 5.0.0) receives only the user's id rather than a live JPA
+ * entity, avoiding detached-entity / {@code LazyInitializationException} hazards across threads. If the user no
+ * longer exists (e.g. deleted before the async dispatch ran), the call is a no-op.
+ *
+ * @param userId the id of the user to send the verification email to
+ * @param appUrl the app url (must be a valid HTTP/HTTPS URL)
+ * @throws IllegalArgumentException if appUrl is null, blank, or uses a dangerous scheme
+ */
+ @Transactional
+ public void sendRegistrationVerificationEmail(final Long userId, final String appUrl) {
+ if (userId == null) {
+ log.warn("UserEmailService.sendRegistrationVerificationEmail: null userId; skipping verification email");
+ return;
+ }
+ User user = userRepository.findById(userId).orElse(null);
+ if (user == null) {
+ log.warn("UserEmailService.sendRegistrationVerificationEmail: user {} no longer exists; skipping verification email", userId);
+ return;
+ }
+ sendRegistrationVerificationEmail(user, appUrl);
+ }
+
/**
* Creates the email variables.
*
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java
index f20b8f79..a05451c4 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java
@@ -157,7 +157,7 @@
* @author Devon Hillard
*/
@Slf4j
-@Service
+@Service("dsUserService")
@RequiredArgsConstructor
@Transactional
public class UserService {
@@ -231,8 +231,6 @@ public String getValue() {
/** The user verification service. */
public final UserVerificationService userVerificationService;
- private final AuthorityService authorityService;
-
/** The user details service. */
private final DSUserDetailsService dsUserDetailsService;
@@ -511,7 +509,7 @@ public void deleteOrDisableUser(final User user) {
// Publish the UserPreDeleteEvent before deleting the user
// This allows any listeners to perform actions before the user is deleted
log.debug("Publishing UserPreDeleteEvent");
- eventPublisher.publishEvent(new UserPreDeleteEvent(this, user));
+ eventPublisher.publishEvent(new UserPreDeleteEvent(this, userId, userEmail));
// Clean up any Tokens associated with this user
final VerificationToken verificationToken = tokenRepository.findByUser(user);
@@ -1036,8 +1034,10 @@ public void authWithoutPassword(User user) {
return;
}
- // Generate authorities from user roles and privileges
- Collection extends GrantedAuthority> authorities = authorityService.getAuthoritiesFromUser(user);
+ // Reuse the authorities already resolved by loadUserByUsername (which loads roles and privileges via the
+ // entity-graph finder). The incoming `user` may be detached, so deriving authorities from it directly could
+ // trigger a LazyInitializationException now that roles/privileges are lazily fetched.
+ Collection extends GrantedAuthority> authorities = userDetails.getAuthorities();
// Authenticate user
authenticateUser(userDetails, authorities);
diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/AppUrlResolver.java b/src/main/java/com/digitalsanctuary/spring/user/util/AppUrlResolver.java
new file mode 100644
index 00000000..063ad59e
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/util/AppUrlResolver.java
@@ -0,0 +1,148 @@
+package com.digitalsanctuary.spring.user.util;
+
+import java.util.List;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Resolves the application base URL used to build security-sensitive email links (password reset, email verification), defending against Host-header /
+ * X-Forwarded-Host poisoning (CWE-640).
+ *
+ *
+ * Resolution order:
+ *
+ * - If {@code user.security.appUrl} is configured, always use it (forwarded headers ignored).
+ * - Otherwise build from the request, honoring {@code X-Forwarded-*} only when the resolved host is in {@code user.security.trustedHosts};
+ * otherwise use the container's own server name (the untrusted forwarded host is ignored and a warning is logged).
+ *
+ *
+ *
+ * The request-derived URL preserves the historical {@code UserUtils.getAppUrl} format with one refinement: the {@code :port} suffix is omitted when the
+ * port is the default for the scheme (80 for {@code http}, 443 for {@code https}), and the context path is appended only when non-empty. This keeps
+ * link formatting stable for existing deployments while producing clean canonical URLs.
+ */
+@Slf4j
+public class AppUrlResolver {
+
+ private static final int DEFAULT_HTTP_PORT = 80;
+ private static final int DEFAULT_HTTPS_PORT = 443;
+
+ private final String configuredAppUrl;
+ private final List trustedHosts;
+
+ /**
+ * Creates a resolver.
+ *
+ * @param configuredAppUrl the canonical base URL ({@code user.security.appUrl}); blank/null means "not configured"
+ * @param trustedHosts the allow-listed forwarded hosts ({@code user.security.trustedHosts}); null is treated as empty
+ */
+ public AppUrlResolver(String configuredAppUrl, List trustedHosts) {
+ String trimmed = (configuredAppUrl == null || configuredAppUrl.isBlank()) ? null : configuredAppUrl.trim();
+ // Strip any trailing slash to honour the "no trailing slash" contract on resolveAppUrl's return value.
+ // Without this, appUrl + "/user/..." produces double slashes when the consumer misconfigures a trailing slash.
+ this.configuredAppUrl = (trimmed != null && trimmed.endsWith("/")) ? trimmed.replaceAll("/+$", "") : trimmed;
+ this.trustedHosts = trustedHosts == null ? List.of() : trustedHosts.stream().map(String::trim).toList();
+ }
+
+ /**
+ * Resolves the application base URL for the given request.
+ *
+ * @param request the current HTTP request
+ * @return the resolved base URL (no trailing slash)
+ */
+ public String resolveAppUrl(HttpServletRequest request) {
+ if (configuredAppUrl != null) {
+ return configuredAppUrl;
+ }
+
+ // X-Forwarded-Host may be a comma-separated list when the request traverses multiple proxies
+ // (RFC 7230); the client-facing host is the first value. Match the allow-list against that value
+ // only, otherwise legitimate multi-proxy chains (e.g. ALB + nginx) never match and silently fall
+ // back to the container's server name.
+ String fwdHost = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
+ boolean useForwarded = fwdHost != null && !fwdHost.isEmpty() && trustedHosts.contains(stripPort(fwdHost));
+ if (fwdHost != null && !fwdHost.isEmpty() && !useForwarded) {
+ log.warn("AppUrlResolver: ignoring untrusted X-Forwarded-Host '{}' (not in user.security.trustedHosts)", sanitizeForLog(fwdHost));
+ }
+
+ String scheme = useForwarded ? forwardedScheme(request) : request.getScheme();
+ String host = useForwarded ? stripPort(fwdHost) : request.getServerName();
+ int port = useForwarded ? forwardedPort(request, scheme) : request.getServerPort();
+
+ StringBuilder url = new StringBuilder();
+ url.append(scheme).append("://").append(host);
+ if (!isDefaultPort(scheme, port)) {
+ url.append(':').append(port);
+ }
+ String contextPath = request.getContextPath();
+ if (contextPath != null && !contextPath.isEmpty()) {
+ url.append(contextPath);
+ }
+ return url.toString();
+ }
+
+ private static int forwardedPort(HttpServletRequest request, String forwardedScheme) {
+ String portHeader = firstHeaderValue(request.getHeader("X-Forwarded-Port"));
+ if (portHeader != null && !portHeader.isBlank()) {
+ try {
+ return Integer.parseInt(portHeader.trim());
+ } catch (NumberFormatException e) {
+ log.warn("AppUrlResolver: ignoring non-numeric X-Forwarded-Port '{}'", sanitizeForLog(portHeader));
+ }
+ }
+ // No usable X-Forwarded-Port: derive the port from the forwarded scheme so we don't leak the
+ // container's internal port (e.g. 8080) into the email link. Default ports are omitted later.
+ return "https".equalsIgnoreCase(forwardedScheme) ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
+ }
+
+ /**
+ * Resolves the forwarded scheme from {@code X-Forwarded-Proto}, accepting only {@code http} or {@code https}. A trusted proxy is expected to send a
+ * sane value, but a misconfigured or compromised one sending e.g. {@code javascript} must never be allowed to flow into a security-sensitive email
+ * link, so any unrecognized value falls back to the container's own scheme.
+ */
+ private static String forwardedScheme(HttpServletRequest request) {
+ String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
+ if (proto == null || proto.isEmpty()) {
+ return request.getScheme();
+ }
+ if ("http".equalsIgnoreCase(proto) || "https".equalsIgnoreCase(proto)) {
+ return proto;
+ }
+ log.warn("AppUrlResolver: ignoring invalid X-Forwarded-Proto '{}', falling back to request scheme", sanitizeForLog(proto));
+ return request.getScheme();
+ }
+
+ /**
+ * Returns the first value of a possibly comma-separated forwarded header (RFC 7230), trimmed. Returns {@code null} for a null input.
+ */
+ private static String firstHeaderValue(String headerValue) {
+ if (headerValue == null) {
+ return null;
+ }
+ int comma = headerValue.indexOf(',');
+ String first = comma >= 0 ? headerValue.substring(0, comma) : headerValue;
+ return first.trim();
+ }
+
+ /**
+ * Neutralizes CR/LF/tab in attacker-controlled header values before logging to prevent log-injection / forging.
+ */
+ private static String sanitizeForLog(String value) {
+ return value == null ? null : value.replaceAll("[\r\n\t]", "_");
+ }
+
+ private static boolean isDefaultPort(String scheme, int port) {
+ return ("http".equalsIgnoreCase(scheme) && port == DEFAULT_HTTP_PORT)
+ || ("https".equalsIgnoreCase(scheme) && port == DEFAULT_HTTPS_PORT);
+ }
+
+ private static String stripPort(String host) {
+ if (host.startsWith("[")) {
+ // IPv6 literal: [::1] or [::1]:8443
+ int bracket = host.indexOf(']');
+ return bracket > 0 ? host.substring(0, bracket + 1) : host;
+ }
+ int colon = host.lastIndexOf(':');
+ return colon > 0 ? host.substring(0, colon) : host;
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/UserUtils.java b/src/main/java/com/digitalsanctuary/spring/user/util/UserUtils.java
index b659b5a9..9c526a9a 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/util/UserUtils.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/util/UserUtils.java
@@ -50,7 +50,13 @@ public static String getClientIP(HttpServletRequest request) {
*
* @param request The HttpServletRequest object.
* @return The application URL as a String.
+ * @deprecated This method trusts {@code X-Forwarded-Host} unconditionally, which makes security-sensitive email links (password reset, email
+ * verification) vulnerable to Host-header / X-Forwarded-Host poisoning (CWE-640). Use
+ * {@link com.digitalsanctuary.spring.user.util.AppUrlResolver#resolveAppUrl(HttpServletRequest)} instead, which builds the base URL from
+ * a configured canonical URL ({@code user.security.appUrl}) and/or a trusted-host allow-list ({@code user.security.trustedHosts}).
+ * Scheduled for removal in a future release.
*/
+ @Deprecated(forRemoval = true)
public static String getAppUrl(HttpServletRequest request) {
// Check for forwarded protocol
String scheme = request.getHeader("X-Forwarded-Proto");
diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index 09914cfa..248d87b7 100644
--- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -36,6 +36,16 @@
"type": "java.lang.String",
"description": "Cron expression for token purge schedule"
},
+ {
+ "name": "user.security.appUrl",
+ "type": "java.lang.String",
+ "description": "Canonical base URL for security email links (password reset, verification). STRONGLY recommended in production to prevent Host-header poisoning (CWE-640). When set, X-Forwarded-Host is ignored."
+ },
+ {
+ "name": "user.security.trustedHosts",
+ "type": "java.util.List",
+ "description": "When user.security.appUrl is not set, X-Forwarded-Host is honored only for hosts in this comma-separated allowlist; otherwise the container's own server name is used."
+ },
{
"name": "user.security.loginActionURI",
"type": "java.lang.String",
@@ -301,6 +311,30 @@
"name": "user.web.globalUserModelOptIn",
"type": "java.lang.Boolean",
"description": "Add user to model by default (true) or only with annotation (false)"
+ },
+ {
+ "name": "user.async.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Enable the library's asynchronous method execution support (@EnableAsync). Set to false if the consuming application already enables async processing globally to avoid double-activation.",
+ "defaultValue": true
+ },
+ {
+ "name": "user.retry.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Enable the library's Spring Retry support (@EnableRetry). Set to false if the consuming application already enables retry globally to avoid double-activation.",
+ "defaultValue": true
+ },
+ {
+ "name": "user.scheduling.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Enable the library's scheduled task execution (@EnableScheduling), used by token purge and similar jobs. Set to false if the consuming application already enables scheduling globally to avoid double-activation.",
+ "defaultValue": true
+ },
+ {
+ "name": "user.method-security.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Enable the library's Spring Security method-level security (@EnableMethodSecurity). Set to false if the consuming application already enables method security globally to avoid double-activation.",
+ "defaultValue": true
}
]
}
\ No newline at end of file
diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports b/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports
new file mode 100644
index 00000000..5b8e3f8d
--- /dev/null
+++ b/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports
@@ -0,0 +1 @@
+com.digitalsanctuary.spring.user.MessageSourceEnvironmentPostProcessor
diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties
index 7582f512..2e5cb603 100644
--- a/src/main/resources/config/dsspringuserconfig.properties
+++ b/src/main/resources/config/dsspringuserconfig.properties
@@ -7,9 +7,6 @@ spring.data.jpa.repositories.packages=com.digitalsanctuary.spring.user.persisten
spring.jpa.entity.packages=com.digitalsanctuary.spring.user.persistence.model
-# Spring Configuration Overrides
-spring.messages.basename=messages/messages,messages/dsspringusermessages
-
# DigitalSanctuary Spring User Configuration
# User Audit Log Configuration
@@ -81,6 +78,11 @@ user.security.bcryptStrength=12
# user.security.tokenHashSecret=
# The lifetime, in minutes, of a password reset token before it expires. Default is 1440 (24 hours).
user.security.passwordResetTokenValidityMinutes=1440
+# Canonical base URL for security email links (password reset, verification). STRONGLY recommended in production
+# to prevent Host-header poisoning (CWE-640). When set, X-Forwarded-Host is ignored.
+user.security.appUrl=
+# When appUrl is not set, X-Forwarded-Host is honored only for hosts in this comma-separated allowlist.
+user.security.trustedHosts=
# If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value.
user.security.testHashTime=true
# The default action for all requests. This can be either deny or allow.
diff --git a/src/test/java/com/digitalsanctuary/spring/user/MessageSourceEnvironmentPostProcessorTest.java b/src/test/java/com/digitalsanctuary/spring/user/MessageSourceEnvironmentPostProcessorTest.java
new file mode 100644
index 00000000..8792a83b
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/MessageSourceEnvironmentPostProcessorTest.java
@@ -0,0 +1,84 @@
+package com.digitalsanctuary.spring.user;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.util.Locale;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.MessageSource;
+
+/**
+ * Verifies that {@link MessageSourceEnvironmentPostProcessor} registers the library's message bundle ADDITIVELY, preserving the consuming application's
+ * own {@code spring.messages.basename} (or Spring Boot's conventional default) rather than overriding it.
+ */
+@DisplayName("MessageSourceEnvironmentPostProcessor Tests")
+class MessageSourceEnvironmentPostProcessorTest {
+
+ @Nested
+ @DisplayName("Basename merge logic")
+ class MergeLogic {
+
+ @Test
+ @DisplayName("should preserve Spring Boot default and append library bundle when basename is unset")
+ void shouldPreserveDefaultWhenUnset() {
+ assertThat(MessageSourceEnvironmentPostProcessor.mergeBasename(null)).isEqualTo("messages,messages/dsspringusermessages");
+ assertThat(MessageSourceEnvironmentPostProcessor.mergeBasename("")).isEqualTo("messages,messages/dsspringusermessages");
+ assertThat(MessageSourceEnvironmentPostProcessor.mergeBasename(" ")).isEqualTo("messages,messages/dsspringusermessages");
+ }
+
+ @Test
+ @DisplayName("should preserve a single consumer basename and append library bundle last")
+ void shouldPreserveSingleConsumerBasename() {
+ assertThat(MessageSourceEnvironmentPostProcessor.mergeBasename("i18n/app"))
+ .isEqualTo("i18n/app,messages/dsspringusermessages");
+ }
+
+ @Test
+ @DisplayName("should preserve multiple consumer basenames in order with library bundle last")
+ void shouldPreserveMultipleConsumerBasenames() {
+ assertThat(MessageSourceEnvironmentPostProcessor.mergeBasename("messages,i18n/labels"))
+ .isEqualTo("messages,i18n/labels,messages/dsspringusermessages");
+ }
+
+ @Test
+ @DisplayName("should trim whitespace around consumer basenames")
+ void shouldTrimWhitespace() {
+ assertThat(MessageSourceEnvironmentPostProcessor.mergeBasename(" messages , i18n/app "))
+ .isEqualTo("messages,i18n/app,messages/dsspringusermessages");
+ }
+
+ @Test
+ @DisplayName("should de-duplicate and keep the library bundle last if the consumer already lists it")
+ void shouldDeduplicateLibraryBundle() {
+ assertThat(MessageSourceEnvironmentPostProcessor.mergeBasename("messages/dsspringusermessages,messages"))
+ .isEqualTo("messages,messages/dsspringusermessages");
+ }
+ }
+
+ @Nested
+ @DisplayName("Additive registration through MessageSource")
+ class AdditiveRegistration {
+
+ // Boots only MessageSourceAutoConfiguration, then applies the merged basename the post-processor would produce for a consumer who points at
+ // their own bundle (test-messages/consumer-bundle). Both the consumer key and the library key must resolve.
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(MessageSourceAutoConfiguration.class))
+ .withPropertyValues("spring.messages.basename="
+ + MessageSourceEnvironmentPostProcessor.mergeBasename("test-messages/consumer-bundle"));
+
+ @Test
+ @DisplayName("should resolve BOTH a consumer-bundle key AND a library-bundle key")
+ void shouldResolveConsumerAndLibraryKeys() {
+ contextRunner.run(context -> {
+ MessageSource messageSource = context.getBean(MessageSource.class);
+ assertThat(messageSource.getMessage("consumer.test.key", null, Locale.ENGLISH))
+ .as("consumer's own bundle must still resolve").isEqualTo("Consumer bundle value");
+ assertThat(messageSource.getMessage("email.forgot-password.prompt", null, Locale.ENGLISH))
+ .as("library bundle must also resolve").isEqualTo("Reset your password");
+ });
+ }
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/UserConfigurationToggleTest.java b/src/test/java/com/digitalsanctuary/spring/user/UserConfigurationToggleTest.java
new file mode 100644
index 00000000..ca2e2fa3
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/UserConfigurationToggleTest.java
@@ -0,0 +1,87 @@
+package com.digitalsanctuary.spring.user;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+/**
+ * Verifies that the cross-cutting {@code @Enable*} support enabled by {@link UserConfiguration} is
+ * individually toggleable and defaults to on (backward compatible).
+ *
+ *
+ * The four nested gating configurations are registered directly (rather than the full scanning
+ * {@link UserConfiguration} entry point) so their {@code @ConditionalOnProperty(matchIfMissing = true)}
+ * gates can be exercised in isolation. Registering the full {@code UserConfiguration} here would trigger
+ * its broad {@code @ComponentScan("com.digitalsanctuary.spring.user")}, which in the test classpath also
+ * picks up unrelated test fixtures and conflicts with other tests' contexts. The gating predicates are
+ * identical regardless of how the nested config is registered, and the full
+ * {@code @IntegrationTest}/{@code @SecurityTest} suite remains the oracle for end-to-end wiring with the
+ * real entry point.
+ */
+@DisplayName("UserConfiguration Cross-Cutting Toggle Tests")
+class UserConfigurationToggleTest {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner().withUserConfiguration(
+ UserConfiguration.AsyncConfiguration.class, UserConfiguration.RetryConfiguration.class,
+ UserConfiguration.SchedulingConfiguration.class, UserConfiguration.MethodSecurityConfiguration.class);
+
+ @Test
+ @DisplayName("Defaults (no properties set): all nested cross-cutting configs are active")
+ void defaultsActivateAllCrossCuttingConfigs() {
+ runner.run(ctx -> {
+ assertThat(ctx).hasSingleBean(UserConfiguration.AsyncConfiguration.class);
+ assertThat(ctx).hasSingleBean(UserConfiguration.RetryConfiguration.class);
+ assertThat(ctx).hasSingleBean(UserConfiguration.SchedulingConfiguration.class);
+ assertThat(ctx).hasSingleBean(UserConfiguration.MethodSecurityConfiguration.class);
+ });
+ }
+
+ @Test
+ @DisplayName("user.method-security.enabled=false: method security nested config does not activate")
+ void methodSecurityToggleOff() {
+ runner.withPropertyValues("user.method-security.enabled=false").run(ctx -> {
+ assertThat(ctx).doesNotHaveBean(UserConfiguration.MethodSecurityConfiguration.class);
+ // Other cross-cutting configs remain active and independent.
+ assertThat(ctx).hasSingleBean(UserConfiguration.AsyncConfiguration.class);
+ assertThat(ctx).hasSingleBean(UserConfiguration.RetryConfiguration.class);
+ assertThat(ctx).hasSingleBean(UserConfiguration.SchedulingConfiguration.class);
+ });
+ }
+
+ @Test
+ @DisplayName("user.method-security.enabled=true (explicit): method security nested config activates")
+ void methodSecurityToggleOnExplicit() {
+ runner.withPropertyValues("user.method-security.enabled=true")
+ .run(ctx -> assertThat(ctx).hasSingleBean(UserConfiguration.MethodSecurityConfiguration.class));
+ }
+
+ @Test
+ @DisplayName("user.scheduling.enabled=false: scheduling nested config does not activate")
+ void schedulingToggleOff() {
+ runner.withPropertyValues("user.scheduling.enabled=false").run(ctx -> {
+ assertThat(ctx).doesNotHaveBean(UserConfiguration.SchedulingConfiguration.class);
+ assertThat(ctx).hasSingleBean(UserConfiguration.AsyncConfiguration.class);
+ });
+ }
+
+ @Test
+ @DisplayName("user.async.enabled=false: async nested config does not activate")
+ void asyncToggleOff() {
+ runner.withPropertyValues("user.async.enabled=false").run(ctx -> {
+ assertThat(ctx).doesNotHaveBean(UserConfiguration.AsyncConfiguration.class);
+ assertThat(ctx).hasSingleBean(UserConfiguration.RetryConfiguration.class);
+ });
+ }
+
+ @Test
+ @DisplayName("user.retry.enabled=false: retry nested config does not activate")
+ void retryToggleOff() {
+ runner.withPropertyValues("user.retry.enabled=false").run(ctx -> {
+ assertThat(ctx).doesNotHaveBean(UserConfiguration.RetryConfiguration.class);
+ // Other cross-cutting configs remain active and independent.
+ assertThat(ctx).hasSingleBean(UserConfiguration.AsyncConfiguration.class);
+ });
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java
index e26c8938..2fdc003e 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java
@@ -2,6 +2,8 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -28,11 +30,14 @@
import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto;
import com.digitalsanctuary.spring.user.dto.UserDto;
+import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent;
+import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException;
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException;
import com.digitalsanctuary.spring.user.service.PasswordPolicyService;
import com.digitalsanctuary.spring.user.service.UserEmailService;
import com.digitalsanctuary.spring.user.service.UserService;
+import com.digitalsanctuary.spring.user.util.AppUrlResolver;
import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService;
import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -67,6 +72,9 @@ class UserAPIRegistrationGuardTest {
@Mock
private WebAuthnCredentialManagementService webAuthnService;
+ @Mock
+ private AppUrlResolver appUrlResolver;
+
@InjectMocks
private UserAPI userAPI;
@@ -190,5 +198,33 @@ void shouldAllowPasswordlessRegistrationWhenGuardAllows() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
+
+ @Test
+ @DisplayName("Should return uniform success response for existing email (anti-enumeration, no 409)")
+ void shouldReturnUniformResponseForExistingEmail() throws Exception {
+ PasswordlessRegistrationDto dto = new PasswordlessRegistrationDto();
+ dto.setEmail("existing@example.com");
+ dto.setFirstName("Test");
+ dto.setLastName("User");
+
+ when(webAuthnCredentialManagementServiceProvider.getIfAvailable()).thenReturn(webAuthnService);
+ // The email is already registered: the service signals it via UserAlreadyExistException.
+ when(userService.registerPasswordlessAccount(any(PasswordlessRegistrationDto.class)))
+ .thenThrow(new UserAlreadyExistException("User already exists"));
+
+ // Response is indistinguishable from a brand-new passwordless registration: HTTP 200,
+ // success-shaped body, redirect to the pending URI. No longer a 409 that would enumerate.
+ mockMvc.perform(post("/user/registration/passwordless")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(dto))
+ .with(csrf()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.redirectUrl").value("/user/registration-pending.html"));
+
+ // No new account is created: no registration event is published.
+ verify(eventPublisher, never()).publishEvent(any(OnRegistrationCompleteEvent.class));
+ }
}
}
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 62be1f0d..420a6411 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java
@@ -1,13 +1,22 @@
package com.digitalsanctuary.spring.user.api;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.*;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+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.Collections;
+import java.util.Locale;
import com.digitalsanctuary.spring.user.audit.AuditEvent;
import com.digitalsanctuary.spring.user.dto.PasswordDto;
@@ -22,9 +31,9 @@
import com.digitalsanctuary.spring.user.service.UserEmailService;
import com.digitalsanctuary.spring.user.service.UserService;
import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
+import com.digitalsanctuary.spring.user.util.AppUrlResolver;
import com.digitalsanctuary.spring.user.util.JSONResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
-
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -36,22 +45,16 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.MessageSource;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
+import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
-import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
-import org.springframework.web.method.support.HandlerMethodArgumentResolver;
-import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
+import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
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;
-
-import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
@ExtendWith(MockitoExtension.class)
@DisplayName("UserAPI Unit Tests")
@@ -91,6 +94,9 @@ public JSONResponse handleSecurityException(SecurityException e) {
@Mock
private PasswordPolicyService passwordPolicyService;
+ @Mock
+ private AppUrlResolver appUrlResolver;
+
@InjectMocks
private UserAPI userAPI;
@@ -151,16 +157,16 @@ void registerUserAccount_success_withVerificationEmail() throws Exception {
.thenReturn(Collections.emptyList());
// When & Then
- MvcResult result = mockMvc.perform(post("/user/registration")
+ mockMvc.perform(post("/user/registration")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.code").value(0))
- .andExpect(jsonPath("$.messages[0]").value("Registration Successful!"))
- .andExpect(jsonPath("$.redirectUrl").value("/user/registration-pending.html"))
- .andReturn();
+ .andExpect(jsonPath("$.messages[0]")
+ .value("If your email address is eligible, you will receive a verification email shortly."))
+ .andExpect(jsonPath("$.redirectUrl").value("/user/registration-pending.html"));
// Verify event publishing
verify(eventPublisher, times(2)).publishEvent(any());
@@ -169,7 +175,8 @@ void registerUserAccount_success_withVerificationEmail() throws Exception {
ArgumentCaptor registrationCaptor = ArgumentCaptor.forClass(OnRegistrationCompleteEvent.class);
verify(eventPublisher).publishEvent(registrationCaptor.capture());
OnRegistrationCompleteEvent registrationEvent = registrationCaptor.getValue();
- assertThat(registrationEvent.getUser()).isEqualTo(newUser);
+ assertThat(registrationEvent.getUserEmail()).isEqualTo(newUser.getEmail());
+ assertThat(registrationEvent.isUserEnabled()).isEqualTo(newUser.isEnabled());
ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(AuditEvent.class);
verify(eventPublisher).publishEvent(auditCaptor.capture());
@@ -205,23 +212,29 @@ void registerUserAccount_success_withAutoLogin() throws Exception {
}
@Test
- @DisplayName("POST /user/registration - user already exists")
- void registerUserAccount_userAlreadyExists() throws Exception {
- // Given
+ @DisplayName("POST /user/registration - existing email returns the same uniform 200 body as a new registration")
+ void registerUserAccount_existingEmail_returnsUniformResponse() throws Exception {
+ // Given - the service signals the email is already registered
when(userService.registerNewUserAccount(any(UserDto.class)))
.thenThrow(new UserAlreadyExistException("User already exists"));
when(passwordPolicyService.validate(any(), anyString(), anyString(), any(Locale.class)))
.thenReturn(Collections.emptyList());
- // When & Then
+ // When & Then - response is indistinguishable from a brand-new registration
mockMvc.perform(post("/user/registration")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.with(csrf()))
- .andExpect(status().isConflict())
- .andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.code").value(2))
- .andExpect(jsonPath("$.messages[0]").value("An account already exists for the email address"));
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.messages[0]")
+ .value("If your email address is eligible, you will receive a verification email shortly."))
+ .andExpect(jsonPath("$.redirectUrl").value("/user/registration-pending.html"));
+
+ // No new account is created: no registration event is published and no auto-login occurs.
+ verify(eventPublisher, never()).publishEvent(any(OnRegistrationCompleteEvent.class));
+ verify(userService, never()).authWithoutPassword(any());
}
@Test
@@ -286,6 +299,7 @@ void resendRegistrationToken_success() throws Exception {
.disabled()
.build();
when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(unverifiedUser);
+ when(appUrlResolver.resolveAppUrl(any())).thenReturn("http://localhost:8080");
// When & Then
mockMvc.perform(post("/user/resendRegistrationToken")
@@ -294,42 +308,51 @@ void resendRegistrationToken_success() throws Exception {
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
- .andExpect(jsonPath("$.messages[0]").value("Verification Email Resent Successfully!"));
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.messages[0]")
+ .value("If your account requires verification, a new verification email has been sent."));
verify(userEmailService).sendRegistrationVerificationEmail(eq(unverifiedUser), anyString());
}
@Test
- @DisplayName("POST /user/resendRegistrationToken - account already verified")
- void resendRegistrationToken_alreadyVerified() throws Exception {
- // Given
+ @DisplayName("POST /user/resendRegistrationToken - already-verified account returns the same uniform 200 body")
+ void resendRegistrationToken_alreadyVerified_returnsUniformResponse() throws Exception {
+ // Given - an existing, already-enabled (verified) account
when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(testUser); // enabled user
- // When & Then
+ // When & Then - same response as the unverified case, and no email is sent
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.with(csrf()))
- .andExpect(status().isConflict())
- .andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.code").value(1))
- .andExpect(jsonPath("$.messages[0]").value("Account is already verified."));
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.messages[0]")
+ .value("If your account requires verification, a new verification email has been sent."));
+
+ verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString());
}
@Test
- @DisplayName("POST /user/resendRegistrationToken - user not found")
- void resendRegistrationToken_userNotFound() throws Exception {
- // Given
+ @DisplayName("POST /user/resendRegistrationToken - unknown email returns the same uniform 200 body (no 500 leak)")
+ void resendRegistrationToken_unknownEmail_returnsUniformResponse() throws Exception {
+ // Given - no account exists for the email
when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(null);
- // When & Then
+ // When & Then - same uniform 200 response; nothing leaks existence
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.with(csrf()))
- .andExpect(status().isInternalServerError())
- .andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.code").value(2));
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.messages[0]")
+ .value("If your account requires verification, a new verification email has been sent."));
+
+ verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString());
}
}
@@ -342,6 +365,7 @@ class PasswordManagementTests {
void resetPassword_success() throws Exception {
// Given
when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(testUser);
+ when(appUrlResolver.resolveAppUrl(any())).thenReturn("http://localhost:8080");
// When & Then
mockMvc.perform(post("/user/resetPassword")
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java
index f269140b..44385ee5 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java
@@ -215,26 +215,32 @@ void shouldRegisterNewUser() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.code").value(0))
- .andExpect(jsonPath("$.messages[0]").value("Registration Successful!"));
+ .andExpect(jsonPath("$.messages[0]")
+ .value("If your email address is eligible, you will receive a verification email shortly."));
assertThat(userService.findUserByEmail(testEmail)).isNotNull();
}
@Test
- @DisplayName("Should return 409 Conflict when the user already exists")
- void shouldRejectExistingUser() throws Exception {
+ @DisplayName("Should return the same uniform 200 body for an existing email (anti-enumeration) and create no duplicate")
+ void shouldReturnUniformResponseForExistingUser() throws Exception {
// Register once.
userService.registerNewUserAccount(baseTestUser);
+ Long existingId = userService.findUserByEmail(testEmail).getId();
- // Register again with the same email.
+ // Register again with the same email - response must be indistinguishable from a new registration.
mockMvc.perform(post(URL + "/registration")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(json(baseTestUser)))
- .andExpect(status().isConflict())
- .andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.code").value(2))
- .andExpect(jsonPath("$.messages[0]").value("An account already exists for the email address"));
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.messages[0]")
+ .value("If your email address is eligible, you will receive a verification email shortly."));
+
+ // No duplicate account was created - the existing account is untouched.
+ assertThat(userService.findUserByEmail(testEmail).getId()).isEqualTo(existingId);
}
@Test
@@ -248,6 +254,32 @@ void shouldRejectInvalidUser() throws Exception {
.content(json(new UserDto())))
.andExpect(status().isBadRequest());
}
+
+ @Test
+ @DisplayName("Should return 400 with a structured error body when password and matchingPassword mismatch (class-level @PasswordMatches)")
+ void shouldReturnBadRequestWhenPasswordsDoNotMatch() throws Exception {
+ // The class-level @PasswordMatches constraint produces a GLOBAL (not field) binding error.
+ // The library's validation advice must surface this as a structured 400 - not a 500.
+ UserDto mismatched = new UserDto();
+ mismatched.setFirstName("Api");
+ mismatched.setLastName("Tester");
+ mismatched.setEmail(testEmail);
+ mismatched.setPassword(VALID_PASSWORD);
+ mismatched.setMatchingPassword(NEW_VALID_PASSWORD);
+
+ mockMvc.perform(post(URL + "/registration")
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(json(mismatched)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("Validation failed"))
+ .andExpect(jsonPath("$.errors").isNotEmpty());
+
+ // The mismatched registration must not have created an account.
+ assertThat(userService.findUserByEmail(testEmail)).isNull();
+ }
}
@Nested
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnFeatureEnabledIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnFeatureEnabledIntegrationTest.java
index b1b76908..bfe33ec1 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnFeatureEnabledIntegrationTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnFeatureEnabledIntegrationTest.java
@@ -17,6 +17,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
+import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
@@ -36,6 +37,8 @@ class WebAuthnFeatureEnabledIntegrationTest {
private static final String TEST_EMAIL = "webauthn-user@test.com";
+ private static final String TEST_PASSWORD = "currentPassword1!";
+
@Autowired
private MockMvc mockMvc;
@@ -54,6 +57,9 @@ class WebAuthnFeatureEnabledIntegrationTest {
@Autowired
private WebAuthnCredentialManagementService credentialManagementService;
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+
@BeforeEach
void setUp() {
webAuthnCredentialRepository.deleteAll();
@@ -65,7 +71,7 @@ void setUp() {
user.setFirstName("Web");
user.setLastName("Authn");
user.setEnabled(true);
- user.setPassword("encoded-password");
+ user.setPassword(passwordEncoder.encode(TEST_PASSWORD));
User savedUser = userRepository.saveAndFlush(user);
WebAuthnUserEntity userEntity = new WebAuthnUserEntity();
@@ -123,8 +129,56 @@ void shouldReturnValidationResponseForOverlongLabel() throws Exception {
@Test
@DisplayName("should return business error response when service throws WebAuthnException")
void shouldReturnBusinessErrorWhenServiceFails() throws Exception {
- mockMvc.perform(delete("/user/webauthn/credentials/does-not-exist").with(user(TEST_EMAIL).roles("USER")).with(csrf()))
- .andExpect(status().isBadRequest())
+ String payload = "{\"currentPassword\":\"" + TEST_PASSWORD + "\"}";
+ mockMvc.perform(delete("/user/webauthn/credentials/does-not-exist").with(user(TEST_EMAIL).roles("USER")).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(payload)).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Credential not found or access denied"));
}
+
+ @Test
+ @DisplayName("should reject passkey deletion when account has password and current password missing")
+ void shouldRejectDeleteWhenCurrentPasswordMissingForPasswordAccount() throws Exception {
+ mockMvc.perform(delete("/user/webauthn/credentials/cred-1").with(user(TEST_EMAIL).roles("USER")).with(csrf()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", containsString("Current password is required")));
+
+ assertThat(webAuthnCredentialRepository.findByIdWithUser("cred-1")).isPresent();
+ }
+
+ @Test
+ @DisplayName("should reject passkey deletion when account has password and current password incorrect")
+ void shouldRejectDeleteWhenCurrentPasswordIncorrectForPasswordAccount() throws Exception {
+ String payload = "{\"currentPassword\":\"wrongPassword!\"}";
+ mockMvc.perform(delete("/user/webauthn/credentials/cred-1").with(user(TEST_EMAIL).roles("USER")).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(payload)).andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", containsString("Current password is incorrect")));
+
+ assertThat(webAuthnCredentialRepository.findByIdWithUser("cred-1")).isPresent();
+ }
+
+ @Test
+ @DisplayName("should reject passkey rename when account has password and current password missing")
+ void shouldRejectRenameWhenCurrentPasswordMissingForPasswordAccount() throws Exception {
+ String payload = "{\"label\":\"New Label\"}";
+ mockMvc.perform(put("/user/webauthn/credentials/cred-1/label").with(user(TEST_EMAIL).roles("USER")).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(payload)).andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", containsString("Current password is required")));
+
+ assertThat(webAuthnCredentialRepository.findByIdWithUser("cred-1"))
+ .isPresent()
+ .hasValueSatisfying(c -> assertThat(c.getLabel()).isEqualTo("My Device"));
+ }
+
+ @Test
+ @DisplayName("should reject passkey rename when account has password and current password incorrect")
+ void shouldRejectRenameWhenCurrentPasswordIncorrectForPasswordAccount() throws Exception {
+ String payload = "{\"label\":\"New Label\",\"currentPassword\":\"wrongPassword!\"}";
+ mockMvc.perform(put("/user/webauthn/credentials/cred-1/label").with(user(TEST_EMAIL).roles("USER")).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(payload)).andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message", containsString("Current password is incorrect")));
+
+ assertThat(webAuthnCredentialRepository.findByIdWithUser("cred-1"))
+ .isPresent()
+ .hasValueSatisfying(c -> assertThat(c.getLabel()).isEqualTo("My Device"));
+ }
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java
index ae1b1dc1..f669cff7 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java
@@ -27,6 +27,7 @@
import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
import com.digitalsanctuary.spring.user.exceptions.WebAuthnUserNotFoundException;
import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.service.LoginAttemptService;
import com.digitalsanctuary.spring.user.service.UserService;
import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService;
import com.digitalsanctuary.spring.user.test.annotations.ServiceTest;
@@ -48,6 +49,9 @@ class WebAuthnManagementAPITest {
@Mock
private ApplicationEventPublisher eventPublisher;
+ @Mock
+ private LoginAttemptService loginAttemptService;
+
@Mock
private UserDetails userDetails;
@@ -161,10 +165,12 @@ void shouldThrowNotFoundWhenUserNotFound() {
class RenameCredentialTests {
@Test
- @DisplayName("should rename credential successfully")
- void shouldRenameSuccessfully() {
+ @DisplayName("should rename credential successfully when account is passwordless")
+ void shouldRenameSuccessfullyWhenAccountPasswordless() {
// Given
- WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop");
+ testUser.setPassword(null);
+ when(userService.hasPassword(testUser)).thenReturn(false);
+ WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop", null);
// When
ResponseEntity response = api.renameCredential("cred-1", request, userDetails);
@@ -175,11 +181,85 @@ void shouldRenameSuccessfully() {
verify(credentialManagementService).renameCredential("cred-1", "Work Laptop", testUser);
}
+ @Test
+ @DisplayName("should rename credential successfully when correct current password provided")
+ void shouldRenameSuccessfullyWhenCorrectCurrentPassword() {
+ // Given
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+ when(userService.checkIfValidOldPassword(testUser, "currentPass")).thenReturn(true);
+ WebAuthnManagementAPI.RenameCredentialRequest request =
+ new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop", "currentPass");
+
+ // When
+ ResponseEntity response = api.renameCredential("cred-1", request, userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(credentialManagementService).renameCredential("cred-1", "Work Laptop", testUser);
+ // Successful re-authentication resets the failed-attempt counter, matching login semantics.
+ verify(loginAttemptService).loginSucceeded(testUser.getEmail());
+ verify(loginAttemptService, never()).loginFailed(testUser.getEmail());
+ }
+
+ @Test
+ @DisplayName("should reject rename and report failed attempt when account is locked")
+ void shouldRejectRenameWhenAccountLocked() {
+ // Given
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+ when(loginAttemptService.isLocked(testUser.getEmail())).thenReturn(true);
+ WebAuthnManagementAPI.RenameCredentialRequest request =
+ new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop", "currentPass");
+
+ // When & Then - locked accounts are rejected before the password is even checked.
+ assertThatThrownBy(() -> api.renameCredential("cred-1", request, userDetails)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("locked");
+ verify(credentialManagementService, never()).renameCredential(any(), any(), any());
+ verify(userService, never()).checkIfValidOldPassword(any(), any());
+ }
+
+ @Test
+ @DisplayName("should reject rename when account has password and current password missing")
+ void shouldRejectRenameWhenCurrentPasswordMissing() {
+ // Given
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+ WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop", null);
+
+ // When & Then
+ assertThatThrownBy(() -> api.renameCredential("cred-1", request, userDetails)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Current password is required");
+ verify(credentialManagementService, never()).renameCredential(any(), any(), any());
+ // A missing field is a client error, not a password guess, so it must not count toward lockout.
+ verify(loginAttemptService, never()).loginFailed(any());
+ }
+
+ @Test
+ @DisplayName("should reject rename when account has password and current password incorrect")
+ void shouldRejectRenameWhenCurrentPasswordIncorrect() {
+ // Given
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+ when(userService.checkIfValidOldPassword(testUser, "wrongPass")).thenReturn(false);
+ WebAuthnManagementAPI.RenameCredentialRequest request =
+ new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop", "wrongPass");
+
+ // When & Then
+ assertThatThrownBy(() -> api.renameCredential("cred-1", request, userDetails)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Current password is incorrect");
+ verify(credentialManagementService, never()).renameCredential(any(), any(), any());
+ // A wrong password is reported to the lockout service so repeated guesses eventually lock the account.
+ verify(loginAttemptService).loginFailed(testUser.getEmail());
+ }
+
@Test
@DisplayName("should throw when rename fails")
void shouldThrowOnFailure() {
// Given
- WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name");
+ testUser.setPassword(null);
+ when(userService.hasPassword(testUser)).thenReturn(false);
+ WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name", null);
doThrow(new WebAuthnException("Credential not found or access denied")).when(credentialManagementService)
.renameCredential(eq("cred-999"), eq("New Name"), any(User.class));
@@ -192,7 +272,7 @@ void shouldThrowOnFailure() {
@DisplayName("should throw not found when user not found")
void shouldThrowNotFoundWhenUserNotFound() {
// Given
- WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name");
+ WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name", null);
when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null);
// When
@@ -207,10 +287,14 @@ void shouldThrowNotFoundWhenUserNotFound() {
class DeleteCredentialTests {
@Test
- @DisplayName("should delete credential successfully")
- void shouldDeleteSuccessfully() {
+ @DisplayName("should delete credential successfully when account is passwordless")
+ void shouldDeleteSuccessfullyWhenAccountPasswordless() {
+ // Given
+ testUser.setPassword(null);
+ when(userService.hasPassword(testUser)).thenReturn(false);
+
// When
- ResponseEntity response = api.deleteCredential("cred-1", userDetails);
+ ResponseEntity response = api.deleteCredential("cred-1", null, userDetails);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -218,15 +302,62 @@ void shouldDeleteSuccessfully() {
verify(credentialManagementService).deleteCredential("cred-1", testUser);
}
+ @Test
+ @DisplayName("should delete credential successfully when correct current password provided")
+ void shouldDeleteSuccessfullyWhenCorrectCurrentPassword() {
+ // Given
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+ when(userService.checkIfValidOldPassword(testUser, "currentPass")).thenReturn(true);
+ WebAuthnManagementAPI.CurrentPasswordRequest request = new WebAuthnManagementAPI.CurrentPasswordRequest("currentPass");
+
+ // When
+ ResponseEntity response = api.deleteCredential("cred-1", request, userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(credentialManagementService).deleteCredential("cred-1", testUser);
+ }
+
+ @Test
+ @DisplayName("should reject delete when account has password and current password missing")
+ void shouldRejectDeleteWhenCurrentPasswordMissing() {
+ // Given
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+
+ // When & Then
+ assertThatThrownBy(() -> api.deleteCredential("cred-1", null, userDetails)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Current password is required");
+ verify(credentialManagementService, never()).deleteCredential(any(), any());
+ }
+
+ @Test
+ @DisplayName("should reject delete when account has password and current password incorrect")
+ void shouldRejectDeleteWhenCurrentPasswordIncorrect() {
+ // Given
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+ when(userService.checkIfValidOldPassword(testUser, "wrongPass")).thenReturn(false);
+ WebAuthnManagementAPI.CurrentPasswordRequest request = new WebAuthnManagementAPI.CurrentPasswordRequest("wrongPass");
+
+ // When & Then
+ assertThatThrownBy(() -> api.deleteCredential("cred-1", request, userDetails)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Current password is incorrect");
+ verify(credentialManagementService, never()).deleteCredential(any(), any());
+ }
+
@Test
@DisplayName("should throw when delete fails")
void shouldThrowOnFailure() {
// Given
+ testUser.setPassword(null);
+ when(userService.hasPassword(testUser)).thenReturn(false);
doThrow(new WebAuthnException("Cannot delete last passkey")).when(credentialManagementService).deleteCredential(eq("cred-1"),
any(User.class));
// When
- assertThatThrownBy(() -> api.deleteCredential("cred-1", userDetails)).isInstanceOf(WebAuthnException.class)
+ assertThatThrownBy(() -> api.deleteCredential("cred-1", null, userDetails)).isInstanceOf(WebAuthnException.class)
.hasMessageContaining("Cannot delete last passkey");
}
@@ -237,7 +368,7 @@ void shouldThrowNotFoundWhenUserNotFound() {
when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null);
// When
- assertThatThrownBy(() -> api.deleteCredential("cred-1", userDetails))
+ assertThatThrownBy(() -> api.deleteCredential("cred-1", null, userDetails))
.isInstanceOf(WebAuthnUserNotFoundException.class).hasMessageContaining("User not found");
verify(credentialManagementService, never()).deleteCredential(any(), any());
}
@@ -256,16 +387,18 @@ private HttpServletRequest createMockRequest() {
}
@Test
- @DisplayName("should remove password when user has passkeys")
- void shouldRemovePasswordWhenUserHasPasskeys() {
+ @DisplayName("should remove password when user has passkeys and correct current password")
+ void shouldRemovePasswordWhenUserHasPasskeysAndCorrectCurrentPassword() {
// Given
HttpServletRequest mockRequest = createMockRequest();
testUser.setPassword("encodedPassword");
when(userService.hasPassword(testUser)).thenReturn(true);
+ when(userService.checkIfValidOldPassword(testUser, "currentPass")).thenReturn(true);
when(credentialManagementService.hasCredentials(testUser)).thenReturn(true);
+ WebAuthnManagementAPI.CurrentPasswordRequest request = new WebAuthnManagementAPI.CurrentPasswordRequest("currentPass");
// When
- ResponseEntity response = api.removePassword(userDetails, mockRequest);
+ ResponseEntity response = api.removePassword(request, userDetails, mockRequest);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -274,6 +407,38 @@ void shouldRemovePasswordWhenUserHasPasskeys() {
verify(eventPublisher).publishEvent(any(AuditEvent.class));
}
+ @Test
+ @DisplayName("should reject removal when current password missing")
+ void shouldRejectRemovalWhenCurrentPasswordMissing() {
+ // Given
+ HttpServletRequest mockRequest = mock(HttpServletRequest.class);
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+
+ // When & Then
+ assertThatThrownBy(() -> api.removePassword(null, userDetails, mockRequest))
+ .isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Current password is required");
+ verify(userService, never()).removeUserPassword(any());
+ }
+
+ @Test
+ @DisplayName("should reject removal when current password incorrect")
+ void shouldRejectRemovalWhenCurrentPasswordIncorrect() {
+ // Given
+ HttpServletRequest mockRequest = mock(HttpServletRequest.class);
+ testUser.setPassword("encodedPassword");
+ when(userService.hasPassword(testUser)).thenReturn(true);
+ when(userService.checkIfValidOldPassword(testUser, "wrongPass")).thenReturn(false);
+ WebAuthnManagementAPI.CurrentPasswordRequest request = new WebAuthnManagementAPI.CurrentPasswordRequest("wrongPass");
+
+ // When & Then
+ assertThatThrownBy(() -> api.removePassword(request, userDetails, mockRequest))
+ .isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Current password is incorrect");
+ verify(userService, never()).removeUserPassword(any());
+ }
+
@Test
@DisplayName("should reject removal when no passkeys")
void shouldRejectRemovalWhenNoPasskeys() {
@@ -281,10 +446,12 @@ void shouldRejectRemovalWhenNoPasskeys() {
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
testUser.setPassword("encodedPassword");
when(userService.hasPassword(testUser)).thenReturn(true);
+ when(userService.checkIfValidOldPassword(testUser, "currentPass")).thenReturn(true);
when(credentialManagementService.hasCredentials(testUser)).thenReturn(false);
+ WebAuthnManagementAPI.CurrentPasswordRequest request = new WebAuthnManagementAPI.CurrentPasswordRequest("currentPass");
// When & Then
- assertThatThrownBy(() -> api.removePassword(userDetails, mockRequest))
+ assertThatThrownBy(() -> api.removePassword(request, userDetails, mockRequest))
.isInstanceOf(WebAuthnException.class)
.hasMessageContaining("register a passkey first");
verify(userService, never()).removeUserPassword(any());
@@ -297,9 +464,10 @@ void shouldRejectRemovalWhenAlreadyPasswordless() {
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
testUser.setPassword(null);
when(userService.hasPassword(testUser)).thenReturn(false);
+ WebAuthnManagementAPI.CurrentPasswordRequest request = new WebAuthnManagementAPI.CurrentPasswordRequest("anything");
// When & Then
- assertThatThrownBy(() -> api.removePassword(userDetails, mockRequest))
+ assertThatThrownBy(() -> api.removePassword(request, userDetails, mockRequest))
.isInstanceOf(WebAuthnException.class)
.hasMessageContaining("does not have a password");
verify(userService, never()).removeUserPassword(any());
diff --git a/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java b/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java
index 14478290..ba7f19a7 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java
@@ -10,6 +10,7 @@
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import com.digitalsanctuary.spring.user.gdpr.GdprDeletionService;
+import com.digitalsanctuary.spring.user.roles.RolePrivilegeSetupService;
import com.digitalsanctuary.spring.user.service.UserEmailService;
import com.digitalsanctuary.spring.user.service.UserService;
@@ -35,9 +36,9 @@
*
*
*
- * Note: this rule is intentionally scoped to the specific methods invoked via {@code self}. Other package-private
- * {@code @Transactional} helpers (e.g. {@code RolePrivilegeSetupService.getOrCreateRole}) are called via {@code this}
- * from within an already-transactional method and run in the caller's transaction, so they do not need to be proxied.
+ * Note: this rule is intentionally scoped to the specific methods invoked via {@code self}. The
+ * {@code RolePrivilegeSetupService.insert*}/{@code updateRolePrivileges} methods use the same {@code @Lazy self}
+ * pattern (to obtain a {@code REQUIRES_NEW} boundary per insert during startup), so they are included below.
*
*/
@DisplayName("Self-proxied (@Lazy self) transactional methods must be proxyable (public/protected)")
@@ -52,7 +53,10 @@ static List selfProxiedMethods() {
Arguments.of(UserService.class, "persistChangedPassword"),
Arguments.of(UserService.class, "persistInitialPassword"),
Arguments.of(GdprDeletionService.class, "executeUserDeletion"),
- Arguments.of(UserEmailService.class, "createPasswordResetTokenForUser"));
+ Arguments.of(UserEmailService.class, "createPasswordResetTokenForUser"),
+ Arguments.of(RolePrivilegeSetupService.class, "insertPrivilege"),
+ Arguments.of(RolePrivilegeSetupService.class, "insertRole"),
+ Arguments.of(RolePrivilegeSetupService.class, "updateRolePrivileges"));
}
@ParameterizedTest(name = "{0}#{1} is public or protected")
diff --git a/src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java
index 3735b696..da8f25b0 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java
@@ -37,10 +37,11 @@ void eventCreation_storesUserAndConsentRecord() {
// When
ConsentChangedEvent event = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.GRANTED);
// Then
- assertThat(event.getUser()).isEqualTo(testUser);
+ assertThat(event.getUserId()).isEqualTo(testUser.getId());
+ assertThat(event.getUserEmail()).isEqualTo(testUser.getEmail());
assertThat(event.getConsentRecord()).isEqualTo(record);
assertThat(event.getSource()).isEqualTo(eventSource);
}
@@ -55,7 +56,7 @@ void getUserId_returnsUserId() {
// When
ConsentChangedEvent event = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.GRANTED);
// Then
assertThat(event.getUserId()).isEqualTo(1L);
@@ -71,7 +72,7 @@ void getConsentType_returnsCorrectType() {
// When
ConsentChangedEvent event = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.WITHDRAWN);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.WITHDRAWN);
// Then
assertThat(event.getConsentType()).isEqualTo(ConsentType.ANALYTICS);
@@ -87,7 +88,7 @@ void isGranted_returnsTrue_forGrantedChangeType() {
// When
ConsentChangedEvent event = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.GRANTED);
// Then
assertThat(event.isGranted()).isTrue();
@@ -104,7 +105,7 @@ void isWithdrawn_returnsTrue_forWithdrawnChangeType() {
// When
ConsentChangedEvent event = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.WITHDRAWN);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.WITHDRAWN);
// Then
assertThat(event.isWithdrawn()).isTrue();
@@ -121,9 +122,9 @@ void getChangeType_returnsCorrectType() {
// When
ConsentChangedEvent grantedEvent = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.GRANTED);
ConsentChangedEvent withdrawnEvent = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.WITHDRAWN);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.WITHDRAWN);
// Then
assertThat(grantedEvent.getChangeType()).isEqualTo(ConsentChangedEvent.ChangeType.GRANTED);
@@ -141,7 +142,7 @@ void event_timestampIsSet() {
// When
ConsentChangedEvent event = new ConsentChangedEvent(
- eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED);
+ eventSource, testUser.getId(), testUser.getEmail(), record, ConsentChangedEvent.ChangeType.GRANTED);
// Then
long afterCreation = System.currentTimeMillis();
diff --git a/src/test/java/com/digitalsanctuary/spring/user/event/OnRegistrationCompleteEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/event/OnRegistrationCompleteEventTest.java
index 53854f7d..82fbb5a0 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/event/OnRegistrationCompleteEventTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/event/OnRegistrationCompleteEventTest.java
@@ -2,9 +2,6 @@
import static org.assertj.core.api.Assertions.assertThat;
-import com.digitalsanctuary.spring.user.persistence.model.User;
-import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
-
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -14,19 +11,15 @@
@DisplayName("OnRegistrationCompleteEvent Tests")
class OnRegistrationCompleteEventTest {
- private User testUser;
+ private Long userId;
+ private String userEmail;
private String appUrl;
private Locale locale;
@BeforeEach
void setUp() {
- testUser = UserTestDataBuilder.aUser()
- .withId(1L)
- .withEmail("test@example.com")
- .withFirstName("Test")
- .withLastName("User")
- .enabled()
- .build();
+ userId = 1L;
+ userEmail = "test@example.com";
appUrl = "https://example.com";
locale = Locale.ENGLISH;
}
@@ -36,29 +29,35 @@ void setUp() {
void eventCreation_withBuilder() {
// When
OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(testUser)
+ .userId(userId)
+ .userEmail(userEmail)
+ .userEnabled(true)
.locale(locale)
.appUrl(appUrl)
.build();
// Then
- assertThat(event.getUser()).isEqualTo(testUser);
+ assertThat(event.getUserId()).isEqualTo(userId);
+ assertThat(event.getUserEmail()).isEqualTo(userEmail);
+ assertThat(event.isUserEnabled()).isTrue();
assertThat(event.getLocale()).isEqualTo(locale);
assertThat(event.getAppUrl()).isEqualTo(appUrl);
- assertThat(event.getSource()).isEqualTo(testUser);
+ assertThat(event.getSource()).isEqualTo(userId);
}
@Test
@DisplayName("Event creation with constructor")
void eventCreation_withConstructor() {
// When
- OnRegistrationCompleteEvent event = new OnRegistrationCompleteEvent(testUser, locale, appUrl);
+ OnRegistrationCompleteEvent event = new OnRegistrationCompleteEvent(userId, userEmail, false, locale, appUrl);
// Then
- assertThat(event.getUser()).isEqualTo(testUser);
+ assertThat(event.getUserId()).isEqualTo(userId);
+ assertThat(event.getUserEmail()).isEqualTo(userEmail);
+ assertThat(event.isUserEnabled()).isFalse();
assertThat(event.getLocale()).isEqualTo(locale);
assertThat(event.getAppUrl()).isEqualTo(appUrl);
- assertThat(event.getSource()).isEqualTo(testUser);
+ assertThat(event.getSource()).isEqualTo(userId);
}
@Test
@@ -66,14 +65,16 @@ void eventCreation_withConstructor() {
void event_withNullAppUrl() {
// When
OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(testUser)
+ .userId(userId)
+ .userEmail(userEmail)
+ .userEnabled(false)
.locale(locale)
.appUrl(null)
.build();
// Then
assertThat(event.getAppUrl()).isNull();
- assertThat(event.getUser()).isEqualTo(testUser);
+ assertThat(event.getUserId()).isEqualTo(userId);
assertThat(event.getLocale()).isEqualTo(locale);
}
@@ -85,7 +86,8 @@ void event_withDifferentLocales() {
// When
OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(testUser)
+ .userId(userId)
+ .userEmail(userEmail)
.locale(frenchLocale)
.appUrl(appUrl)
.build();
@@ -98,30 +100,28 @@ void event_withDifferentLocales() {
@Test
@DisplayName("Event equality includes all fields")
void eventEquality_includesAllFields() {
- // Given
- User anotherUser = UserTestDataBuilder.aUser()
- .withId(2L)
- .withEmail("another@example.com")
- .build();
-
// When
OnRegistrationCompleteEvent event1 = OnRegistrationCompleteEvent.builder()
- .user(testUser)
+ .userId(userId)
+ .userEmail(userEmail)
.locale(locale)
.appUrl(appUrl)
.build();
OnRegistrationCompleteEvent event2 = OnRegistrationCompleteEvent.builder()
- .user(testUser)
+ .userId(userId)
+ .userEmail(userEmail)
.locale(locale)
.appUrl(appUrl)
.build();
OnRegistrationCompleteEvent event3 = OnRegistrationCompleteEvent.builder()
- .user(testUser)
+ .userId(userId)
+ .userEmail(userEmail)
.locale(Locale.FRENCH)
.appUrl("https://different.com")
.build();
OnRegistrationCompleteEvent event4 = OnRegistrationCompleteEvent.builder()
- .user(anotherUser)
+ .userId(2L)
+ .userEmail("another@example.com")
.locale(locale)
.appUrl(appUrl)
.build();
@@ -137,7 +137,8 @@ void eventEquality_includesAllFields() {
void event_toStringIncludesRelevantInfo() {
// When
OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(testUser)
+ .userId(userId)
+ .userEmail(userEmail)
.locale(locale)
.appUrl(appUrl)
.build();
@@ -148,4 +149,4 @@ void event_toStringIncludesRelevantInfo() {
assertThat(eventString).contains(appUrl);
assertThat(eventString).contains("en");
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/event/UserPreDeleteEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/event/UserPreDeleteEventTest.java
index 7812536d..eef26935 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/event/UserPreDeleteEventTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/event/UserPreDeleteEventTest.java
@@ -2,9 +2,6 @@
import static org.assertj.core.api.Assertions.assertThat;
-import com.digitalsanctuary.spring.user.persistence.model.User;
-import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
-
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -12,29 +9,26 @@
@DisplayName("UserPreDeleteEvent Tests")
class UserPreDeleteEventTest {
- private User testUser;
+ private Long userId;
+ private String userEmail;
private Object eventSource;
@BeforeEach
void setUp() {
- testUser = UserTestDataBuilder.aUser()
- .withId(1L)
- .withEmail("test@example.com")
- .withFirstName("Test")
- .withLastName("User")
- .enabled()
- .build();
+ userId = 1L;
+ userEmail = "test@example.com";
eventSource = this;
}
@Test
- @DisplayName("Event creation stores user and source")
- void eventCreation_storesUserAndSource() {
+ @DisplayName("Event creation stores user data and source")
+ void eventCreation_storesUserDataAndSource() {
// When
- UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, testUser);
+ UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, userId, userEmail);
// Then
- assertThat(event.getUser()).isEqualTo(testUser);
+ assertThat(event.getUserId()).isEqualTo(userId);
+ assertThat(event.getUserEmail()).isEqualTo(userEmail);
assertThat(event.getSource()).isEqualTo(eventSource);
}
@@ -42,7 +36,7 @@ void eventCreation_storesUserAndSource() {
@DisplayName("getUserId returns user's ID")
void getUserId_returnsUserId() {
// When
- UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, testUser);
+ UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, userId, userEmail);
// Then
assertThat(event.getUserId()).isEqualTo(1L);
@@ -56,50 +50,38 @@ void event_withDifferentSources() {
Object source2 = "Different Source";
// When
- UserPreDeleteEvent event1 = new UserPreDeleteEvent(source1, testUser);
- UserPreDeleteEvent event2 = new UserPreDeleteEvent(source2, testUser);
+ UserPreDeleteEvent event1 = new UserPreDeleteEvent(source1, userId, userEmail);
+ UserPreDeleteEvent event2 = new UserPreDeleteEvent(source2, userId, userEmail);
// Then
assertThat(event1.getSource()).isEqualTo(source1);
assertThat(event2.getSource()).isEqualTo(source2);
- assertThat(event1.getUser()).isEqualTo(event2.getUser());
+ assertThat(event1.getUserId()).isEqualTo(event2.getUserId());
}
@Test
@DisplayName("Event preserves user information")
void event_preservesUserInformation() {
// When
- UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, testUser);
+ UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, userId, userEmail);
// Then
- User eventUser = event.getUser();
- assertThat(eventUser.getEmail()).isEqualTo("test@example.com");
- assertThat(eventUser.getFirstName()).isEqualTo("Test");
- assertThat(eventUser.getLastName()).isEqualTo("User");
- assertThat(eventUser.isEnabled()).isTrue();
+ assertThat(event.getUserId()).isEqualTo(1L);
+ assertThat(event.getUserEmail()).isEqualTo("test@example.com");
}
@Test
@DisplayName("Multiple events for different users")
void multipleEvents_forDifferentUsers() {
- // Given
- User user1 = UserTestDataBuilder.aUser()
- .withId(1L)
- .withEmail("user1@example.com")
- .build();
- User user2 = UserTestDataBuilder.aUser()
- .withId(2L)
- .withEmail("user2@example.com")
- .build();
-
// When
- UserPreDeleteEvent event1 = new UserPreDeleteEvent(eventSource, user1);
- UserPreDeleteEvent event2 = new UserPreDeleteEvent(eventSource, user2);
+ UserPreDeleteEvent event1 = new UserPreDeleteEvent(eventSource, 1L, "user1@example.com");
+ UserPreDeleteEvent event2 = new UserPreDeleteEvent(eventSource, 2L, "user2@example.com");
// Then
- assertThat(event1.getUser()).isNotEqualTo(event2.getUser());
assertThat(event1.getUserId()).isEqualTo(1L);
assertThat(event2.getUserId()).isEqualTo(2L);
+ assertThat(event1.getUserEmail()).isEqualTo("user1@example.com");
+ assertThat(event2.getUserEmail()).isEqualTo("user2@example.com");
}
@Test
@@ -109,11 +91,11 @@ void event_timestampIsSet() {
long beforeCreation = System.currentTimeMillis();
// When
- UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, testUser);
+ UserPreDeleteEvent event = new UserPreDeleteEvent(eventSource, userId, userEmail);
// Then
long afterCreation = System.currentTimeMillis();
assertThat(event.getTimestamp()).isGreaterThanOrEqualTo(beforeCreation);
assertThat(event.getTimestamp()).isLessThanOrEqualTo(afterCreation);
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/exceptions/GlobalValidationExceptionHandlerScopeTest.java b/src/test/java/com/digitalsanctuary/spring/user/exceptions/GlobalValidationExceptionHandlerScopeTest.java
new file mode 100644
index 00000000..74db97c0
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/exceptions/GlobalValidationExceptionHandlerScopeTest.java
@@ -0,0 +1,56 @@
+package com.digitalsanctuary.spring.user.exceptions;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+
+import com.digitalsanctuary.spring.user.api.GdprAPI;
+import com.digitalsanctuary.spring.user.api.MfaAPI;
+import com.digitalsanctuary.spring.user.api.UserAPI;
+import com.digitalsanctuary.spring.user.api.WebAuthnManagementAPI;
+import com.digitalsanctuary.spring.user.controller.UserActionController;
+import com.digitalsanctuary.spring.user.controller.UserPageController;
+
+/**
+ * Verifies that {@link GlobalValidationExceptionHandler} is scoped to the library's own controllers
+ * via {@code @ControllerAdvice(assignableTypes = ...)} rather than applying application-wide.
+ */
+@DisplayName("GlobalValidationExceptionHandler scope")
+class GlobalValidationExceptionHandlerScopeTest {
+
+ @Test
+ @DisplayName("Should be scoped to the library's own controllers via assignableTypes")
+ void shouldScopeAdviceToLibraryControllers() {
+ ControllerAdvice annotation = GlobalValidationExceptionHandler.class.getAnnotation(ControllerAdvice.class);
+
+ assertThat(annotation).as("@ControllerAdvice must be present").isNotNull();
+ assertThat(annotation.assignableTypes())
+ .as("Advice must be scoped to the library's own controllers")
+ .containsExactlyInAnyOrder(UserAPI.class, GdprAPI.class, MfaAPI.class, UserActionController.class, UserPageController.class);
+ }
+
+ @Test
+ @DisplayName("Should NOT target WebAuthnManagementAPI (it has its own dedicated advice)")
+ void shouldNotTargetWebAuthnManagementApi() {
+ ControllerAdvice annotation = GlobalValidationExceptionHandler.class.getAnnotation(ControllerAdvice.class);
+
+ assertThat(annotation.assignableTypes())
+ .as("WebAuthnManagementAPI is handled by its dedicated advice and must be excluded")
+ .doesNotContain(WebAuthnManagementAPI.class);
+ }
+
+ @Test
+ @DisplayName("Should NOT apply application-wide (no basePackages, annotations, or unrestricted scope)")
+ void shouldNotApplyApplicationWide() {
+ ControllerAdvice annotation = GlobalValidationExceptionHandler.class.getAnnotation(ControllerAdvice.class);
+
+ assertThat(annotation.assignableTypes())
+ .as("assignableTypes must be set so the advice is not global")
+ .isNotEmpty();
+ assertThat(annotation.basePackages()).as("No basePackages scope").isEmpty();
+ assertThat(annotation.basePackageClasses()).as("No basePackageClasses scope").isEmpty();
+ assertThat(annotation.annotations()).as("No annotation-based scope").isEmpty();
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java
index f7eeb9b5..d6708b89 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java
@@ -164,7 +164,8 @@ void publishesConsentChangedEvent() {
verify(eventPublisher).publishEvent(eventCaptor.capture());
ConsentChangedEvent capturedEvent = eventCaptor.getValue();
- assertThat(capturedEvent.getUser()).isEqualTo(testUser);
+ assertThat(capturedEvent.getUserId()).isEqualTo(testUser.getId());
+ assertThat(capturedEvent.getUserEmail()).isEqualTo(testUser.getEmail());
assertThat(capturedEvent.getConsentType()).isEqualTo(ConsentType.MARKETING_EMAILS);
assertThat(capturedEvent.isGranted()).isTrue();
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java
index 344c6a47..c4d6599d 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java
@@ -107,7 +107,8 @@ void publishesUserPreDeleteEvent_beforeDeletion() {
// Then
ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserPreDeleteEvent.class);
verify(eventPublisher).publishEvent(eventCaptor.capture());
- assertThat(eventCaptor.getValue().getUser()).isEqualTo(testUser);
+ assertThat(eventCaptor.getValue().getUserId()).isEqualTo(testUser.getId());
+ assertThat(eventCaptor.getValue().getUserEmail()).isEqualTo(testUser.getEmail());
}
@Test
diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java
index 03cf8162..1a969283 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java
@@ -28,6 +28,7 @@
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.persistence.model.VerificationToken;
import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository;
+import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository;
import com.digitalsanctuary.spring.user.test.annotations.ServiceTest;
import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
@@ -48,6 +49,9 @@ class GdprExportServiceTest {
@Mock
private PasswordResetTokenRepository passwordResetTokenRepository;
+ @Mock
+ private UserRepository userRepository;
+
@Mock
private ApplicationEventPublisher eventPublisher;
@@ -102,6 +106,27 @@ void exportsBasicUserData() {
assertThat(export.getUserData().isEnabled()).isTrue();
}
+ @Test
+ @DisplayName("exports role names by reloading the user through the entity-graph finder")
+ void exportsRoleNamesViaEntityGraphFinder() {
+ // Given - roles are LAZY, so the export service reloads the user with roles initialized.
+ User userWithRoles = UserTestDataBuilder.aVerifiedUser()
+ .withId(1L)
+ .withEmail("test@example.com")
+ .withRole("ROLE_USER")
+ .build();
+ when(userRepository.findWithRolesByEmail("test@example.com")).thenReturn(userWithRoles);
+ when(gdprConfig.isConsentTracking()).thenReturn(true);
+ when(auditLogQueryService.findByUser(testUser)).thenReturn(new ArrayList<>());
+ when(auditLogQueryService.findByUserAndAction(any(), any())).thenReturn(new ArrayList<>());
+
+ // When
+ GdprExportDTO export = gdprExportService.exportUserData(testUser);
+
+ // Then
+ assertThat(export.getUserData().getRoles()).containsExactly("ROLE_USER");
+ }
+
@Test
@DisplayName("includes export metadata")
void includesExportMetadata() {
diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java
index dbef822e..9cf620bc 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java
@@ -3,9 +3,7 @@
import static org.mockito.Mockito.*;
import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent;
-import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.service.UserEmailService;
-import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -29,25 +27,27 @@ class RegistrationListenerTest {
@InjectMocks
private RegistrationListener registrationListener;
- private User testUser;
+ private static final Long USER_ID = 1L;
+ private static final String USER_EMAIL = "test@example.com";
private String appUrl;
private Locale locale;
@BeforeEach
void setUp() {
- // Default the shared fixture to a DISABLED (not-yet-verified) user so the "send verification email"
- // tests exercise the email-sending path. The skip-for-enabled-users behavior is covered separately.
- testUser = UserTestDataBuilder.aUser()
- .withId(1L)
- .withEmail("test@example.com")
- .withFirstName("Test")
- .withLastName("User")
- .disabled()
- .build();
appUrl = "https://example.com";
locale = Locale.ENGLISH;
}
+ private OnRegistrationCompleteEvent eventFor(Long userId, String userEmail, boolean enabled, String url) {
+ return OnRegistrationCompleteEvent.builder()
+ .userId(userId)
+ .userEmail(userEmail)
+ .userEnabled(enabled)
+ .locale(locale)
+ .appUrl(url)
+ .build();
+ }
+
@Nested
@DisplayName("Registration Event Handling Tests")
class RegistrationEventHandlingTests {
@@ -55,24 +55,16 @@ class RegistrationEventHandlingTests {
@Test
@DisplayName("onApplicationEvent - sends verification email when enabled and user is not yet verified")
void onApplicationEvent_sendsVerificationEmailWhenEnabled() {
- // Given - a DISABLED (not yet verified) user, as produced by the form-registration path when
+ // Given - a not-yet-verified (disabled) user, as produced by the form-registration path when
// email verification is required.
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true);
- User unverifiedUser = UserTestDataBuilder.aUser()
- .withEmail("unverified@example.com")
- .disabled()
- .build();
- OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(unverifiedUser)
- .locale(locale)
- .appUrl(appUrl)
- .build();
+ OnRegistrationCompleteEvent event = eventFor(2L, "unverified@example.com", false, appUrl);
// When
registrationListener.onApplicationEvent(event);
- // Then
- verify(userEmailService).sendRegistrationVerificationEmail(unverifiedUser, appUrl);
+ // Then - the listener passes the user id; the email service reloads the entity in its own transaction.
+ verify(userEmailService).sendRegistrationVerificationEmail(2L, appUrl);
}
@Test
@@ -81,21 +73,13 @@ void onApplicationEvent_skipsVerificationEmailForEnabledUser() {
// Given - sending is enabled, but the user is already enabled (e.g. a first-time OAuth2/OIDC
// registration where the provider has already verified the email). They must NOT receive an email.
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true);
- User enabledUser = UserTestDataBuilder.aUser()
- .withEmail("oauth@example.com")
- .enabled()
- .build();
- OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(enabledUser)
- .locale(locale)
- .appUrl(appUrl)
- .build();
+ OnRegistrationCompleteEvent event = eventFor(3L, "oauth@example.com", true, appUrl);
// When
registrationListener.onApplicationEvent(event);
// Then
- verify(userEmailService, never()).sendRegistrationVerificationEmail(any(), any());
+ verify(userEmailService, never()).sendRegistrationVerificationEmail(anyLong(), any());
}
@Test
@@ -103,35 +87,27 @@ void onApplicationEvent_skipsVerificationEmailForEnabledUser() {
void onApplicationEvent_doesNotSendEmailWhenDisabled() {
// Given
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", false);
- OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(testUser)
- .locale(locale)
- .appUrl(appUrl)
- .build();
+ OnRegistrationCompleteEvent event = eventFor(USER_ID, USER_EMAIL, false, appUrl);
// When
registrationListener.onApplicationEvent(event);
// Then
- verify(userEmailService, never()).sendRegistrationVerificationEmail(any(), any());
+ verify(userEmailService, never()).sendRegistrationVerificationEmail(anyLong(), any());
}
@Test
- @DisplayName("onApplicationEvent - handles null app URL")
+ @DisplayName("onApplicationEvent - passes null app URL through to email service")
void onApplicationEvent_handlesNullAppUrl() {
// Given
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true);
- OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(testUser)
- .locale(locale)
- .appUrl(null)
- .build();
+ OnRegistrationCompleteEvent event = eventFor(USER_ID, USER_EMAIL, false, null);
// When
registrationListener.onApplicationEvent(event);
// Then
- verify(userEmailService).sendRegistrationVerificationEmail(testUser, null);
+ verify(userEmailService).sendRegistrationVerificationEmail(USER_ID, null);
}
@Test
@@ -139,10 +115,11 @@ void onApplicationEvent_handlesNullAppUrl() {
void onApplicationEvent_handlesDifferentLocales() {
// Given
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true);
- Locale frenchLocale = Locale.FRENCH;
OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder()
- .user(testUser)
- .locale(frenchLocale)
+ .userId(USER_ID)
+ .userEmail(USER_EMAIL)
+ .userEnabled(false)
+ .locale(Locale.FRENCH)
.appUrl(appUrl)
.build();
@@ -150,7 +127,7 @@ void onApplicationEvent_handlesDifferentLocales() {
registrationListener.onApplicationEvent(event);
// Then
- verify(userEmailService).sendRegistrationVerificationEmail(testUser, appUrl);
+ verify(userEmailService).sendRegistrationVerificationEmail(USER_ID, appUrl);
}
}
@@ -163,65 +140,35 @@ class MultipleUserRegistrationTests {
void multipleRegistrationEvents_handledIndependently() {
// Given
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true);
- User user1 = UserTestDataBuilder.aUser()
- .withEmail("user1@example.com")
- .build();
- User user2 = UserTestDataBuilder.aUser()
- .withEmail("user2@example.com")
- .build();
-
- OnRegistrationCompleteEvent event1 = OnRegistrationCompleteEvent.builder()
- .user(user1)
- .locale(locale)
- .appUrl(appUrl)
- .build();
- OnRegistrationCompleteEvent event2 = OnRegistrationCompleteEvent.builder()
- .user(user2)
- .locale(locale)
- .appUrl(appUrl)
- .build();
+ OnRegistrationCompleteEvent event1 = eventFor(11L, "user1@example.com", false, appUrl);
+ OnRegistrationCompleteEvent event2 = eventFor(12L, "user2@example.com", false, appUrl);
// When
registrationListener.onApplicationEvent(event1);
registrationListener.onApplicationEvent(event2);
// Then
- verify(userEmailService).sendRegistrationVerificationEmail(user1, appUrl);
- verify(userEmailService).sendRegistrationVerificationEmail(user2, appUrl);
+ verify(userEmailService).sendRegistrationVerificationEmail(11L, appUrl);
+ verify(userEmailService).sendRegistrationVerificationEmail(12L, appUrl);
}
@Test
@DisplayName("Configuration change affects subsequent events")
void configurationChange_affectsSubsequentEvents() {
// Given
- User user1 = UserTestDataBuilder.aUser()
- .withEmail("user1@example.com")
- .build();
- User user2 = UserTestDataBuilder.aUser()
- .withEmail("user2@example.com")
- .build();
-
- OnRegistrationCompleteEvent event1 = OnRegistrationCompleteEvent.builder()
- .user(user1)
- .locale(locale)
- .appUrl(appUrl)
- .build();
- OnRegistrationCompleteEvent event2 = OnRegistrationCompleteEvent.builder()
- .user(user2)
- .locale(locale)
- .appUrl(appUrl)
- .build();
+ OnRegistrationCompleteEvent event1 = eventFor(11L, "user1@example.com", false, appUrl);
+ OnRegistrationCompleteEvent event2 = eventFor(12L, "user2@example.com", false, appUrl);
// When
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true);
registrationListener.onApplicationEvent(event1);
-
+
ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", false);
registrationListener.onApplicationEvent(event2);
// Then
- verify(userEmailService).sendRegistrationVerificationEmail(user1, appUrl);
- verify(userEmailService, never()).sendRegistrationVerificationEmail(user2, appUrl);
+ verify(userEmailService).sendRegistrationVerificationEmail(11L, appUrl);
+ verify(userEmailService, never()).sendRegistrationVerificationEmail(12L, appUrl);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java
index 039999c1..a81afb2f 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java
@@ -53,7 +53,7 @@ void shouldDeleteWebAuthnDataForUser() {
when(userEntityRepository.findByUserId(testUser.getId())).thenReturn(Optional.of(userEntity));
- UserPreDeleteEvent event = new UserPreDeleteEvent(this, testUser);
+ UserPreDeleteEvent event = new UserPreDeleteEvent(this, testUser.getId(), testUser.getEmail());
// When
listener.onUserPreDelete(event);
@@ -69,7 +69,7 @@ void shouldDoNothingWhenNoWebAuthnData() {
// Given
when(userEntityRepository.findByUserId(testUser.getId())).thenReturn(Optional.empty());
- UserPreDeleteEvent event = new UserPreDeleteEvent(this, testUser);
+ UserPreDeleteEvent event = new UserPreDeleteEvent(this, testUser.getId(), testUser.getEmail());
// When
listener.onUserPreDelete(event);
diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/EntityEqualityTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/EntityEqualityTest.java
new file mode 100644
index 00000000..a5ce86e4
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/EntityEqualityTest.java
@@ -0,0 +1,151 @@
+package com.digitalsanctuary.spring.user.persistence.model;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests verifying the identity-based ({@code id}-only) {@code equals}/{@code hashCode} contract and the
+ * secret-safe {@code toString} on the JPA entities migrated away from Lombok {@code @Data}.
+ *
+ * Under id-only equality two managed instances are equal only when they share a non-null id; instances with
+ * different ids (or a null id) are not equal. {@code toString} must never include passwords or token secrets, as
+ * it is commonly emitted to logs.
+ */
+class EntityEqualityTest {
+
+ @Test
+ void shouldBeEqualAndShareHashCodeWhenUsersHaveSameId() {
+ // Given two distinct User instances with the same id but otherwise different field values
+ User first = new User();
+ first.setId(1L);
+ first.setEmail("first@test.com");
+
+ User second = new User();
+ second.setId(1L);
+ second.setEmail("second@test.com");
+
+ // Then they are equal and share a hash code, regardless of differing non-id fields
+ assertThat(first).isEqualTo(second);
+ assertThat(first).hasSameHashCodeAs(second);
+ }
+
+ @Test
+ void shouldNotBeEqualWhenUsersHaveDifferentIds() {
+ // Given two User instances with different ids
+ User first = new User();
+ first.setId(1L);
+
+ User second = new User();
+ second.setId(2L);
+
+ // Then they are not equal
+ assertThat(first).isNotEqualTo(second);
+ }
+
+ @Test
+ void shouldNotBeEqualWhenOneUserHasNullId() {
+ // Given two User instances, one with a null id (transient/unsaved)
+ User saved = new User();
+ saved.setId(1L);
+
+ User transientUser = new User();
+
+ // Then they are not equal under id-only equality
+ assertThat(saved).isNotEqualTo(transientUser);
+ }
+
+ @Test
+ void shouldNotIncludePasswordWhenUserToStringCalled() {
+ // Given a user carrying a (fake) password hash
+ User user = new User();
+ user.setId(1L);
+ user.setEmail("user@test.com");
+ user.setPassword("SUPERSECRET_HASH");
+
+ // Then the rendered string excludes the password hash
+ assertThat(user.toString()).doesNotContain("SUPERSECRET_HASH");
+ }
+
+ @Test
+ void shouldBeEqualAndShareHashCodeWhenVerificationTokensHaveSameId() {
+ // Given two distinct VerificationToken instances with the same id but different secret values
+ VerificationToken first = new VerificationToken("token-aaa");
+ first.setId(10L);
+
+ VerificationToken second = new VerificationToken("token-bbb");
+ second.setId(10L);
+
+ // Then they are equal and share a hash code
+ assertThat(first).isEqualTo(second);
+ assertThat(first).hasSameHashCodeAs(second);
+ }
+
+ @Test
+ void shouldNotBeEqualWhenVerificationTokensHaveDifferentIds() {
+ // Given two VerificationToken instances with different ids
+ VerificationToken first = new VerificationToken("token");
+ first.setId(10L);
+
+ VerificationToken second = new VerificationToken("token");
+ second.setId(20L);
+
+ // Then they are not equal even with the same token value
+ assertThat(first).isNotEqualTo(second);
+ }
+
+ @Test
+ void shouldNotBeEqualWhenOneVerificationTokenHasNullId() {
+ // Given two VerificationToken instances, one with a null id
+ VerificationToken saved = new VerificationToken("token");
+ saved.setId(10L);
+
+ VerificationToken transientToken = new VerificationToken("token");
+
+ // Then they are not equal under id-only equality
+ assertThat(saved).isNotEqualTo(transientToken);
+ }
+
+ @Test
+ void shouldNotIncludeTokenSecretWhenVerificationTokenToStringCalled() {
+ // Given a verification token holding hashed and raw secret values
+ VerificationToken token = new VerificationToken("HASHED_TOKEN_SECRET");
+ token.setId(10L);
+ token.setPlainToken("RAW_TOKEN_SECRET");
+
+ // When rendered to a string
+ String rendered = token.toString();
+
+ // Then neither the hashed nor the raw token secret appears
+ assertThat(rendered).doesNotContain("HASHED_TOKEN_SECRET");
+ assertThat(rendered).doesNotContain("RAW_TOKEN_SECRET");
+ }
+
+ @Test
+ void shouldBeEqualAndShareHashCodeWhenWebAuthnCredentialsHaveSameCredentialId() {
+ // Given two distinct WebAuthnCredential instances with the same natural-key credentialId
+ WebAuthnCredential first = new WebAuthnCredential();
+ first.setCredentialId("AAAA-same-credential-id");
+ first.setLabel("My iPhone");
+
+ WebAuthnCredential second = new WebAuthnCredential();
+ second.setCredentialId("AAAA-same-credential-id");
+ second.setLabel("Some Other Label");
+
+ // Then they are equal and share a hash code, regardless of differing non-key fields
+ assertThat(first).isEqualTo(second);
+ assertThat(first).hasSameHashCodeAs(second);
+ }
+
+ @Test
+ void shouldNotBeEqualWhenWebAuthnCredentialsHaveDifferentCredentialIds() {
+ // Given two WebAuthnCredential instances with different credentialIds
+ WebAuthnCredential first = new WebAuthnCredential();
+ first.setCredentialId("AAAA-credential-one");
+
+ WebAuthnCredential second = new WebAuthnCredential();
+ second.setCredentialId("BBBB-credential-two");
+
+ // Then they are not equal
+ assertThat(first).isNotEqualTo(second);
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/RolePrivilegeUniquenessTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/RolePrivilegeUniquenessTest.java
new file mode 100644
index 00000000..ba7b8183
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/RolePrivilegeUniquenessTest.java
@@ -0,0 +1,49 @@
+package com.digitalsanctuary.spring.user.persistence.model;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
+import org.springframework.dao.DataIntegrityViolationException;
+import com.digitalsanctuary.spring.user.persistence.repository.PrivilegeRepository;
+import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository;
+import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest;
+
+/**
+ * Database-slice tests verifying that the UNIQUE + NOT NULL constraint on the {@code name} column of {@link Role} and
+ * {@link Privilege} is enforced by the schema. Two roles (or two privileges) with the same name must not coexist.
+ */
+@DatabaseTest
+class RolePrivilegeUniquenessTest {
+
+ @Autowired
+ private RoleRepository roleRepository;
+
+ @Autowired
+ private PrivilegeRepository privilegeRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ @Test
+ void shouldRejectDuplicateRoleNameWhenFlushed() {
+ roleRepository.saveAndFlush(new Role("ROLE_DUPLICATE"));
+ entityManager.clear();
+
+ Role second = new Role("ROLE_DUPLICATE");
+
+ assertThatThrownBy(() -> roleRepository.saveAndFlush(second))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @Test
+ void shouldRejectDuplicatePrivilegeNameWhenFlushed() {
+ privilegeRepository.saveAndFlush(new Privilege("DUPLICATE_PRIVILEGE"));
+ entityManager.clear();
+
+ Privilege second = new Privilege("DUPLICATE_PRIVILEGE");
+
+ assertThatThrownBy(() -> privilegeRepository.saveAndFlush(second))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/TokenUniquenessTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/TokenUniquenessTest.java
new file mode 100644
index 00000000..d338b387
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/TokenUniquenessTest.java
@@ -0,0 +1,64 @@
+package com.digitalsanctuary.spring.user.persistence.model;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
+import org.springframework.dao.DataIntegrityViolationException;
+import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository;
+import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository;
+import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest;
+import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
+
+/**
+ * Database-slice tests verifying that the UNIQUE + NOT NULL constraint on the {@code token} column
+ * of {@link PasswordResetToken} and {@link VerificationToken} is enforced by the schema. Two tokens
+ * with the same value must not coexist in either table.
+ */
+@DatabaseTest
+class TokenUniquenessTest {
+
+ @Autowired
+ private PasswordResetTokenRepository passwordResetTokenRepository;
+
+ @Autowired
+ private VerificationTokenRepository verificationTokenRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ private User persistUser(String email) {
+ User user = UserTestDataBuilder.aUser().withId(null).withEmail(email).build();
+ return entityManager.persistAndFlush(user);
+ }
+
+ @Test
+ void shouldRejectDuplicateTokenValueWhenPasswordResetTokenIsFlushed() {
+ User user1 = persistUser("prt-unique1@test.com");
+ User user2 = persistUser("prt-unique2@test.com");
+ String duplicateToken = "duplicate-hash-value-prt";
+
+ PasswordResetToken first = new PasswordResetToken(duplicateToken, user1);
+ passwordResetTokenRepository.saveAndFlush(first);
+
+ PasswordResetToken second = new PasswordResetToken(duplicateToken, user2);
+
+ assertThatThrownBy(() -> passwordResetTokenRepository.saveAndFlush(second))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @Test
+ void shouldRejectDuplicateTokenValueWhenVerificationTokenIsFlushed() {
+ User user1 = persistUser("vt-unique1@test.com");
+ User user2 = persistUser("vt-unique2@test.com");
+ String duplicateToken = "duplicate-hash-value-vt";
+
+ VerificationToken first = new VerificationToken(duplicateToken, user1);
+ verificationTokenRepository.saveAndFlush(first);
+
+ VerificationToken second = new VerificationToken(duplicateToken, user2);
+
+ assertThatThrownBy(() -> verificationTokenRepository.saveAndFlush(second))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryEntityGraphTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryEntityGraphTest.java
new file mode 100644
index 00000000..35d473d8
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryEntityGraphTest.java
@@ -0,0 +1,141 @@
+package com.digitalsanctuary.spring.user.persistence.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import java.util.HashSet;
+import java.util.Set;
+import org.hibernate.Hibernate;
+import org.hibernate.SessionFactory;
+import org.hibernate.stat.Statistics;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
+import com.digitalsanctuary.spring.user.persistence.model.Privilege;
+import com.digitalsanctuary.spring.user.persistence.model.Role;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest;
+import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
+import jakarta.persistence.EntityManager;
+
+/**
+ * Database-slice tests verifying that {@link UserRepository#findWithRolesByEmail(String)} eagerly loads the
+ * User → roles → privileges graph via {@code @EntityGraph} in a bounded number of queries, while the plain
+ * {@link UserRepository#findByEmail(String)} leaves the now-LAZY collections uninitialized. This is the regression guard
+ * for the EAGER → LAZY switch on {@code User.roles} and {@code Role.privileges}.
+ */
+@DatabaseTest
+class UserRepositoryEntityGraphTest {
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ private void persistUserWithRolesAndPrivileges(String email) {
+ Privilege read = new Privilege("READ_PRIVILEGE");
+ Privilege write = new Privilege("WRITE_PRIVILEGE");
+ entityManager.persist(read);
+ entityManager.persist(write);
+
+ Role userRole = new Role("ROLE_USER");
+ Set userPrivileges = new HashSet<>();
+ userPrivileges.add(read);
+ userRole.setPrivileges(userPrivileges);
+
+ Role adminRole = new Role("ROLE_ADMIN");
+ Set adminPrivileges = new HashSet<>();
+ adminPrivileges.add(read);
+ adminPrivileges.add(write);
+ adminRole.setPrivileges(adminPrivileges);
+
+ entityManager.persist(userRole);
+ entityManager.persist(adminRole);
+
+ Set roles = new HashSet<>();
+ roles.add(userRole);
+ roles.add(adminRole);
+ User user = UserTestDataBuilder.aUser().withId(null).withEmail(email).build();
+ user.setRolesAsSet(roles);
+ entityManager.persist(user);
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ private Statistics statistics() {
+ EntityManager em = entityManager.getEntityManager();
+ Statistics stats = em.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();
+ stats.setStatisticsEnabled(true);
+ return stats;
+ }
+
+ @Test
+ void shouldInitializeRolesAndPrivilegesWhenLoadedViaEntityGraphFinder() {
+ persistUserWithRolesAndPrivileges("graph@test.com");
+
+ User user = userRepository.findWithRolesByEmail("graph@test.com");
+
+ // Detach so any later access would hit a closed session if the graph were not initialized.
+ entityManager.getEntityManager().detach(user);
+
+ assertThat(user).isNotNull();
+ assertThat(Hibernate.isInitialized(user.getRolesAsSet())).isTrue();
+ for (Role role : user.getRolesAsSet()) {
+ assertThat(Hibernate.isInitialized(role.getPrivileges())).isTrue();
+ }
+ }
+
+ @Test
+ void shouldAccessRolesAndPrivilegesWithoutLazyInitializationExceptionAfterDetach() {
+ persistUserWithRolesAndPrivileges("detached@test.com");
+
+ User user = userRepository.findWithRolesByEmail("detached@test.com");
+ entityManager.getEntityManager().detach(user);
+
+ // Traversing the full graph on a detached entity must not throw LazyInitializationException.
+ assertThatCode(() -> {
+ for (Role role : user.getRolesAsSet()) {
+ role.getName();
+ for (Privilege privilege : role.getPrivileges()) {
+ privilege.getName();
+ }
+ }
+ }).doesNotThrowAnyException();
+
+ assertThat(user.getRolesAsSet())
+ .extracting(Role::getName)
+ .containsExactlyInAnyOrder("ROLE_USER", "ROLE_ADMIN");
+ }
+
+ @Test
+ void shouldNotInitializeRolesWhenLoadedViaPlainFindByEmail() {
+ persistUserWithRolesAndPrivileges("lazy@test.com");
+
+ User user = userRepository.findByEmail("lazy@test.com");
+
+ // The plain finder must leave roles uninitialized, proving the EAGER -> LAZY switch took effect.
+ assertThat(Hibernate.isInitialized(user.getRolesAsSet())).isFalse();
+ }
+
+ @Test
+ void shouldLoadRolesAndPrivilegesInBoundedQueryCountViaEntityGraphFinder() {
+ persistUserWithRolesAndPrivileges("bounded@test.com");
+
+ Statistics stats = statistics();
+ stats.clear();
+
+ User user = userRepository.findWithRolesByEmail("bounded@test.com");
+ // Force full traversal to prove no additional (N+1) queries are issued for privileges.
+ int privilegeCount = 0;
+ for (Role role : user.getRolesAsSet()) {
+ privilegeCount += role.getPrivileges().size();
+ }
+
+ assertThat(privilegeCount).isPositive();
+ // A single entity-graph fetch should not degenerate into a per-role / per-privilege N+1 explosion.
+ // Allow a small bound to tolerate join-table fetch strategy differences across Hibernate versions.
+ assertThat(stats.getPrepareStatementCount())
+ .as("EntityGraph finder should load roles + privileges in a bounded number of queries")
+ .isLessThanOrEqualTo(3);
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/roles/RolePrivilegeSetupServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/roles/RolePrivilegeSetupServiceTest.java
index bcc51ae3..41ecf678 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/roles/RolePrivilegeSetupServiceTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/roles/RolePrivilegeSetupServiceTest.java
@@ -3,14 +3,18 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
@@ -22,6 +26,7 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.event.ContextRefreshedEvent;
+import org.springframework.dao.DataIntegrityViolationException;
import com.digitalsanctuary.spring.user.persistence.model.Privilege;
import com.digitalsanctuary.spring.user.persistence.model.Role;
@@ -49,10 +54,14 @@ class RolePrivilegeSetupServiceTest {
@BeforeEach
void setUp() {
rolePrivilegeSetupService = new RolePrivilegeSetupService(
- rolesAndPrivilegesConfig,
- roleRepository,
+ rolesAndPrivilegesConfig,
+ roleRepository,
privilegeRepository
);
+ // In production the REQUIRES_NEW insert methods are routed through the Spring proxy. In this unit test there is
+ // no proxy, so point the self-reference at the bean itself: a direct call still exercises the catch/re-read
+ // branch we care about (the transactional propagation is a runtime concern verified by integration tests).
+ rolePrivilegeSetupService.setSelf(rolePrivilegeSetupService);
}
@Nested
@@ -66,23 +75,22 @@ void shouldSetupRolesAndPrivilegesOnFirstContextRefresh() {
Map> rolesAndPrivileges = new HashMap<>();
rolesAndPrivileges.put("ROLE_ADMIN", Arrays.asList("READ_PRIVILEGE", "WRITE_PRIVILEGE", "DELETE_PRIVILEGE"));
rolesAndPrivileges.put("ROLE_USER", Arrays.asList("READ_PRIVILEGE"));
-
+
when(rolesAndPrivilegesConfig.getRolesAndPrivileges()).thenReturn(rolesAndPrivileges);
when(privilegeRepository.findByName(anyString())).thenReturn(null);
- when(privilegeRepository.save(any(Privilege.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(privilegeRepository.saveAndFlush(any(Privilege.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(roleRepository.findByName(anyString())).thenReturn(null);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
rolePrivilegeSetupService.onApplicationEvent(contextRefreshedEvent);
// Then
- // READ_PRIVILEGE appears twice (for ROLE_ADMIN and ROLE_USER), so 4 total lookups
- verify(privilegeRepository, times(4)).findByName(anyString());
- // All 4 privileges will be saved (READ is saved twice because findByName returns null each time)
- verify(privilegeRepository, times(4)).save(any(Privilege.class));
+ // 4 privilege creations (READ appears for both roles and findByName always returns null, so it is created
+ // each time it is requested in getOrCreatePrivilege): 3 for ROLE_ADMIN + 1 for ROLE_USER.
+ verify(privilegeRepository, times(4)).saveAndFlush(any(Privilege.class));
verify(roleRepository, times(2)).findByName(anyString());
- verify(roleRepository, times(2)).save(any(Role.class));
+ verify(roleRepository, times(2)).saveAndFlush(any(Role.class));
assertThat(rolePrivilegeSetupService.isAlreadySetup()).isTrue();
}
@@ -123,9 +131,11 @@ void shouldSkipNullRoleNamesInConfiguration() {
Map> rolesAndPrivileges = new HashMap<>();
rolesAndPrivileges.put(null, Arrays.asList("READ_PRIVILEGE"));
rolesAndPrivileges.put("ROLE_USER", Arrays.asList("READ_PRIVILEGE"));
-
+
when(rolesAndPrivilegesConfig.getRolesAndPrivileges()).thenReturn(rolesAndPrivileges);
when(privilegeRepository.findByName("READ_PRIVILEGE")).thenReturn(new Privilege("READ_PRIVILEGE"));
+ when(roleRepository.findByName("ROLE_USER")).thenReturn(null);
+ when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
rolePrivilegeSetupService.onApplicationEvent(contextRefreshedEvent);
@@ -142,9 +152,11 @@ void shouldSkipRolesWithNullPrivilegeLists() {
Map> rolesAndPrivileges = new HashMap<>();
rolesAndPrivileges.put("ROLE_ADMIN", null);
rolesAndPrivileges.put("ROLE_USER", Arrays.asList("READ_PRIVILEGE"));
-
+
when(rolesAndPrivilegesConfig.getRolesAndPrivileges()).thenReturn(rolesAndPrivileges);
when(privilegeRepository.findByName("READ_PRIVILEGE")).thenReturn(new Privilege("READ_PRIVILEGE"));
+ when(roleRepository.findByName("ROLE_USER")).thenReturn(null);
+ when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
rolePrivilegeSetupService.onApplicationEvent(contextRefreshedEvent);
@@ -167,7 +179,7 @@ void shouldCreateNewPrivilegeWhenNotExists() {
when(privilegeRepository.findByName(privilegeName)).thenReturn(null);
Privilege savedPrivilege = new Privilege(privilegeName);
savedPrivilege.setId(1L);
- when(privilegeRepository.save(any(Privilege.class))).thenReturn(savedPrivilege);
+ when(privilegeRepository.saveAndFlush(any(Privilege.class))).thenReturn(savedPrivilege);
// When
Privilege result = rolePrivilegeSetupService.getOrCreatePrivilege(privilegeName);
@@ -177,7 +189,7 @@ void shouldCreateNewPrivilegeWhenNotExists() {
assertThat(result.getName()).isEqualTo(privilegeName);
assertThat(result.getId()).isEqualTo(1L);
verify(privilegeRepository).findByName(privilegeName);
- verify(privilegeRepository).save(any(Privilege.class));
+ verify(privilegeRepository).saveAndFlush(any(Privilege.class));
}
@Test
@@ -195,31 +207,48 @@ void shouldReturnExistingPrivilegeWhenExists() {
// Then
assertThat(result).isEqualTo(existingPrivilege);
verify(privilegeRepository).findByName(privilegeName);
- verify(privilegeRepository, never()).save(any(Privilege.class));
+ verify(privilegeRepository, never()).saveAndFlush(any(Privilege.class));
}
@Test
- @DisplayName("Should handle multiple privileges efficiently")
- void shouldHandleMultiplePrivilegesEfficiently() {
- // Given
- List privilegeNames = Arrays.asList("READ", "WRITE", "DELETE", "UPDATE");
- when(privilegeRepository.findByName(anyString())).thenReturn(null);
- when(privilegeRepository.save(any(Privilege.class))).thenAnswer(invocation -> {
- Privilege p = invocation.getArgument(0);
- p.setId((long) privilegeNames.indexOf(p.getName()) + 1);
- return p;
- });
+ @DisplayName("Should return existing privilege when a concurrent node inserted it (constraint race)")
+ void shouldReturnExistingPrivilegeWhenConcurrentInsertViolatesConstraint() {
+ // Given: first findByName misses (privilege not yet visible), insert hits the unique constraint because a
+ // concurrent node committed it first, then the re-read finds the winning node's row.
+ String privilegeName = "READ_PRIVILEGE";
+ Privilege concurrentPrivilege = new Privilege(privilegeName);
+ concurrentPrivilege.setId(42L);
+ when(privilegeRepository.findByName(privilegeName))
+ .thenReturn(null)
+ .thenReturn(concurrentPrivilege);
+ when(privilegeRepository.saveAndFlush(any(Privilege.class)))
+ .thenThrow(new DataIntegrityViolationException("unique violation on privilege.name"));
// When
- Set privileges = new HashSet<>();
- for (String name : privilegeNames) {
- privileges.add(rolePrivilegeSetupService.getOrCreatePrivilege(name));
- }
+ Privilege result = rolePrivilegeSetupService.getOrCreatePrivilege(privilegeName);
- // Then
- assertThat(privileges).hasSize(4);
- verify(privilegeRepository, times(4)).findByName(anyString());
- verify(privilegeRepository, times(4)).save(any(Privilege.class));
+ // Then: no exception propagated, the existing row is returned, no duplicate created.
+ assertThat(result).isSameAs(concurrentPrivilege);
+ verify(privilegeRepository, times(2)).findByName(privilegeName);
+ verify(privilegeRepository, times(1)).saveAndFlush(any(Privilege.class));
+ }
+
+ @Test
+ @DisplayName("Should rethrow when insert fails and re-read still finds nothing")
+ void shouldRethrowWhenInsertFailsAndReReadFindsNothing() {
+ // Given: a genuine integrity problem (not a name race) — the row is still absent after the failed insert.
+ String privilegeName = "READ_PRIVILEGE";
+ when(privilegeRepository.findByName(privilegeName)).thenReturn(null);
+ DataIntegrityViolationException failure = new DataIntegrityViolationException("not a name conflict");
+ when(privilegeRepository.saveAndFlush(any(Privilege.class))).thenThrow(failure);
+
+ // When / Then
+ try {
+ rolePrivilegeSetupService.getOrCreatePrivilege(privilegeName);
+ org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown(DataIntegrityViolationException.class);
+ } catch (DataIntegrityViolationException e) {
+ assertThat(e).isSameAs(failure);
+ }
}
}
@@ -232,26 +261,29 @@ class RoleManagementTests {
void shouldCreateNewRoleWhenNotExists() {
// Given
String roleName = "ROLE_ADMIN";
- Set privileges = new HashSet<>();
- privileges.add(new Privilege("READ_PRIVILEGE"));
- privileges.add(new Privilege("WRITE_PRIVILEGE"));
-
+ Privilege readPriv = new Privilege("READ_PRIVILEGE");
+ readPriv.setId(10L);
+ Privilege writePriv = new Privilege("WRITE_PRIVILEGE");
+ writePriv.setId(11L);
+ when(privilegeRepository.findByName("READ_PRIVILEGE")).thenReturn(readPriv);
+ when(privilegeRepository.findByName("WRITE_PRIVILEGE")).thenReturn(writePriv);
+
when(roleRepository.findByName(roleName)).thenReturn(null);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> {
+ when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(invocation -> {
Role r = invocation.getArgument(0);
r.setId(1L);
return r;
});
// When
- Role result = rolePrivilegeSetupService.getOrCreateRole(roleName, privileges);
+ Role result = rolePrivilegeSetupService.getOrCreateRole(roleName, new HashSet<>(Arrays.asList("READ_PRIVILEGE", "WRITE_PRIVILEGE")));
// Then
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo(roleName);
- assertThat(result.getPrivileges()).isEqualTo(privileges);
+ assertThat(result.getPrivileges()).containsExactlyInAnyOrder(readPriv, writePriv);
verify(roleRepository).findByName(roleName);
- verify(roleRepository).save(any(Role.class));
+ verify(roleRepository).saveAndFlush(any(Role.class));
}
@Test
@@ -264,28 +296,28 @@ void shouldUpdateExistingRolePrivileges() {
Set oldPrivileges = new HashSet<>();
oldPrivileges.add(new Privilege("OLD_PRIVILEGE"));
existingRole.setPrivileges(oldPrivileges);
-
- Set newPrivileges = new HashSet<>();
+
Privilege readPriv = new Privilege("READ_PRIVILEGE");
readPriv.setId(10L);
Privilege writePriv = new Privilege("WRITE_PRIVILEGE");
writePriv.setId(11L);
- newPrivileges.add(readPriv);
- newPrivileges.add(writePriv);
-
+ when(privilegeRepository.findByName("READ_PRIVILEGE")).thenReturn(readPriv);
+ when(privilegeRepository.findByName("WRITE_PRIVILEGE")).thenReturn(writePriv);
+
when(roleRepository.findByName(roleName)).thenReturn(existingRole);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(roleRepository.findById(2L)).thenReturn(Optional.of(existingRole));
+ when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
- Role result = rolePrivilegeSetupService.getOrCreateRole(roleName, newPrivileges);
+ Role result = rolePrivilegeSetupService.getOrCreateRole(roleName, new HashSet<>(Arrays.asList("READ_PRIVILEGE", "WRITE_PRIVILEGE")));
// Then
// The role's privileges should be completely replaced with the new set
assertThat(result).isSameAs(existingRole);
- assertThat(result.getPrivileges()).isEqualTo(newPrivileges);
+ assertThat(result.getPrivileges()).containsExactlyInAnyOrder(readPriv, writePriv);
assertThat(result.getPrivileges()).hasSize(2);
verify(roleRepository).findByName(roleName);
- verify(roleRepository).save(existingRole);
+ verify(roleRepository).saveAndFlush(existingRole);
}
@Test
@@ -293,19 +325,47 @@ void shouldUpdateExistingRolePrivileges() {
void shouldHandleRoleWithEmptyPrivilegeSet() {
// Given
String roleName = "ROLE_GUEST";
- Set emptyPrivileges = new HashSet<>();
-
+
when(roleRepository.findByName(roleName)).thenReturn(null);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
- Role result = rolePrivilegeSetupService.getOrCreateRole(roleName, emptyPrivileges);
+ Role result = rolePrivilegeSetupService.getOrCreateRole(roleName, new HashSet<>());
// Then
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo(roleName);
assertThat(result.getPrivileges()).isEmpty();
- verify(roleRepository).save(any(Role.class));
+ verify(roleRepository).saveAndFlush(any(Role.class));
+ }
+
+ @Test
+ @DisplayName("Should return existing role when a concurrent node inserted it (constraint race)")
+ void shouldReturnExistingRoleWhenConcurrentInsertViolatesConstraint() {
+ // Given: role not visible on first read, insert hits the unique constraint (concurrent node won), re-read
+ // finds the winning row, and we (re)assign privileges onto it.
+ String roleName = "ROLE_ADMIN";
+ Privilege readPriv = new Privilege("READ_PRIVILEGE");
+ when(privilegeRepository.findByName("READ_PRIVILEGE")).thenReturn(readPriv);
+
+ Role concurrentRole = new Role(roleName);
+ concurrentRole.setId(99L);
+
+ when(roleRepository.findByName(roleName))
+ .thenReturn(null)
+ .thenReturn(concurrentRole);
+ when(roleRepository.findById(99L)).thenReturn(Optional.of(concurrentRole));
+ when(roleRepository.saveAndFlush(any(Role.class)))
+ .thenThrow(new DataIntegrityViolationException("unique violation on role.name"))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // When
+ Role result = rolePrivilegeSetupService.getOrCreateRole(roleName, new HashSet<>(Arrays.asList("READ_PRIVILEGE")));
+
+ // Then: no exception propagated, the existing row is returned with the desired privileges.
+ assertThat(result).isSameAs(concurrentRole);
+ assertThat(result.getPrivileges()).containsExactly(readPriv);
+ verify(roleRepository, times(2)).findByName(roleName);
}
}
@@ -313,6 +373,39 @@ void shouldHandleRoleWithEmptyPrivilegeSet() {
@DisplayName("Integration Tests")
class IntegrationTests {
+ /**
+ * Wires the mocked repositories to behave like a simple in-memory store: {@code findByName} returns whatever
+ * was previously {@code saveAndFlush}-ed (assigning an id on first save), and {@code findById} returns the
+ * stored row. This lets the role insert/update path resolve privileges to "managed" instances by name, mirroring
+ * production where the privileges were created earlier in the same setup flow.
+ */
+ private void wireInMemoryStore() {
+ final Map privileges = new HashMap<>();
+ final Map roles = new HashMap<>();
+ final Map rolesById = new HashMap<>();
+
+ lenient().when(privilegeRepository.findByName(anyString())).thenAnswer(inv -> privileges.get(inv.getArgument(0)));
+ lenient().when(privilegeRepository.saveAndFlush(any(Privilege.class))).thenAnswer(inv -> {
+ Privilege p = inv.getArgument(0);
+ if (p.getId() == null) {
+ p.setId((long) (privileges.size() + 1));
+ }
+ privileges.put(p.getName(), p);
+ return p;
+ });
+ lenient().when(roleRepository.findByName(anyString())).thenAnswer(inv -> roles.get(inv.getArgument(0)));
+ lenient().when(roleRepository.findById(any())).thenAnswer(inv -> Optional.ofNullable(rolesById.get(inv.getArgument(0))));
+ lenient().when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(inv -> {
+ Role r = inv.getArgument(0);
+ if (r.getId() == null) {
+ r.setId((long) (roles.size() + 1));
+ }
+ roles.put(r.getName(), r);
+ rolesById.put(r.getId(), r);
+ return r;
+ });
+ }
+
@Test
@DisplayName("Should handle complex role hierarchy setup")
void shouldHandleComplexRoleHierarchySetup() {
@@ -322,27 +415,24 @@ void shouldHandleComplexRoleHierarchySetup() {
rolesAndPrivileges.put("ROLE_ADMIN", Arrays.asList("READ", "WRITE", "DELETE"));
rolesAndPrivileges.put("ROLE_MODERATOR", Arrays.asList("READ", "WRITE"));
rolesAndPrivileges.put("ROLE_USER", Arrays.asList("READ"));
-
+
when(rolesAndPrivilegesConfig.getRolesAndPrivileges()).thenReturn(rolesAndPrivileges);
- when(privilegeRepository.findByName(anyString())).thenReturn(null);
- when(privilegeRepository.save(any(Privilege.class))).thenAnswer(invocation -> invocation.getArgument(0));
- when(roleRepository.findByName(anyString())).thenReturn(null);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ wireInMemoryStore();
// When
rolePrivilegeSetupService.onApplicationEvent(contextRefreshedEvent);
// Then
- // Verify unique privileges are created (7 unique privileges total, but READ appears 4 times)
+ // 6 unique privileges are persisted exactly once each (READ/WRITE/DELETE are shared across roles).
ArgumentCaptor privilegeCaptor = ArgumentCaptor.forClass(Privilege.class);
- verify(privilegeRepository, times(9)).save(privilegeCaptor.capture()); // Total privilege saves
-
- // Verify all roles are created
+ verify(privilegeRepository, times(6)).saveAndFlush(privilegeCaptor.capture());
+ assertThat(privilegeCaptor.getAllValues()).extracting(Privilege::getName)
+ .containsExactlyInAnyOrder("SUPER_READ", "SUPER_WRITE", "SUPER_DELETE", "READ", "WRITE", "DELETE");
+
+ // All 4 roles are created with their resolved privileges.
ArgumentCaptor roleCaptor = ArgumentCaptor.forClass(Role.class);
- verify(roleRepository, times(4)).save(roleCaptor.capture());
-
- List savedRoles = roleCaptor.getAllValues();
- assertThat(savedRoles).extracting(Role::getName)
+ verify(roleRepository, times(4)).saveAndFlush(roleCaptor.capture());
+ assertThat(roleCaptor.getAllValues()).extracting(Role::getName)
.containsExactlyInAnyOrder("ROLE_SUPER_ADMIN", "ROLE_ADMIN", "ROLE_MODERATOR", "ROLE_USER");
}
@@ -353,30 +443,19 @@ void shouldReuseExistingPrivilegesAcrossRoles() {
Map> rolesAndPrivileges = new HashMap<>();
rolesAndPrivileges.put("ROLE_ADMIN", Arrays.asList("READ", "WRITE"));
rolesAndPrivileges.put("ROLE_USER", Arrays.asList("READ"));
-
- Privilege readPrivilege = new Privilege("READ");
- readPrivilege.setId(1L);
-
+
when(rolesAndPrivilegesConfig.getRolesAndPrivileges()).thenReturn(rolesAndPrivileges);
- // First call returns null (creates), subsequent calls return existing
- when(privilegeRepository.findByName("READ"))
- .thenReturn(null)
- .thenReturn(readPrivilege);
- when(privilegeRepository.findByName("WRITE")).thenReturn(null);
- when(privilegeRepository.save(any(Privilege.class))).thenAnswer(invocation -> invocation.getArgument(0));
- when(roleRepository.findByName(anyString())).thenReturn(null);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ wireInMemoryStore();
// When
rolePrivilegeSetupService.onApplicationEvent(contextRefreshedEvent);
// Then
- // READ privilege should be looked up twice but only created once
- verify(privilegeRepository, times(2)).findByName("READ");
- // WRITE privilege should be created once
- verify(privilegeRepository, times(1)).findByName("WRITE");
- // Only 2 privileges should be saved (READ once, WRITE once)
- verify(privilegeRepository, times(2)).save(any(Privilege.class));
+ // READ and WRITE are each created exactly once; READ is reused for the second role.
+ ArgumentCaptor privilegeCaptor = ArgumentCaptor.forClass(Privilege.class);
+ verify(privilegeRepository, times(2)).saveAndFlush(privilegeCaptor.capture());
+ assertThat(privilegeCaptor.getAllValues()).extracting(Privilege::getName)
+ .containsExactlyInAnyOrder("READ", "WRITE");
}
@Test
@@ -385,41 +464,24 @@ void shouldHandleDatabaseTransactionProperly() {
// Given
Map> rolesAndPrivileges = new HashMap<>();
rolesAndPrivileges.put("ROLE_TRANSACTIONAL", Arrays.asList("TRANSACT_READ", "TRANSACT_WRITE"));
-
+
when(rolesAndPrivilegesConfig.getRolesAndPrivileges()).thenReturn(rolesAndPrivileges);
- when(privilegeRepository.findByName(anyString())).thenReturn(null);
-
- // Track created privileges to return the same instance when saved
- Map createdPrivileges = new HashMap<>();
- when(privilegeRepository.save(any(Privilege.class))).thenAnswer(invocation -> {
- Privilege p = invocation.getArgument(0);
- p.setId((long) (createdPrivileges.size() + 1)); // Simulate DB generating ID
- createdPrivileges.put(p.getName(), p);
- return p;
- });
-
- when(roleRepository.findByName(anyString())).thenReturn(null);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> {
- Role r = invocation.getArgument(0);
- r.setId(1L);
- return r;
- });
+ wireInMemoryStore();
// When
rolePrivilegeSetupService.onApplicationEvent(contextRefreshedEvent);
// Then
ArgumentCaptor roleCaptor = ArgumentCaptor.forClass(Role.class);
- verify(roleRepository).save(roleCaptor.capture());
-
+ verify(roleRepository).saveAndFlush(roleCaptor.capture());
+
Role savedRole = roleCaptor.getValue();
// The privileges are stored in a Set, so we should have 2 unique privileges
assertThat(savedRole.getPrivileges()).hasSize(2);
- // Verify the privileges have their names set correctly
assertThat(savedRole.getPrivileges())
.extracting(Privilege::getName)
.containsExactlyInAnyOrder("TRANSACT_READ", "TRANSACT_WRITE");
- // Verify the privileges have IDs assigned
+ // Verify the privileges have IDs assigned (they were resolved as persisted, managed entities)
assertThat(savedRole.getPrivileges()).allMatch(p -> p.getId() != null && p.getId() > 0);
}
@@ -430,27 +492,50 @@ void shouldCompleteSetupWithMixedExistingAndNewEntities() {
Map> rolesAndPrivileges = new HashMap<>();
rolesAndPrivileges.put("ROLE_EXISTING", Arrays.asList("EXISTING_PRIV", "NEW_PRIV"));
rolesAndPrivileges.put("ROLE_NEW", Arrays.asList("EXISTING_PRIV"));
-
+
Privilege existingPriv = new Privilege("EXISTING_PRIV");
existingPriv.setId(100L);
Role existingRole = new Role("ROLE_EXISTING");
existingRole.setId(200L);
-
+
when(rolesAndPrivilegesConfig.getRolesAndPrivileges()).thenReturn(rolesAndPrivileges);
- when(privilegeRepository.findByName("EXISTING_PRIV")).thenReturn(existingPriv);
- when(privilegeRepository.findByName("NEW_PRIV")).thenReturn(null);
- when(privilegeRepository.save(any(Privilege.class))).thenAnswer(invocation -> invocation.getArgument(0));
- when(roleRepository.findByName("ROLE_EXISTING")).thenReturn(existingRole);
- when(roleRepository.findByName("ROLE_NEW")).thenReturn(null);
- when(roleRepository.save(any(Role.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ // Pre-seed an in-memory store with one existing privilege and one existing role.
+ final Map privileges = new HashMap<>();
+ privileges.put("EXISTING_PRIV", existingPriv);
+ final Map roles = new HashMap<>();
+ roles.put("ROLE_EXISTING", existingRole);
+ final Map rolesById = new HashMap<>();
+ rolesById.put(200L, existingRole);
+
+ when(privilegeRepository.findByName(anyString())).thenAnswer(inv -> privileges.get(inv.getArgument(0)));
+ when(privilegeRepository.saveAndFlush(any(Privilege.class))).thenAnswer(inv -> {
+ Privilege p = inv.getArgument(0);
+ if (p.getId() == null) {
+ p.setId((long) (privileges.size() + 1));
+ }
+ privileges.put(p.getName(), p);
+ return p;
+ });
+ when(roleRepository.findByName(anyString())).thenAnswer(inv -> roles.get(inv.getArgument(0)));
+ when(roleRepository.findById(any())).thenAnswer(inv -> Optional.ofNullable(rolesById.get(inv.getArgument(0))));
+ when(roleRepository.saveAndFlush(any(Role.class))).thenAnswer(inv -> {
+ Role r = inv.getArgument(0);
+ if (r.getId() == null) {
+ r.setId((long) (roles.size() + 1));
+ }
+ roles.put(r.getName(), r);
+ rolesById.put(r.getId(), r);
+ return r;
+ });
// When
rolePrivilegeSetupService.onApplicationEvent(contextRefreshedEvent);
// Then
- verify(privilegeRepository, times(1)).save(any(Privilege.class)); // Only NEW_PRIV
- verify(roleRepository, times(2)).save(any(Role.class)); // Both roles get saved (existing one with updated privileges)
+ verify(privilegeRepository, times(1)).saveAndFlush(any(Privilege.class)); // Only NEW_PRIV created
+ verify(roleRepository, times(2)).saveAndFlush(any(Role.class)); // Both roles saved (existing one updated)
+ verify(roleRepository, times(1)).findById(200L); // existing role re-read via REQUIRES_NEW update path
assertThat(rolePrivilegeSetupService.isAlreadySetup()).isTrue();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java
index 90c91401..00ec2ec5 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java
@@ -77,7 +77,7 @@ void shouldRejectNewOAuth2UserWhenGuardDenies() {
.withLastName("User")
.build();
- when(userRepository.findByEmail("new@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("new@gmail.com")).thenReturn(null);
doThrow(new RegistrationDeniedException("Domain not allowed"))
.when(userService).enforceRegistrationGuard(eq("new@gmail.com"), eq(RegistrationSource.OAUTH2), anyString());
@@ -99,7 +99,7 @@ void shouldAllowNewOAuth2UserWhenGuardAllows() {
.withLastName("User")
.build();
- when(userRepository.findByEmail("allowed@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("allowed@gmail.com")).thenReturn(null);
doNothing().when(userService)
.enforceRegistrationGuard(eq("allowed@gmail.com"), eq(RegistrationSource.OAUTH2), anyString());
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
@@ -125,7 +125,7 @@ void shouldNotCallGuardForExistingOAuth2User() {
existingUser.setEmail("existing@gmail.com");
existingUser.setProvider(User.Provider.GOOGLE);
- when(userRepository.findByEmail("existing@gmail.com")).thenReturn(existingUser);
+ when(userRepository.findWithRolesByEmail("existing@gmail.com")).thenReturn(existingUser);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
User result = service.handleOAuthLoginSuccess("google", googleUser);
diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java
index b1598f00..00c0595e 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java
@@ -90,7 +90,7 @@ void shouldCreateNewUserFromGoogleOAuth2() {
.withLastName("Doe")
.build();
- when(userRepository.findByEmail("john.doe@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("john.doe@gmail.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User savedUser = invocation.getArgument(0);
savedUser.setId(123L);
@@ -121,8 +121,8 @@ void shouldCreateNewUserFromGoogleOAuth2() {
// can observe OAuth2 registrations the same way they observe form registrations.
ArgumentCaptor regCaptor = ArgumentCaptor.forClass(OnRegistrationCompleteEvent.class);
verify(eventPublisher).publishEvent(regCaptor.capture());
- assertThat(regCaptor.getValue().getUser().getEmail()).isEqualTo("john.doe@gmail.com");
- assertThat(regCaptor.getValue().getUser().isEnabled()).isTrue();
+ assertThat(regCaptor.getValue().getUserEmail()).isEqualTo("john.doe@gmail.com");
+ assertThat(regCaptor.getValue().isUserEnabled()).isTrue();
}
@Test
@@ -143,7 +143,7 @@ void shouldUpdateExistingGoogleUser() {
existingUser.setProvider(User.Provider.GOOGLE);
existingUser.setEnabled(true);
- when(userRepository.findByEmail("existing@gmail.com")).thenReturn(existingUser);
+ when(userRepository.findWithRolesByEmail("existing@gmail.com")).thenReturn(existingUser);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -169,7 +169,7 @@ void shouldHandleGoogleUserWithMissingFields() {
.withoutAttribute("family_name")
.build();
- when(userRepository.findByEmail("nolastname@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("nolastname@gmail.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -191,14 +191,14 @@ void shouldConvertEmailToLowercase() {
.withEmail("John.Doe@GMAIL.COM")
.build();
- when(userRepository.findByEmail("john.doe@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("john.doe@gmail.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
service.handleOAuthLoginSuccess("google", googleUser);
// Then
- verify(userRepository).findByEmail("john.doe@gmail.com"); // Lowercase lookup
+ verify(userRepository).findWithRolesByEmail("john.doe@gmail.com"); // Lowercase lookup
}
}
@@ -215,7 +215,7 @@ void shouldAcceptWhenEmailVerifiedBooleanTrue() {
.withAttribute("email_verified", Boolean.TRUE)
.build();
- when(userRepository.findByEmail("verified@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("verified@gmail.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -235,7 +235,7 @@ void shouldAcceptWhenEmailVerifiedStringTrue() {
.withAttribute("email_verified", "true")
.build();
- when(userRepository.findByEmail("verified-str@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("verified-str@gmail.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -255,7 +255,7 @@ void shouldAcceptWhenEmailVerifiedAbsent() {
.withoutAttribute("email_verified")
.build();
- when(userRepository.findByEmail("noclaim@gmail.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("noclaim@gmail.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -314,7 +314,7 @@ void shouldCreateNewUserFromFacebookOAuth2() {
.withFullName("Jane Marie Smith")
.build();
- when(userRepository.findByEmail("jane.smith@facebook.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("jane.smith@facebook.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -363,7 +363,7 @@ void shouldHandleFacebookUserWithoutName() {
.withoutAttribute("name")
.build();
- when(userRepository.findByEmail("noname@facebook.com")).thenReturn(null);
+ when(userRepository.findWithRolesByEmail("noname@facebook.com")).thenReturn(null);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -393,7 +393,7 @@ void shouldRejectGoogleLoginForFacebookUser() {
existingUser.setEmail("conflict@example.com");
existingUser.setProvider(User.Provider.FACEBOOK);
- when(userRepository.findByEmail("conflict@example.com")).thenReturn(existingUser);
+ when(userRepository.findWithRolesByEmail("conflict@example.com")).thenReturn(existingUser);
// When/Then
assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser))
@@ -413,7 +413,7 @@ void shouldRejectOAuth2LoginForLocalUser() {
existingUser.setEmail("local@example.com");
existingUser.setProvider(User.Provider.LOCAL);
- when(userRepository.findByEmail("local@example.com")).thenReturn(existingUser);
+ when(userRepository.findWithRolesByEmail("local@example.com")).thenReturn(existingUser);
// When/Then
assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser))
@@ -434,7 +434,7 @@ void shouldAllowSameProviderReAuthentication() {
existingUser.setProvider(User.Provider.GOOGLE);
existingUser.setFirstName("Existing");
- when(userRepository.findByEmail("same@gmail.com")).thenReturn(existingUser);
+ when(userRepository.findWithRolesByEmail("same@gmail.com")).thenReturn(existingUser);
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When - Should not throw exception
@@ -525,7 +525,7 @@ void shouldLoadUserThroughOAuth2RequestFlow() {
DSUserDetails mockUserDetails = mock(DSUserDetails.class);
when(loginHelperService.userLoginHelper(any(User.class), ArgumentMatchers.