diff --git a/CHANGELOG.md b/CHANGELOG.md index 62987a9..43403f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [Unreleased] +### Features +- HTMX-aware AuthenticationEntryPoint for session expiry handling (#294) + - When HTMX requests (identified by `HX-Request: true` header) hit an expired session, the framework now returns a 401 JSON response with an `HX-Redirect` header instead of the default 302 redirect that causes HTMX to swap login page HTML into fragment targets. + - New classes: + - `HtmxAwareAuthenticationEntryPoint` — detects HTMX requests and returns 401 + JSON + `HX-Redirect`; delegates to wrapped entry point for standard browser requests + - `HtmxAwareAuthenticationEntryPointConfiguration` — registers the entry point via `@ConditionalOnMissingBean(AuthenticationEntryPoint.class)` + - `WebSecurityConfig` now always configures `exceptionHandling()` with the injected entry point (previously only configured when OAuth2 was enabled) + - Consumer override: define any `AuthenticationEntryPoint` bean to replace the default + - 100% backward-compatible: non-HTMX browser requests get the same 302 redirect as before + ## [4.3.1] - 2026-03-22 ### Features - No new user-facing features in this release. diff --git a/CLAUDE.md b/CLAUDE.md index bd1ed74..572c861 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,12 +111,14 @@ com.digitalsanctuary.spring.user - `DSUserDetails` - Custom UserDetails implementation wrapping User entity - `DSOAuth2UserService` / `DSOidcUserService` - OAuth2/OIDC user services - `LoginAttemptService` - Brute force protection with account lockout +- `HtmxAwareAuthenticationEntryPoint` - Returns 401 JSON for HTMX requests instead of 302 redirect on session expiry **Extension Points:** - `BaseUserProfile` - Extend for custom user data (see PROFILE.md) - `UserProfileService` - Interface for profile management - `BaseSessionProfile` - Session-scoped profile access - `UserPreDeleteEvent` - Listen for user deletion to clean up related data +- `AuthenticationEntryPoint` - Override via `@ConditionalOnMissingBean` to customize session expiry behavior ### Auto-Configuration diff --git a/README.md b/README.md index 2ea87bb..663918f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond - [Role-Based Access Control](#role-based-access-control) - [Account Lockout](#account-lockout) - [Audit Logging](#audit-logging) + - [HTMX Support](#htmx-support) - [User Management](#user-management) - [Registration](#registration) - [Profile Management](#profile-management) @@ -83,6 +84,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond - Audit event framework for recording and logging security events, customizable to store audit events in a database or publish them via a REST API. - Role and Privilege setup service to define roles, associated privileges, and role inheritance hierarchy using `application.yml`. - Configurable Account Lockout after too many failed login attempts + - HTMX-aware session expiry handling — returns 401 JSON instead of 302 redirect for HTMX requests, preventing broken UI fragments - **Advanced Security** - Role and privilege-based authorization @@ -504,6 +506,29 @@ user: flushRate: 10000 ``` +### HTMX Support + +When HTMX-powered pages make requests (polling, fragment loading, etc.) and the user's session expires, Spring Security's default 302 redirect causes HTMX to swap the full login page HTML into each target element, breaking the UI. + +The framework automatically detects HTMX requests (via the `HX-Request` header) and returns a proper 401 response instead: + +- **Status**: `401 Unauthorized` +- **Header**: `HX-Redirect: ` (triggers HTMX full-page redirect) +- **Body**: `{"error": "authentication_required", "message": "Session expired. Please log in.", "loginUrl": ""}` + +Non-HTMX browser requests continue to receive the standard 302 redirect to the login page. + +**Overriding the default behavior:** + +To provide a custom `AuthenticationEntryPoint`, define your own bean and the framework's default will back off automatically: + +```java +@Bean +public AuthenticationEntryPoint authenticationEntryPoint() { + return new MyCustomAuthenticationEntryPoint(); +} +``` + ## User Management ### Registration diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPoint.java b/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPoint.java new file mode 100644 index 0000000..7563f0c --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPoint.java @@ -0,0 +1,86 @@ +package com.digitalsanctuary.spring.user.security; + +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * An {@link AuthenticationEntryPoint} that detects HTMX requests and returns a JSON 401 response instead of the + * default 302 redirect to the login page. + * + *

