From 0fa330a357cc96233edb49ec7f2d64d259be65ce Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 19:50:22 +0200 Subject: [PATCH] test(auth): integration tests for full session lifecycle and idle-timeout Also switches pom.xml to spring-boot-starter-session-jdbc (Spring Boot 4.x split the session auto-config into a separate starter; spring-session-jdbc alone does not register JdbcSessionAutoConfiguration). Adds SpringSessionConfig#cookieSerializer bean to configure fa_session name and SameSite=Strict (spring.session.cookie.* properties are no longer supported by the Boot 4.x auto-configuration layer). Cleans up application.yaml / application-dev.yaml: removes store-type: jdbc and the unsupported cookie.* keys. Co-Authored-By: Claude Sonnet 4.6 --- backend/pom.xml | 5 +- .../config/SpringSessionConfig.java | 22 +++ .../src/main/resources/application-dev.yaml | 8 +- backend/src/main/resources/application.yaml | 9 +- .../auth/AuthSessionIntegrationTest.java | 154 ++++++++++++++++++ 5 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java diff --git a/backend/pom.xml b/backend/pom.xml index f1a144d6..6e9b389b 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -70,9 +70,8 @@ spring-boot-starter-security - org.springframework.session - spring-session-jdbc - 4.0.3 + org.springframework.boot + spring-boot-starter-session-jdbc org.springframework.boot diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java new file mode 100644 index 00000000..415903cd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/SpringSessionConfig.java @@ -0,0 +1,22 @@ +package org.raddatz.familienarchiv.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.web.http.CookieSerializer; +import org.springframework.session.web.http.DefaultCookieSerializer; + +@Configuration +public class SpringSessionConfig { + + @Bean + public CookieSerializer cookieSerializer() { + DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setCookieName("fa_session"); + serializer.setSameSite("Strict"); + // cookieHttpOnly: true is the DefaultCookieSerializer default + // useSecureCookie not set: auto-detects from request.isSecure(). + // With forward-headers-strategy: native, Caddy's X-Forwarded-Proto: https + // causes isSecure() → true in production; direct HTTP in dev/tests → false. + return serializer; + } +} diff --git a/backend/src/main/resources/application-dev.yaml b/backend/src/main/resources/application-dev.yaml index dd6c521d..54e4a972 100644 --- a/backend/src/main/resources/application-dev.yaml +++ b/backend/src/main/resources/application-dev.yaml @@ -1,11 +1,9 @@ spring: jpa: show-sql: true - session: - cookie: - # Dev runs over HTTP (port 5173 → 8080); Secure=true would prevent the - # cookie from being sent on plain HTTP. Override to false for local dev only. - secure: false + # spring.session.cookie.secure is no longer a supported Boot 4.x property. + # DefaultCookieSerializer auto-detects Secure from request.isSecure(). + # Direct HTTP in dev → isSecure()=false → cookie sent without Secure attribute. springdoc: api-docs: diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index f0df329b..2a764e8e 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -39,16 +39,11 @@ spring: enable: true session: - store-type: jdbc timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds) jdbc: initialize-schema: never # Flyway owns schema creation (V67) - cookie: - name: fa_session - same-site: strict - http-only: true - # secure: true is the default when forward-headers-strategy detects HTTPS behind Caddy. - # application-dev.yaml overrides this to false for local HTTP dev. + # Cookie name, SameSite, and Secure are configured via SpringSessionConfig#cookieSerializer + # (spring.session.cookie.* is not supported in Spring Boot 4.x). server: # Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java new file mode 100644 index 00000000..92ff991e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java @@ -0,0 +1,154 @@ +package org.raddatz.familienarchiv.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.AppUserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestTemplate; +import software.amazon.awssdk.services.s3.S3Client; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class AuthSessionIntegrationTest { + + @LocalServerPort int port; + @MockitoBean S3Client s3Client; + @Autowired AppUserRepository userRepository; + @Autowired PasswordEncoder passwordEncoder; + @Autowired JdbcTemplate jdbcTemplate; + + private RestTemplate http; + private String baseUrl; + + private static final String TEST_EMAIL = "session-it@test.de"; + private static final String TEST_PASSWORD = "pass4Session!"; + + @BeforeEach + void setUp() { + http = noThrowRestTemplate(); + baseUrl = "http://localhost:" + port; + // spring_session_attributes cascades on delete — removing the parent row is enough + jdbcTemplate.update("DELETE FROM spring_session"); + jdbcTemplate.update("DELETE FROM app_users WHERE email = ?", TEST_EMAIL); + userRepository.save(AppUser.builder() + .email(TEST_EMAIL) + .password(passwordEncoder.encode(TEST_PASSWORD)) + .build()); + } + + // ─── Task 13: full session lifecycle ────────────────────────────────────── + + @Test + void login_sets_opaque_fa_session_cookie() { + ResponseEntity response = doLogin(); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + String cookie = extractFaSessionCookie(response); + assertThat(cookie).isNotBlank(); + // Opaque token — must not look like Basic-auth credentials (email:password) + assertThat(cookie).doesNotContain(":"); + } + + @Test + void session_cookie_authenticates_subsequent_request() { + String cookie = extractFaSessionCookie(doLogin()); + + ResponseEntity me = http.exchange( + baseUrl + "/api/users/me", HttpMethod.GET, + new HttpEntity<>(cookieHeaders(cookie)), String.class); + + assertThat(me.getStatusCode().value()).isEqualTo(200); + } + + @Test + void logout_invalidates_session_and_cookie_returns_401_on_reuse() { + String cookie = extractFaSessionCookie(doLogin()); + + ResponseEntity logout = http.postForEntity( + baseUrl + "/api/auth/logout", + new HttpEntity<>(cookieHeaders(cookie)), Void.class); + assertThat(logout.getStatusCode().value()).isEqualTo(204); + + ResponseEntity me = http.exchange( + baseUrl + "/api/users/me", HttpMethod.GET, + new HttpEntity<>(cookieHeaders(cookie)), String.class); + assertThat(me.getStatusCode().value()).isEqualTo(401); + } + + // ─── Task 14: idle-timeout ──────────────────────────────────────────────── + + @Test + void session_expired_by_idle_timeout_returns_401() { + String cookie = extractFaSessionCookie(doLogin()); + + // Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now + long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000; + jdbcTemplate.update( + "UPDATE spring_session SET LAST_ACCESS_TIME = ?, EXPIRY_TIME = ?", + nineHoursAgoMs, nineHoursAgoMs); + + ResponseEntity me = http.exchange( + baseUrl + "/api/users/me", HttpMethod.GET, + new HttpEntity<>(cookieHeaders(cookie)), String.class); + assertThat(me.getStatusCode().value()).isEqualTo(401); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private ResponseEntity doLogin() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}"; + return http.postForEntity(baseUrl + "/api/auth/login", + new HttpEntity<>(body, headers), String.class); + } + + private HttpHeaders cookieHeaders(String sessionId) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Cookie", "fa_session=" + sessionId); + return headers; + } + + private String extractFaSessionCookie(ResponseEntity response) { + List setCookieHeader = response.getHeaders().get("Set-Cookie"); + if (setCookieHeader == null) return ""; + return setCookieHeader.stream() + .filter(c -> c.startsWith("fa_session=")) + .map(c -> c.split(";")[0].substring("fa_session=".length())) + .findFirst() + .orElse(""); + } + + private RestTemplate noThrowRestTemplate() { + RestTemplate template = new RestTemplate(); + template.setErrorHandler(new DefaultResponseErrorHandler() { + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return false; + } + }); + return template; + } +}