When an HTMX-powered page has polling or dynamic fragment requests and the user's session expires, Spring + * Security's default entry point sends a 302 redirect to the login page. HTMX transparently follows the redirect and + * swaps the full login page HTML into each target element, breaking the UI. This entry point intercepts HTMX requests + * (identified by the {@code HX-Request} header) and returns a 401 status with a JSON body and an {@code HX-Redirect} + * header, allowing the HTMX client to handle the session expiry gracefully.

+ * + *

Non-HTMX requests are delegated to the wrapped {@link AuthenticationEntryPoint}, preserving the existing + * redirect behavior for standard browser requests.

+ * + * @see HTMX HX-Request Header + * @see HTMX HX-Redirect Response Header + */ +@Slf4j +public class HtmxAwareAuthenticationEntryPoint implements AuthenticationEntryPoint { + + static final String HX_REQUEST_HEADER = "HX-Request"; + static final String HX_REDIRECT_HEADER = "HX-Redirect"; + + private final AuthenticationEntryPoint delegate; + private final String loginUrl; + + /** + * Creates a new HTMX-aware authentication entry point. + * + * @param delegate the entry point to delegate to for non-HTMX requests + * @param loginUrl the login page URL used in the JSON response and HX-Redirect header + */ + public HtmxAwareAuthenticationEntryPoint(AuthenticationEntryPoint delegate, String loginUrl) { + this.delegate = delegate; + this.loginUrl = loginUrl; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + if (isHtmxRequest(request)) { + log.debug("HTMX request detected for URI {}; returning 401 with JSON body and HX-Redirect to {}", + request.getRequestURI(), loginUrl); + + if (response.isCommitted()) { + log.warn("Response already committed for HTMX request to {}; cannot write 401 response", + request.getRequestURI()); + return; + } + + // Prepend the servlet context path so deployments with server.servlet.context-path work correctly. + // LoginUrlAuthenticationEntryPoint does the same when building its redirect URL. + String contextPath = request.getContextPath(); + String fullLoginUrl = contextPath + loginUrl; + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json;charset=UTF-8"); + response.setHeader(HX_REDIRECT_HEADER, fullLoginUrl); + String escapedLoginUrl = fullLoginUrl + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + response.getWriter().write("{\"error\":\"authentication_required\"," + + "\"message\":\"Session expired. Please log in.\"," + + "\"loginUrl\":\"" + escapedLoginUrl + "\"}"); + } else { + delegate.commence(request, response, authException); + } + } + + private boolean isHtmxRequest(HttpServletRequest request) { + return "true".equalsIgnoreCase(request.getHeader(HX_REQUEST_HEADER)); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java new file mode 100644 index 0000000..53b7edb --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java @@ -0,0 +1,59 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import lombok.extern.slf4j.Slf4j; + +/** + * Auto-configuration for the {@link AuthenticationEntryPoint}. + * + *

Registers an {@link HtmxAwareAuthenticationEntryPoint} that wraps the appropriate inner entry point + * (form-login or OAuth2) when no custom {@link AuthenticationEntryPoint} bean is defined by the consuming + * application.

+ * + *

Consuming applications can override this by defining their own {@link AuthenticationEntryPoint} bean:

+ *
{@code
+ * @Bean
+ * public AuthenticationEntryPoint myCustomEntryPoint() {
+ *     return new MyCustomAuthenticationEntryPoint();
+ * }
+ * }
+ */ +@Slf4j +@Configuration +public class HtmxAwareAuthenticationEntryPointConfiguration { + + @Value("${user.security.loginPageURI}") + private String loginPageURI; + + @Value("${spring.security.oauth2.enabled:false}") + private boolean oauth2Enabled; + + /** + * Creates the default {@link AuthenticationEntryPoint} bean. This bean is only registered when no custom + * {@link AuthenticationEntryPoint} bean is provided by the consuming application. + * + * @return an {@link HtmxAwareAuthenticationEntryPoint} wrapping the appropriate inner entry point + */ + @Bean + @Primary + @ConditionalOnMissingBean(AuthenticationEntryPoint.class) + public AuthenticationEntryPoint authenticationEntryPoint() { + AuthenticationEntryPoint inner; + if (oauth2Enabled) { + // null failureHandler is intentional: OAuth2AuthenticationExceptions without a handler fall through + // to the redirect path in CustomOAuth2AuthenticationEntryPoint, which is the desired behavior. + inner = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI); + log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint"); + } else { + inner = new LoginUrlAuthenticationEntryPoint(loginPageURI); + log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint"); + } + return new HtmxAwareAuthenticationEntryPoint(inner, loginPageURI); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index fce7aa4..5f5b52a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -27,6 +27,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; @@ -122,6 +123,7 @@ public class WebSecurityConfig { @Value("${user.dev.auto-login-enabled:false}") private boolean devAutoLoginEnabled; + private final AuthenticationEntryPoint authenticationEntryPoint; private final UserDetailsService userDetailsService; private final LoginSuccessService loginSuccessService; private final LogoutSuccessService logoutSuccessService; @@ -150,6 +152,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.formLogin( formLogin -> formLogin.loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).permitAll()); + // Always configure exception handling with the injected entry point (HTMX-aware by default) + http.exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint)); + // Configure remember-me only if explicitly enabled and key is provided if (rememberMeEnabled && rememberMeKey != null && !rememberMeKey.trim().isEmpty()) { http.rememberMe(rememberMe -> rememberMe.key(rememberMeKey).userDetailsService(userDetailsService)); @@ -211,10 +216,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti * @throws Exception the exception */ private void setupOAuth2(HttpSecurity http) throws Exception { - CustomOAuth2AuthenticationEntryPoint loginAuthenticationEntryPoint = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI); - - http.exceptionHandling(handling -> handling.authenticationEntryPoint(loginAuthenticationEntryPoint)) - .oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> { + // Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean + http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> { log.error("WebSecurityConfig.configure: OAuth2 login failure: {}", exception.getMessage()); request.getSession().setAttribute("error.message", exception.getMessage()); response.sendRedirect(loginPageURI); diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java new file mode 100644 index 0000000..87e62e1 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java @@ -0,0 +1,94 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +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.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +@DisplayName("HtmxAwareAuthenticationEntryPointConfiguration Tests") +class HtmxAwareAuthenticationEntryPointConfigurationTest { + + // Register as auto-configuration so it is processed after user-defined beans, + // which is required for @ConditionalOnMissingBean to evaluate correctly. + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HtmxAwareAuthenticationEntryPointConfiguration.class)) + .withPropertyValues( + "user.security.loginPageURI=/user/login.html" + ); + + @Nested + @DisplayName("Non-OAuth2 Configuration") + class NonOAuth2Configuration { + + @Test + @DisplayName("Should register HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint when OAuth2 disabled") + void shouldRegisterHtmxEntryPointWrappingLoginUrlWhenOAuth2Disabled() { + contextRunner + .withPropertyValues("spring.security.oauth2.enabled=false") + .run(context -> { + assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); + assertThat(context.getBean(AuthenticationEntryPoint.class)) + .isInstanceOf(HtmxAwareAuthenticationEntryPoint.class); + }); + } + + @Test + @DisplayName("Should register HtmxAwareAuthenticationEntryPoint when OAuth2 property absent") + void shouldRegisterHtmxEntryPointWhenOAuth2PropertyAbsent() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); + assertThat(context.getBean(AuthenticationEntryPoint.class)) + .isInstanceOf(HtmxAwareAuthenticationEntryPoint.class); + }); + } + } + + @Nested + @DisplayName("OAuth2 Configuration") + class OAuth2Configuration { + + @Test + @DisplayName("Should register HtmxAwareAuthenticationEntryPoint when OAuth2 enabled") + void shouldRegisterHtmxEntryPointWhenOAuth2Enabled() { + contextRunner + .withPropertyValues("spring.security.oauth2.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); + assertThat(context.getBean(AuthenticationEntryPoint.class)) + .isInstanceOf(HtmxAwareAuthenticationEntryPoint.class); + }); + } + } + + @Nested + @DisplayName("Consumer Override via @ConditionalOnMissingBean") + class ConsumerOverride { + + @Test + @DisplayName("Should not register library bean when consumer provides an AuthenticationEntryPoint") + void shouldNotRegisterLibraryBeanWhenConsumerProvidesEntryPoint() { + contextRunner + .withUserConfiguration(ConsumerEntryPointConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); + assertThat(context.getBean(AuthenticationEntryPoint.class)) + .isInstanceOf(LoginUrlAuthenticationEntryPoint.class); + }); + } + } + + @Configuration + static class ConsumerEntryPointConfiguration { + @Bean + public AuthenticationEntryPoint consumerEntryPoint() { + return new LoginUrlAuthenticationEntryPoint("/custom/login"); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointTest.java new file mode 100644 index 0000000..7a77a87 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointTest.java @@ -0,0 +1,328 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HtmxAwareAuthenticationEntryPoint Tests") +class HtmxAwareAuthenticationEntryPointTest { + + @Mock + private AuthenticationEntryPoint delegate; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + private static final String LOGIN_URL = "/user/login"; + private HtmxAwareAuthenticationEntryPoint entryPoint; + + @BeforeEach + void setUp() { + entryPoint = new HtmxAwareAuthenticationEntryPoint(delegate, LOGIN_URL); + } + + @Nested + @DisplayName("HTMX Request Handling") + class HtmxRequestHandling { + + private StringWriter responseBody; + + @BeforeEach + void setUp() throws IOException { + when(request.getHeader("HX-Request")).thenReturn("true"); + when(request.getContextPath()).thenReturn(""); + responseBody = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(responseBody)); + } + + @Test + @DisplayName("Should return 401 when HTMX request received") + void shouldReturn401WhenHtmxRequestReceived() throws IOException, ServletException { + // Given + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + + @Test + @DisplayName("Should set JSON content type with UTF-8 charset when HTMX request received") + void shouldSetJsonContentTypeWhenHtmxRequestReceived() throws IOException, ServletException { + // Given + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response).setCharacterEncoding("UTF-8"); + verify(response).setContentType("application/json;charset=UTF-8"); + } + + @Test + @DisplayName("Should set HX-Redirect header to login URL when HTMX request received") + void shouldSetHxRedirectHeaderWhenHtmxRequestReceived() throws IOException, ServletException { + // Given + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response).setHeader("HX-Redirect", LOGIN_URL); + } + + @Test + @DisplayName("Should write JSON body with error details when HTMX request received") + void shouldWriteJsonBodyWhenHtmxRequestReceived() throws IOException, ServletException { + // Given + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + String body = responseBody.toString(); + assertThat(body).contains("\"error\":\"authentication_required\""); + assertThat(body).contains("\"message\":\"Session expired. Please log in.\""); + assertThat(body).contains("\"loginUrl\":\"" + LOGIN_URL + "\""); + } + + @Test + @DisplayName("Should not call delegate when HTMX request received") + void shouldNotCallDelegateWhenHtmxRequestReceived() throws IOException, ServletException { + // Given + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verifyNoInteractions(delegate); + } + + @Test + @DisplayName("Should handle HX-Request header case insensitively") + void shouldHandleHxRequestHeaderCaseInsensitively() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn("TRUE"); + when(request.getContextPath()).thenReturn(""); + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verifyNoInteractions(delegate); + } + } + + @Nested + @DisplayName("Servlet Context Path Handling") + class ServletContextPathHandling { + + private StringWriter responseBody; + + @BeforeEach + void setUp() throws IOException { + when(request.getHeader("HX-Request")).thenReturn("true"); + responseBody = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(responseBody)); + } + + @Test + @DisplayName("Should prepend context path to HX-Redirect header when context path is non-empty") + void shouldPrependContextPathToHxRedirectWhenContextPathIsNonEmpty() throws IOException, ServletException { + // Given + when(request.getContextPath()).thenReturn("/app"); + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response).setHeader("HX-Redirect", "/app" + LOGIN_URL); + } + + @Test + @DisplayName("Should include context path in JSON loginUrl when context path is non-empty") + void shouldIncludeContextPathInJsonLoginUrlWhenContextPathIsNonEmpty() throws IOException, ServletException { + // Given + when(request.getContextPath()).thenReturn("/app"); + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + assertThat(responseBody.toString()).contains("\"loginUrl\":\"/app" + LOGIN_URL + "\""); + } + + @Test + @DisplayName("Should use login URL as-is when context path is empty") + void shouldUseLoginUrlAsIsWhenContextPathIsEmpty() throws IOException, ServletException { + // Given + when(request.getContextPath()).thenReturn(""); + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response).setHeader("HX-Redirect", LOGIN_URL); + assertThat(responseBody.toString()).contains("\"loginUrl\":\"" + LOGIN_URL + "\""); + } + } + + @Nested + @DisplayName("Non-HTMX Request Handling") + class NonHtmxRequestHandling { + + @Test + @DisplayName("Should delegate when HX-Request header is absent") + void shouldDelegateWhenHxRequestHeaderAbsent() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn(null); + AuthenticationException authException = new BadCredentialsException("Invalid credentials"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(delegate).commence(request, response, authException); + } + + @Test + @DisplayName("Should delegate when HX-Request header is false") + void shouldDelegateWhenHxRequestHeaderIsFalse() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn("false"); + AuthenticationException authException = new BadCredentialsException("Invalid credentials"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(delegate).commence(request, response, authException); + } + + @Test + @DisplayName("Should preserve authentication exception when delegating") + void shouldPreserveAuthenticationExceptionWhenDelegating() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn(null); + InsufficientAuthenticationException authException = + new InsufficientAuthenticationException("Full authentication required"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(delegate).commence(request, response, authException); + } + + @Test + @DisplayName("Should not modify response when delegating") + void shouldNotModifyResponseWhenDelegating() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn(null); + AuthenticationException authException = new BadCredentialsException("Invalid"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response, never()).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response, never()).setContentType(any()); + verify(response, never()).setHeader(any(), any()); + } + } + + @Nested + @DisplayName("Delegate Exception Propagation") + class DelegateExceptionPropagation { + + @Test + @DisplayName("Should propagate IOException from delegate") + void shouldPropagateIOExceptionFromDelegate() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn(null); + AuthenticationException authException = new BadCredentialsException("Invalid"); + doThrow(new IOException("Network error")).when(delegate).commence(any(), any(), any()); + + // When/Then + assertThatThrownBy(() -> entryPoint.commence(request, response, authException)) + .isInstanceOf(IOException.class) + .hasMessage("Network error"); + } + + @Test + @DisplayName("Should propagate ServletException from delegate") + void shouldPropagateServletExceptionFromDelegate() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn(null); + AuthenticationException authException = new BadCredentialsException("Invalid"); + doThrow(new ServletException("Servlet error")).when(delegate).commence(any(), any(), any()); + + // When/Then + assertThatThrownBy(() -> entryPoint.commence(request, response, authException)) + .isInstanceOf(ServletException.class) + .hasMessage("Servlet error"); + } + } + + @Nested + @DisplayName("Response Committed Handling") + class ResponseCommittedHandling { + + @Test + @DisplayName("Should not write response when already committed") + void shouldNotWriteResponseWhenAlreadyCommitted() throws IOException, ServletException { + // Given + when(request.getHeader("HX-Request")).thenReturn("true"); + when(response.isCommitted()).thenReturn(true); + AuthenticationException authException = new InsufficientAuthenticationException("Session expired"); + + // When + entryPoint.commence(request, response, authException); + + // Then + verify(response, never()).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response, never()).getWriter(); + verifyNoInteractions(delegate); + } + } +}