From 9b4da70f52c8d1ae6fe69cd7451a0a8c3b20a78d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:21:15 +0200 Subject: [PATCH 01/26] feat(security): enable CSRF protection with CookieCsrfTokenRepository Re-enables Spring Security's CSRF filter (was disabled with a TODO comment). Uses CookieCsrfTokenRepository so the frontend can read the XSRF-TOKEN cookie and send it as X-XSRF-TOKEN on state-mutating requests. Returns CSRF_TOKEN_MISSING error code on 403 instead of generic FORBIDDEN. Updates all WebMvcTest classes to include .with(csrf()) on POST/PUT/PATCH/ DELETE/multipart requests, and fixes integration tests to supply the XSRF-TOKEN cookie + header directly (lazy generation in Spring Security 7). Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/audit/AuditKind.java | 10 +- .../exception/DomainException.java | 4 + .../familienarchiv/exception/ErrorCode.java | 4 + .../security/SecurityConfig.java | 39 +++++--- .../auth/AuthSessionControllerTest.java | 32 ++++++- .../auth/AuthSessionIntegrationTest.java | 37 ++++++-- .../document/DocumentControllerTest.java | 93 ++++++++++--------- .../annotation/AnnotationControllerTest.java | 45 ++++----- .../comment/CommentControllerTest.java | 29 +++--- .../TranscriptionBlockControllerTest.java | 55 +++++------ .../geschichte/GeschichteControllerTest.java | 15 +-- .../NotificationControllerTest.java | 15 +-- .../familienarchiv/ocr/OcrControllerTest.java | 29 +++--- .../person/PersonControllerTest.java | 61 ++++++------ .../RelationshipControllerTest.java | 13 +-- .../familienarchiv/tag/TagControllerTest.java | 29 +++--- .../user/AdminControllerTest.java | 19 ++-- .../user/AuthControllerTest.java | 11 ++- .../user/InviteControllerTest.java | 15 +-- .../user/UserControllerTest.java | 19 ++-- 20 files changed, 330 insertions(+), 244 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java index 3ceb8f39..62f04874 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -43,8 +43,14 @@ public enum AuditKind { /** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */ LOGIN_FAILED, - /** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */ - LOGOUT; + /** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */ + LOGOUT, + + /** Payload: {@code {"actorId": "uuid", "targetUserId": "uuid", "revokedCount": 3}} */ + ADMIN_FORCE_LOGOUT, + + /** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */ + LOGIN_RATE_LIMITED; public static final Set ROLLUP_ELIGIBLE = Set.of( TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java index b911059e..c65ad5b8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/DomainException.java @@ -55,4 +55,8 @@ public class DomainException extends RuntimeException { public static DomainException internal(ErrorCode code, String message) { return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message); } + + public static DomainException tooManyRequests(ErrorCode code, String message) { + return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 7489bb83..54802f86 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -68,6 +68,10 @@ public enum ErrorCode { SESSION_EXPIRED, /** The password-reset token is missing, expired, or already used. 400 */ INVALID_RESET_TOKEN, + /** CSRF token is missing or does not match the expected value. 403 */ + CSRF_TOKEN_MISSING, + /** The login rate limit has been exceeded for this IP/email combination. 429 */ + TOO_MANY_LOGIN_ATTEMPTS, // --- Annotations --- /** The annotation with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java index 80747a8f..1a5b904f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityConfig.java @@ -1,7 +1,9 @@ package org.raddatz.familienarchiv.security; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.user.CustomUserDetailsService; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; @@ -19,6 +21,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfException; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; + +import java.util.Map; @Configuration @EnableWebSecurity @@ -78,15 +85,13 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - // CSRF is intentionally disabled. The session model relies on: - // 1. SameSite=Strict on the fa_session cookie — a cross-site POST from - // evil.com cannot include the cookie. - // 2. CORS — Spring's default rejects cross-origin requests with credentials - // unless explicitly allowed (no allowedOrigins config). - // - // If either of those is ever weakened, CSRF protection MUST be re-enabled. - // Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524). - .csrf(csrf -> csrf.disable()) + // CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103). + // The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it). + // All state-changing requests must include X-XSRF-TOKEN matching the cookie. + // See ADR-020 and issue #524 for the full security rationale. + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) .authorizeHttpRequests(auth -> { // Actuator endpoints are governed by managementFilterChain (@Order(1)) above. @@ -112,10 +117,18 @@ public class SecurityConfig { // erlaubt pdf im Iframe .headers(headers -> headers .frameOptions(frameOptions -> frameOptions.sameOrigin())) - // Return 401 (not 302 redirect to /login) for unauthenticated API requests. - // httpBasic and formLogin are removed — authentication is via Spring Session only. - .exceptionHandling(ex -> ex.authenticationEntryPoint( - (req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))); + // Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures. + .exceptionHandling(ex -> ex + .authenticationEntryPoint( + (req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)) + .accessDeniedHandler((req, res, e) -> { + res.setStatus(HttpServletResponse.SC_FORBIDDEN); + res.setContentType("application/json;charset=UTF-8"); + ErrorCode code = (e instanceof CsrfException) + ? ErrorCode.CSRF_TOKEN_MISSING + : ErrorCode.FORBIDDEN; + res.getWriter().write(new ObjectMapper().writeValueAsString(Map.of("code", code.name()))); + })); return http.build(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java index 7ace8c45..e7860af0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionControllerTest.java @@ -23,6 +23,7 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -48,6 +49,7 @@ class AuthSessionControllerTest { .thenReturn(new LoginResult(appUser, auth)); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}")) .andExpect(status().isOk()) @@ -61,6 +63,7 @@ class AuthSessionControllerTest { .thenThrow(DomainException.invalidCredentials()); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) .andExpect(status().isUnauthorized()) @@ -77,6 +80,7 @@ class AuthSessionControllerTest { // No WithMockUser — must be reachable without an active session mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}")) .andExpect(status().isOk()); @@ -91,6 +95,7 @@ class AuthSessionControllerTest { .thenReturn(new LoginResult(appUser, auth)); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}")) .andExpect(status().isOk()); @@ -116,6 +121,7 @@ class AuthSessionControllerTest { .thenReturn(new LoginResult(appUser, auth)); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}")) .andExpect(status().isOk()) @@ -131,12 +137,24 @@ class AuthSessionControllerTest { .thenThrow(DomainException.invalidCredentials()); mockMvc.perform(post("/api/auth/login") + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) .andExpect(status().isUnauthorized()) .andExpect(header().doesNotExist("Set-Cookie")); } + // ─── CSRF protection ────────────────────────────────────────────────────── + + @Test + void authenticated_post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception { + // Red test: CSRF disabled → returns 204; after re-enabling returns 403. + mockMvc.perform(post("/api/auth/logout") + .with(user("user@test.de"))) // authenticated but no CSRF token + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name())); + } + // ─── POST /api/auth/logout ───────────────────────────────────────────────── @Test @@ -144,15 +162,18 @@ class AuthSessionControllerTest { doNothing().when(authService).logout(anyString(), anyString(), anyString()); mockMvc.perform(post("/api/auth/logout") - .with(user("user@test.de"))) + .with(user("user@test.de")) + .with(csrf())) .andExpect(status().isNoContent()); } @Test - void logout_returns_401_when_not_authenticated() throws Exception { - // No authentication at all — Spring Security must return 401 + void logout_without_session_returns_403() throws Exception { + // CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null, + // ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403. mockMvc.perform(post("/api/auth/logout")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name())); } @Test @@ -163,7 +184,8 @@ class AuthSessionControllerTest { .when(authService).logout(anyString(), anyString(), anyString()); mockMvc.perform(post("/api/auth/logout") - .with(user("ghost@test.de"))) + .with(user("ghost@test.de")) + .with(csrf())) .andExpect(status().isNoContent()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java index 92ff991e..1a8786a4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthSessionIntegrationTest.java @@ -62,7 +62,8 @@ class AuthSessionIntegrationTest { @Test void login_sets_opaque_fa_session_cookie() { - ResponseEntity response = doLogin(); + String xsrf = fetchXsrfToken(); + ResponseEntity response = doLogin(xsrf); assertThat(response.getStatusCode().value()).isEqualTo(200); String cookie = extractFaSessionCookie(response); @@ -73,7 +74,8 @@ class AuthSessionIntegrationTest { @Test void session_cookie_authenticates_subsequent_request() { - String cookie = extractFaSessionCookie(doLogin()); + String xsrf = fetchXsrfToken(); + String cookie = extractFaSessionCookie(doLogin(xsrf)); ResponseEntity me = http.exchange( baseUrl + "/api/users/me", HttpMethod.GET, @@ -84,16 +86,17 @@ class AuthSessionIntegrationTest { @Test void logout_invalidates_session_and_cookie_returns_401_on_reuse() { - String cookie = extractFaSessionCookie(doLogin()); + String xsrf = fetchXsrfToken(); + String sessionCookie = extractFaSessionCookie(doLogin(xsrf)); ResponseEntity logout = http.postForEntity( baseUrl + "/api/auth/logout", - new HttpEntity<>(cookieHeaders(cookie)), Void.class); + new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class); assertThat(logout.getStatusCode().value()).isEqualTo(204); ResponseEntity me = http.exchange( baseUrl + "/api/users/me", HttpMethod.GET, - new HttpEntity<>(cookieHeaders(cookie)), String.class); + new HttpEntity<>(cookieHeaders(sessionCookie)), String.class); assertThat(me.getStatusCode().value()).isEqualTo(401); } @@ -101,7 +104,8 @@ class AuthSessionIntegrationTest { @Test void session_expired_by_idle_timeout_returns_401() { - String cookie = extractFaSessionCookie(doLogin()); + String xsrf = fetchXsrfToken(); + String cookie = extractFaSessionCookie(doLogin(xsrf)); // Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000; @@ -117,9 +121,20 @@ class AuthSessionIntegrationTest { // ─── helpers ───────────────────────────────────────────────────────────── - private ResponseEntity doLogin() { + /** + * Generates an XSRF token for use in integration tests. + * CookieCsrfTokenRepository validates that Cookie: XSRF-TOKEN=X matches X-XSRF-TOKEN: X. + * By supplying both with the same value we simulate exactly what a browser does. + */ + private String fetchXsrfToken() { + return java.util.UUID.randomUUID().toString(); + } + + private ResponseEntity doLogin(String xsrfToken) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Cookie", "XSRF-TOKEN=" + xsrfToken); + headers.set("X-XSRF-TOKEN", xsrfToken); String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}"; return http.postForEntity(baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class); @@ -131,6 +146,13 @@ class AuthSessionIntegrationTest { return headers; } + private HttpHeaders csrfAndSessionHeaders(String sessionId, String xsrfToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrfToken); + headers.set("X-XSRF-TOKEN", xsrfToken); + return headers; + } + private String extractFaSessionCookie(ResponseEntity response) { List setCookieHeader = response.getHeaders().get("Set-Cookie"); if (setCookieHeader == null) return ""; @@ -141,6 +163,7 @@ class AuthSessionIntegrationTest { .orElse(""); } + private RestTemplate noThrowRestTemplate() { RestTemplate template = new RestTemplate(); template.setErrorHandler(new DefaultResponseErrorHandler() { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index 9d85491a..b9f95677 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -48,6 +48,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(DocumentController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -214,14 +215,14 @@ class DocumentControllerTest { @Test void createDocument_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(multipart("/api/documents")) + mockMvc.perform(multipart("/api/documents").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void createDocument_returns403_whenMissingWritePermission() throws Exception { - mockMvc.perform(multipart("/api/documents")) + mockMvc.perform(multipart("/api/documents").with(csrf())) .andExpect(status().isForbidden()); } @@ -235,7 +236,7 @@ class DocumentControllerTest { .build(); when(documentService.createDocument(any(), any())).thenReturn(doc); - mockMvc.perform(multipart("/api/documents")) + mockMvc.perform(multipart("/api/documents").with(csrf())) .andExpect(status().isOk()); } @@ -244,7 +245,7 @@ class DocumentControllerTest { @Test void updateDocument_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) - .with(req -> { req.setMethod("PUT"); return req; })) + .with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .andExpect(status().isUnauthorized()); } @@ -252,7 +253,7 @@ class DocumentControllerTest { @WithMockUser void updateDocument_returns403_whenMissingWritePermission() throws Exception { mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) - .with(req -> { req.setMethod("PUT"); return req; })) + .with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .andExpect(status().isForbidden()); } @@ -269,7 +270,7 @@ class DocumentControllerTest { when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc); mockMvc.perform(multipart("/api/documents/" + id) - .with(req -> { req.setMethod("PUT"); return req; })) + .with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .andExpect(status().isOk()); } @@ -278,7 +279,7 @@ class DocumentControllerTest { @Test void deleteDocument_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .delete("/api/documents/" + UUID.randomUUID())) + .delete("/api/documents/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @@ -286,7 +287,7 @@ class DocumentControllerTest { @WithMockUser void deleteDocument_returns403_whenMissingWritePermission() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .delete("/api/documents/" + UUID.randomUUID())) + .delete("/api/documents/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -295,7 +296,7 @@ class DocumentControllerTest { void deleteDocument_returns204_whenHasWritePermission() throws Exception { UUID id = UUID.randomUUID(); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .delete("/api/documents/" + id)) + .delete("/api/documents/" + id).with(csrf())) .andExpect(status().isNoContent()); } @@ -303,14 +304,14 @@ class DocumentControllerTest { @Test void quickUpload_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(multipart("/api/documents/quick-upload")) + mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void quickUpload_returns403_whenMissingWritePermission() throws Exception { - mockMvc.perform(multipart("/api/documents/quick-upload")) + mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) .andExpect(status().isForbidden()); } @@ -326,7 +327,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created[0].title").value("scan001")) .andExpect(jsonPath("$.updated").isEmpty()) @@ -345,7 +346,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.updated[0].title").value("Alter Brief")) @@ -360,7 +361,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("files", "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.errors[0].filename").value("report.docx")) @@ -490,7 +491,7 @@ class DocumentControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception { - mockMvc.perform(multipart("/api/documents/quick-upload")) + mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.updated").isEmpty()) @@ -640,7 +641,7 @@ class DocumentControllerTest { @Test void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .andExpect(status().isUnauthorized()); @@ -649,7 +650,7 @@ class DocumentControllerTest { @Test @WithMockUser void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .andExpect(status().isForbidden()); @@ -659,7 +660,7 @@ class DocumentControllerTest { @WithMockUser(authorities = "WRITE_ALL") void patchTrainingLabels_returns204_whenAddingLabel() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .andExpect(status().isNoContent()); @@ -671,7 +672,7 @@ class DocumentControllerTest { @WithMockUser(authorities = "WRITE_ALL") void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(patch("/api/documents/" + id + "/training-labels") + mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}")) .andExpect(status().isNoContent()); @@ -682,7 +683,7 @@ class DocumentControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels") + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}")) .andExpect(status().isBadRequest()); @@ -696,7 +697,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file)) + mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf())) .andExpect(status().isForbidden()); } @@ -713,7 +714,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file)) + mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(id.toString())) .andExpect(jsonPath("$.status").value("UPLOADED")); @@ -726,7 +727,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile( "file", "evil.html", "text/html", "".getBytes()); - mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile)) + mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf())) .andExpect(status().isBadRequest()); } @@ -743,7 +744,7 @@ class DocumentControllerTest { org.springframework.mock.web.MockMultipartFile file = new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); - mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file)) + mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf())) .andExpect(status().isNotFound()); } @@ -800,7 +801,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", ("{\"senderId\":\"" + senderId + "\"}").getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created.length()").value(3)) .andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString())) @@ -827,7 +828,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", ("{\"senderId\":\"" + senderId + "\"}").getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString())) @@ -859,7 +860,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", "{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.created[0].title").value("Alpha")) .andExpect(jsonPath("$.created[1].title").value("Beta")) @@ -883,7 +884,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", "{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf())) .andExpect(status().isBadRequest()); } @@ -904,7 +905,7 @@ class DocumentControllerTest { new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", "{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes()); - mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata)) + mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf())) .andExpect(status().isOk()); org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames()) @@ -926,7 +927,7 @@ class DocumentControllerTest { "files", "f" + i + ".pdf", "application/pdf", new byte[]{1})); } - mockMvc.perform(builder) + mockMvc.perform(builder.with(csrf())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE")); } @@ -945,7 +946,7 @@ class DocumentControllerTest { @Test void patchBulk_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(UUID.randomUUID().toString()))) .andExpect(status().isUnauthorized()); @@ -954,7 +955,7 @@ class DocumentControllerTest { @Test @WithMockUser void patchBulk_returns403_forReadAllUser() throws Exception { - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(UUID.randomUUID().toString()))) .andExpect(status().isForbidden()); @@ -965,7 +966,7 @@ class DocumentControllerTest { void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"documentIds\":[]}")) .andExpect(status().isBadRequest()); @@ -976,7 +977,7 @@ class DocumentControllerTest { void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception { when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -990,7 +991,7 @@ class DocumentControllerTest { String[] ids = new String[501]; for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString(); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(ids))) .andExpect(status().isBadRequest()) @@ -1009,7 +1010,7 @@ class DocumentControllerTest { String tooLong = "x".repeat(256); String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}"; - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()); @@ -1025,7 +1026,7 @@ class DocumentControllerTest { String[] ids = new String[500]; for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString(); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(ids))) .andExpect(status().isOk()) @@ -1042,7 +1043,7 @@ class DocumentControllerTest { // Same id sent three times — controller should dedupe and call the // service exactly once, returning updated=1, not 3. - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(id.toString(), id.toString(), id.toString()))) .andExpect(status().isOk()) @@ -1061,7 +1062,7 @@ class DocumentControllerTest { when(documentService.applyBulkEditToDocument(any(), any(), any())) .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build()); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(id1.toString(), id2.toString()))) .andExpect(status().isOk()) @@ -1137,7 +1138,7 @@ class DocumentControllerTest { void batchMetadata_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}")) + .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf())) .andExpect(status().isUnauthorized()); } @@ -1146,7 +1147,7 @@ class DocumentControllerTest { void batchMetadata_returns403_forUserWithoutReadAll() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}")) + .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf())) .andExpect(status().isForbidden()); } @@ -1155,7 +1156,7 @@ class DocumentControllerTest { void batchMetadata_returns400_whenIdsEmpty() throws Exception { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[]}")) + .content("{\"ids\":[]}").with(csrf())) .andExpect(status().isBadRequest()); } @@ -1172,7 +1173,7 @@ class DocumentControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content(sb.toString())) + .content(sb.toString()).with(csrf())) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); } @@ -1187,7 +1188,7 @@ class DocumentControllerTest { mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[\"" + id + "\"]}")) + .content("{\"ids\":[\"" + id + "\"]}").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value(id.toString())) .andExpect(jsonPath("$[0].title").value("Brief")) @@ -1208,7 +1209,7 @@ class DocumentControllerTest { org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "evil\r\nFAKE LOG ENTRY: admin logged in")); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(badId.toString()))) .andExpect(status().isOk()) @@ -1232,7 +1233,7 @@ class DocumentControllerTest { .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound( org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId)); - mockMvc.perform(patch("/api/documents/bulk") + mockMvc.perform(patch("/api/documents/bulk").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(bulkBody(okId.toString(), badId.toString()))) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java index 20cf8a79..2157f6c7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/annotation/AnnotationControllerTest.java @@ -31,6 +31,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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 static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(AnnotationController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -67,7 +68,7 @@ class AnnotationControllerTest { @Test void createAnnotation_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isUnauthorized()); @@ -76,7 +77,7 @@ class AnnotationControllerTest { @Test @WithMockUser void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { - mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isForbidden()); @@ -92,7 +93,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); @@ -101,7 +102,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isNoContent()); } @@ -115,7 +116,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()) @@ -133,7 +134,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); @@ -143,28 +144,28 @@ class AnnotationControllerTest { @Test void deleteAnnotation_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "READ_ALL") void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "ANNOTATE_ALL") void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { - mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isNoContent()); } @@ -174,7 +175,7 @@ class AnnotationControllerTest { @Test void patchAnnotation_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isUnauthorized()); @@ -183,7 +184,7 @@ class AnnotationControllerTest { @Test @WithMockUser void patchAnnotation_returns403_withoutPermission() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isForbidden()); @@ -199,7 +200,7 @@ class AnnotationControllerTest { .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isOk()) @@ -217,7 +218,7 @@ class AnnotationControllerTest { .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isOk()); @@ -229,7 +230,7 @@ class AnnotationControllerTest { when(annotationService.updateAnnotation(any(), any(), any())) .thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found")); - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(PATCH_JSON)) .andExpect(status().isNotFound()); @@ -238,7 +239,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"x\":-0.1,\"y\":0.3}")) .andExpect(status().isBadRequest()); @@ -247,7 +248,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"width\":0.005}")) .andExpect(status().isBadRequest()); @@ -256,7 +257,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"height\":0.005}")) .andExpect(status().isBadRequest()); @@ -265,7 +266,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void patchAnnotation_returns400_withXAboveMaximum() throws Exception { - mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"x\":1.1}")) .andExpect(status().isBadRequest()); @@ -276,7 +277,7 @@ class AnnotationControllerTest { @Test void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception { // authentication == null → resolveUserId returns null - mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isUnauthorized()); @@ -294,7 +295,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); @@ -312,7 +313,7 @@ class AnnotationControllerTest { when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + docId + "/annotations") + mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(ANNOTATION_JSON)) .andExpect(status().isCreated()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java index e4630ae7..473b1a7a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/comment/CommentControllerTest.java @@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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 static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(CommentController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -70,7 +71,7 @@ class CommentControllerTest { .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.blockId").value(blockId.toString())); @@ -79,7 +80,7 @@ class CommentControllerTest { @Test void postBlockComment_returns401_whenUnauthenticated() throws Exception { UUID blockId = UUID.randomUUID(); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isUnauthorized()); } @@ -88,7 +89,7 @@ class CommentControllerTest { @WithMockUser void postBlockComment_returns403_whenMissingPermission() throws Exception { UUID blockId = UUID.randomUUID(); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isForbidden()); } @@ -101,7 +102,7 @@ class CommentControllerTest { .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -116,7 +117,7 @@ class CommentControllerTest { .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build(); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); - mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -127,7 +128,7 @@ class CommentControllerTest { @WithMockUser(authorities = "ANNOTATE_ALL") void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception { mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID" - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isBadRequest()); } @@ -136,7 +137,7 @@ class CommentControllerTest { void replyToBlockComment_returns401_whenUnauthenticated() throws Exception { UUID blockId = UUID.randomUUID(); mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isUnauthorized()); } @@ -151,7 +152,7 @@ class CommentControllerTest { when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -166,7 +167,7 @@ class CommentControllerTest { when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId - + "/comments/" + COMMENT_ID + "/replies") + + "/comments/" + COMMENT_ID + "/replies").with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } @@ -175,7 +176,7 @@ class CommentControllerTest { @Test void editComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isUnauthorized()); } @@ -187,7 +188,7 @@ class CommentControllerTest { .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isOk()); } @@ -199,7 +200,7 @@ class CommentControllerTest { .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isOk()); } @@ -208,14 +209,14 @@ class CommentControllerTest { @Test void deleteComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteComment_returns204_whenAuthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())) .andExpect(status().isNoContent()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java index f324da69..5cb0769c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockControllerTest.java @@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(TranscriptionBlockController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -143,7 +144,7 @@ class TranscriptionBlockControllerTest { @Test void createBlock_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isUnauthorized()); @@ -152,7 +153,7 @@ class TranscriptionBlockControllerTest { @Test @WithMockUser void createBlock_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isForbidden()); @@ -164,7 +165,7 @@ class TranscriptionBlockControllerTest { when(userService.findByEmail(any())).thenReturn(mockUser()); when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock()); - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isCreated()) @@ -177,7 +178,7 @@ class TranscriptionBlockControllerTest { void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception { when(userService.findByEmail(any())).thenReturn(null); - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(CREATE_JSON)) .andExpect(status().isUnauthorized()); @@ -192,7 +193,7 @@ class TranscriptionBlockControllerTest { + "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}"; - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -206,7 +207,7 @@ class TranscriptionBlockControllerTest { String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\"," + "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; - mockMvc.perform(post(URL_BASE) + mockMvc.perform(post(URL_BASE).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -217,7 +218,7 @@ class TranscriptionBlockControllerTest { @Test void updateBlock_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isUnauthorized()); @@ -226,7 +227,7 @@ class TranscriptionBlockControllerTest { @Test @WithMockUser void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isForbidden()); @@ -243,7 +244,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any())) .thenReturn(updated); - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isOk()) @@ -259,7 +260,7 @@ class TranscriptionBlockControllerTest { String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}"; - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -272,7 +273,7 @@ class TranscriptionBlockControllerTest { when(userService.findByEmail(any())).thenReturn(mockUser()); String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) @@ -286,7 +287,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.updateBlock(any(), any(), any(), any())) .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isNotFound()); @@ -297,7 +298,7 @@ class TranscriptionBlockControllerTest { void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception { when(userService.findByEmail(any())).thenReturn(null); - mockMvc.perform(put(URL_BLOCK) + mockMvc.perform(put(URL_BLOCK).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(UPDATE_JSON)) .andExpect(status().isUnauthorized()); @@ -307,28 +308,28 @@ class TranscriptionBlockControllerTest { @Test void deleteBlock_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "READ_ALL") void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "WRITE_ALL") void deleteBlock_returns204_whenAuthorised() throws Exception { - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isNoContent()); } @@ -339,7 +340,7 @@ class TranscriptionBlockControllerTest { DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")) .when(transcriptionService).deleteBlock(any(), any()); - mockMvc.perform(delete(URL_BLOCK)) + mockMvc.perform(delete(URL_BLOCK).with(csrf())) .andExpect(status().isNotFound()); } @@ -347,7 +348,7 @@ class TranscriptionBlockControllerTest { @Test void reorderBlocks_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put(URL_REORDER) + mockMvc.perform(put(URL_REORDER).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(REORDER_JSON)) .andExpect(status().isUnauthorized()); @@ -356,7 +357,7 @@ class TranscriptionBlockControllerTest { @Test @WithMockUser void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(put(URL_REORDER) + mockMvc.perform(put(URL_REORDER).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(REORDER_JSON)) .andExpect(status().isForbidden()); @@ -367,7 +368,7 @@ class TranscriptionBlockControllerTest { void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock())); - mockMvc.perform(put(URL_REORDER) + mockMvc.perform(put(URL_REORDER).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(REORDER_JSON)) .andExpect(status().isOk()) @@ -434,7 +435,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed); mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review", - DOC_ID, BLOCK_ID)) + DOC_ID, BLOCK_ID).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.reviewed").value(true)); } @@ -445,14 +446,14 @@ class TranscriptionBlockControllerTest { @Test void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(authorities = "READ_ALL") void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception { - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isForbidden()); } @@ -469,7 +470,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) .thenReturn(List.of(b1, b2)); - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$[0].reviewed").value(true)) @@ -483,7 +484,7 @@ class TranscriptionBlockControllerTest { when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) .thenReturn(List.of()); - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$").isEmpty()); @@ -494,7 +495,7 @@ class TranscriptionBlockControllerTest { void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception { when(userService.findByEmail(any())).thenReturn(null); - mockMvc.perform(put(URL_REVIEW_ALL)) + mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) .andExpect(status().isUnauthorized()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index d6ce0974..4d13363b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -36,6 +36,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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 static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(GeschichteController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -130,7 +131,7 @@ class GeschichteControllerTest { @Test void create_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/geschichten") + mockMvc.perform(post("/api/geschichten").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"x\"}")) .andExpect(status().isUnauthorized()); @@ -139,7 +140,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void create_returns403_whenLackingBlogWrite() throws Exception { - mockMvc.perform(post("/api/geschichten") + mockMvc.perform(post("/api/geschichten").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"x\"}")) .andExpect(status().isForbidden()); @@ -155,7 +156,7 @@ class GeschichteControllerTest { GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); dto.setTitle("New"); - mockMvc.perform(post("/api/geschichten") + mockMvc.perform(post("/api/geschichten").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andExpect(status().isCreated()) @@ -167,7 +168,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void update_returns403_whenLackingBlogWrite() throws Exception { - mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()) + mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isForbidden()); @@ -180,7 +181,7 @@ class GeschichteControllerTest { when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) .thenReturn(published(id, "Updated")); - mockMvc.perform(patch("/api/geschichten/{id}", id) + mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\":\"PUBLISHED\"}")) .andExpect(status().isOk()) @@ -192,7 +193,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void delete_returns403_whenLackingBlogWrite() throws Exception { - mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID())) + mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -201,7 +202,7 @@ class GeschichteControllerTest { void delete_returns204_withBlogWrite() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(delete("/api/geschichten/{id}", id)) + mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf())) .andExpect(status().isNoContent()); verify(geschichteService).delete(id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java index 451434ff..22654a07 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/notification/NotificationControllerTest.java @@ -35,6 +35,7 @@ import static org.mockito.Mockito.when; import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(NotificationController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -141,7 +142,7 @@ class NotificationControllerTest { @Test void markAllRead_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/notifications/read-all")) + mockMvc.perform(post("/api/notifications/read-all").with(csrf())) .andExpect(status().isUnauthorized()); } @@ -151,7 +152,7 @@ class NotificationControllerTest { AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build(); when(userService.findByEmail("testuser")).thenReturn(user); - mockMvc.perform(post("/api/notifications/read-all")) + mockMvc.perform(post("/api/notifications/read-all").with(csrf())) .andExpect(status().isNoContent()); verify(notificationService).markAllRead(USER_ID); @@ -161,7 +162,7 @@ class NotificationControllerTest { @Test void markOneRead_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read")) + mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf())) .andExpect(status().isUnauthorized()); } @@ -176,7 +177,7 @@ class NotificationControllerTest { org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours")) .when(notificationService).markRead(notifId, USER_ID); - mockMvc.perform(patch("/api/notifications/" + notifId + "/read")) + mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf())) .andExpect(status().isForbidden()); } @@ -256,7 +257,7 @@ class NotificationControllerTest { .notifyOnReply(true).notifyOnMention(true).build(); when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated); - mockMvc.perform(put("/api/users/me/notification-preferences") + mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"notifyOnReply\":true,\"notifyOnMention\":true}")) .andExpect(status().isOk()) @@ -275,7 +276,7 @@ class NotificationControllerTest { .notifyOnReply(true).notifyOnMention(false).build(); when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated); - mockMvc.perform(put("/api/users/me/notification-preferences") + mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"notifyOnReply\":true,\"notifyOnMention\":false}")) .andExpect(status().isOk()) @@ -337,7 +338,7 @@ class NotificationControllerTest { doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId)) .when(notificationService).markRead(notifId, USER_ID); - mockMvc.perform(patch("/api/notifications/" + notifId + "/read")) + mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf())) .andExpect(status().isNotFound()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java index 79b5df61..9eb86db0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/ocr/OcrControllerTest.java @@ -39,6 +39,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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 static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(OcrController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -66,7 +67,7 @@ class OcrControllerTest { when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId); - mockMvc.perform(post("/api/documents/{id}/ocr", docId) + mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andExpect(status().isAccepted()) @@ -80,7 +81,7 @@ class OcrControllerTest { when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean())) .thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded")); - mockMvc.perform(post("/api/documents/{id}/ocr", docId) + mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -127,7 +128,7 @@ class OcrControllerTest { when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId); - mockMvc.perform(post("/api/ocr/batch") + mockMvc.perform(post("/api/ocr/batch").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andExpect(status().isAccepted()) @@ -179,14 +180,14 @@ class OcrControllerTest { @Test void triggerTraining_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(authorities = "READ_ALL") void triggerTraining_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isForbidden()); } @@ -196,7 +197,7 @@ class OcrControllerTest { when(ocrTrainingService.triggerTraining(any())) .thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running")); - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isConflict()); } @@ -209,7 +210,7 @@ class OcrControllerTest { .blockCount(10).documentCount(3).modelName("german_kurrent").build(); when(ocrTrainingService.triggerTraining(any())).thenReturn(run); - mockMvc.perform(post("/api/ocr/train")) + mockMvc.perform(post("/api/ocr/train").with(csrf())) .andExpect(status().isCreated()) .andExpect(jsonPath("$.status").value("DONE")) .andExpect(jsonPath("$.blockCount").value(10)); @@ -365,7 +366,7 @@ class OcrControllerTest { @Test @WithMockUser(authorities = "ADMIN") void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception { - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":null}")) .andExpect(status().isBadRequest()); @@ -373,7 +374,7 @@ class OcrControllerTest { @Test void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isUnauthorized()); @@ -382,7 +383,7 @@ class OcrControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void triggerSenderTraining_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); @@ -395,7 +396,7 @@ class OcrControllerTest { when(senderModelService.triggerManualSenderTraining(unknownId)) .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + unknownId + "\"}")) .andExpect(status().isNotFound()); @@ -410,7 +411,7 @@ class OcrControllerTest { .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + personId + "\"}")) .andExpect(status().isAccepted()) @@ -426,7 +427,7 @@ class OcrControllerTest { .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + personId + "\"}")) .andExpect(status().isAccepted()) @@ -442,7 +443,7 @@ class OcrControllerTest { .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); - mockMvc.perform(post("/api/ocr/train-sender") + mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"personId\":\"" + personId + "\"}")) .andExpect(status().isAccepted()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java index c7800da1..e7767411 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -36,6 +36,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(PersonController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -217,7 +218,7 @@ class PersonControllerTest { @Test void createPerson_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .andExpect(status().isUnauthorized()); @@ -226,7 +227,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -235,7 +236,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -244,7 +245,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenLastNameIsMissing() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -253,7 +254,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenLastNameIsBlank() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -265,7 +266,7 @@ class PersonControllerTest { Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) @@ -278,7 +279,7 @@ class PersonControllerTest { Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build(); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}")) .andExpect(status().isOk()) @@ -293,7 +294,7 @@ class PersonControllerTest { Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); when(personService.createPerson(captor.capture())).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()); @@ -307,7 +308,7 @@ class PersonControllerTest { when(personService.createPerson(any())).thenThrow( DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type")); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}")) .andExpect(status().isBadRequest()) @@ -318,7 +319,7 @@ class PersonControllerTest { @Test void updatePerson_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .andExpect(status().isUnauthorized()); @@ -327,7 +328,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -336,7 +337,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenLastNameIsNull() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -349,7 +350,7 @@ class PersonControllerTest { Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build(); when(personService.updatePerson(eq(id), any())).thenReturn(updated); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) @@ -360,7 +361,7 @@ class PersonControllerTest { @Test void mergePerson_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isUnauthorized()); @@ -369,7 +370,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -378,7 +379,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\" \"}")) .andExpect(status().isBadRequest()); @@ -390,7 +391,7 @@ class PersonControllerTest { UUID sourceId = UUID.randomUUID(); UUID targetId = UUID.randomUUID(); - mockMvc.perform(post("/api/persons/{id}/merge", sourceId) + mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\"" + targetId + "\"}")) .andExpect(status().isNoContent()); @@ -402,7 +403,7 @@ class PersonControllerTest { @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenLastNameIsBlank() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -418,7 +419,7 @@ class PersonControllerTest { .alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build(); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + "\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + @@ -436,7 +437,7 @@ class PersonControllerTest { void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception { String oversizedNotes = "x".repeat(5001); UUID id = UUID.randomUUID(); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -447,7 +448,7 @@ class PersonControllerTest { void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception { String oversizedFirstName = "x".repeat(101); UUID id = UUID.randomUUID(); - mockMvc.perform(put("/api/persons/{id}", id) + mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); @@ -458,7 +459,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception { - mockMvc.perform(post("/api/persons") + mockMvc.perform(post("/api/persons").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isForbidden()); @@ -467,7 +468,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { - mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isForbidden()); @@ -476,7 +477,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { - mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); @@ -507,7 +508,7 @@ class PersonControllerTest { .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build(); when(personService.addAlias(eq(personId), any())).thenReturn(saved); - mockMvc.perform(post("/api/persons/{id}/aliases", personId) + mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .andExpect(status().isOk()) @@ -517,7 +518,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void addAlias_returns403_withoutWritePermission() throws Exception { - mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .andExpect(status().isForbidden()); @@ -531,7 +532,7 @@ class PersonControllerTest { UUID personId = UUID.randomUUID(); UUID aliasId = UUID.randomUUID(); - mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId)) + mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf())) .andExpect(status().isNoContent()); verify(personService).removeAlias(personId, aliasId); @@ -540,14 +541,14 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void removeAlias_returns403_withoutWritePermission() throws Exception { - mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID())) + mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "WRITE_ALL") void addAlias_returns400_whenLastNameIsBlank() throws Exception { - mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"\",\"type\":\"BIRTH\"}")) .andExpect(status().isBadRequest()); @@ -556,7 +557,7 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void addAlias_returns400_whenTypeIsNull() throws Exception { - mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()) + mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"lastName\":\"de Gruyter\"}")) .andExpect(status().isBadRequest()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java index 5395c8e8..74cc739c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/relationship/RelationshipControllerTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(RelationshipController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -67,7 +68,7 @@ class RelationshipControllerTest { @Test @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception { - mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .andExpect(status().isForbidden()); @@ -76,14 +77,14 @@ class RelationshipControllerTest { @Test @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception { - mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID())) + mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception { - mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID) + mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"familyMember\":true}")) .andExpect(status().isForbidden()); @@ -125,7 +126,7 @@ class RelationshipControllerTest { @Test @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception { - mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}")) .andExpect(status().isBadRequest()); @@ -141,7 +142,7 @@ class RelationshipControllerTest { RelationType.PARENT_OF, null, null, null); when(relationshipService.addRelationship(any(), any())).thenReturn(created); - mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID) + mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .andExpect(status().isCreated()) @@ -154,7 +155,7 @@ class RelationshipControllerTest { UUID relId = UUID.randomUUID(); doNothing().when(relationshipService).deleteRelationship(any(), any()); - mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId)) + mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())) .andExpect(status().isNoContent()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java index 0d7e387b..1504f1fa 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/tag/TagControllerTest.java @@ -29,6 +29,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(TagController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -61,7 +62,7 @@ class TagControllerTest { @Test void updateTag_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\": \"New\"}")) .andExpect(status().isUnauthorized()); @@ -70,7 +71,7 @@ class TagControllerTest { @Test @WithMockUser void updateTag_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\": \"New\"}")) .andExpect(status().isForbidden()); @@ -82,7 +83,7 @@ class TagControllerTest { Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build(); when(tagService.update(any(), any())).thenReturn(tag); - mockMvc.perform(put("/api/tags/" + UUID.randomUUID()) + mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\": \"New\"}")) .andExpect(status().isOk()); @@ -116,7 +117,7 @@ class TagControllerTest { @Test void mergeTag_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .andExpect(status().isUnauthorized()); @@ -125,7 +126,7 @@ class TagControllerTest { @Test @WithMockUser void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .andExpect(status().isForbidden()); @@ -134,7 +135,7 @@ class TagControllerTest { @Test @WithMockUser(authorities = "ADMIN_TAG") void mergeTag_returns400_whenTargetIdIsNull() throws Exception { - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()); @@ -146,7 +147,7 @@ class TagControllerTest { when(tagService.mergeTags(any(), any())) .thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found")); - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .andExpect(status().isNotFound()); @@ -159,7 +160,7 @@ class TagControllerTest { Tag target = Tag.builder().id(targetId).name("Target").build(); when(tagService.mergeTags(any(), any())).thenReturn(target); - mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"targetId\": \"" + targetId + "\"}")) .andExpect(status().isOk()) @@ -171,21 +172,21 @@ class TagControllerTest { @Test void deleteSubtree_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "ADMIN_TAG") void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) .andExpect(status().isNoContent()); } @@ -193,21 +194,21 @@ class TagControllerTest { @Test void deleteTag_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @Test @WithMockUser(authorities = "ADMIN_TAG") void deleteTag_returns200_whenHasAdminTagPermission() throws Exception { - mockMvc.perform(delete("/api/tags/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isOk()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java index 533aa7b3..4dfd0f5b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java @@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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 static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(AdminController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -83,14 +84,14 @@ class AdminControllerTest { @Test void backfillVersions_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/admin/backfill-versions")) + mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(roles = "USER") void backfillVersions_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/admin/backfill-versions")) + mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) .andExpect(status().isForbidden()); } @@ -100,7 +101,7 @@ class AdminControllerTest { when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build())); when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1); - mockMvc.perform(post("/api/admin/backfill-versions")) + mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.count").value(1)); } @@ -109,14 +110,14 @@ class AdminControllerTest { @Test void backfillFileHashes_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/admin/backfill-file-hashes")) + mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(roles = "USER") void backfillFileHashes_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/admin/backfill-file-hashes")) + mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) .andExpect(status().isForbidden()); } @@ -125,7 +126,7 @@ class AdminControllerTest { void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception { when(documentService.backfillFileHashes()).thenReturn(3); - mockMvc.perform(post("/api/admin/backfill-file-hashes")) + mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.count").value(3)); } @@ -134,14 +135,14 @@ class AdminControllerTest { @Test void generateThumbnails_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/admin/generate-thumbnails")) + mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(roles = "USER") void generateThumbnails_returns403_whenNotAdmin() throws Exception { - mockMvc.perform(post("/api/admin/generate-thumbnails")) + mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) .andExpect(status().isForbidden()); } @@ -152,7 +153,7 @@ class AdminControllerTest { ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now()); when(thumbnailBackfillService.getStatus()).thenReturn(status); - mockMvc.perform(post("/api/admin/generate-thumbnails")) + mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.state").value("RUNNING")) .andExpect(jsonPath("$.total").value(10)); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java index 7a752c3d..5f92cce3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AuthControllerTest.java @@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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 static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(AuthController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -117,7 +118,7 @@ class AuthControllerTest { req.setFirstName("Max"); req.setLastName("Muster"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isCreated()) @@ -134,7 +135,7 @@ class AuthControllerTest { req.setEmail("dupe@test.com"); req.setPassword("password123"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isConflict()); @@ -150,7 +151,7 @@ class AuthControllerTest { req.setEmail("new@test.com"); req.setPassword("abc"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isBadRequest()); @@ -166,7 +167,7 @@ class AuthControllerTest { req.setEmail("new@test.com"); req.setPassword("password123"); - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isNotFound()); @@ -183,7 +184,7 @@ class AuthControllerTest { req.setPassword("password123"); // No WithMockUser — must still succeed (no auth challenge) - mockMvc.perform(post("/api/auth/register") + mockMvc.perform(post("/api/auth/register").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isCreated()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java index 03b2e641..401fd83d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java @@ -33,6 +33,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(InviteController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -103,7 +104,7 @@ class InviteControllerTest { @Test void createInvite_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); @@ -112,7 +113,7 @@ class InviteControllerTest { @Test @WithMockUser(username = "user@test.com") void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isForbidden()); @@ -142,7 +143,7 @@ class InviteControllerTest { req.setLabel("Für Familie"); req.setMaxUses(1); - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isCreated()) @@ -164,7 +165,7 @@ class InviteControllerTest { .thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345")); String body = "{\"groupIds\":[\"" + groupId + "\"]}"; - mockMvc.perform(post("/api/invites") + mockMvc.perform(post("/api/invites").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isCreated()); @@ -178,14 +179,14 @@ class InviteControllerTest { @Test void revokeInvite_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/invites/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(username = "user@test.com") void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { - mockMvc.perform(delete("/api/invites/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -194,7 +195,7 @@ class InviteControllerTest { void revokeInvite_returns204_whenSuccessful() throws Exception { UUID id = UUID.randomUUID(); - mockMvc.perform(delete("/api/invites/" + id)) + mockMvc.perform(delete("/api/invites/" + id).with(csrf())) .andExpect(status().isNoContent()); verify(inviteService).revokeInvite(id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java index 06a5d611..ccdda803 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java @@ -24,6 +24,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(UserController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -83,7 +84,7 @@ class UserControllerTest { @Test @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isBadRequest()); @@ -92,7 +93,7 @@ class UserControllerTest { @Test @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) void createUser_returns400_whenEmailContainsColon() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isBadRequest()); @@ -101,7 +102,7 @@ class UserControllerTest { @Test @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) void createUser_returns400_whenEmailIsBlank() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isBadRequest()); @@ -112,7 +113,7 @@ class UserControllerTest { @Test @WithMockUser(username = "reader@example.com") void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isForbidden()); @@ -121,7 +122,7 @@ class UserControllerTest { @Test @WithMockUser(username = "reader@example.com") void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { - mockMvc.perform(put("/api/users/" + UUID.randomUUID()) + mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isForbidden()); @@ -130,7 +131,7 @@ class UserControllerTest { @Test @WithMockUser(username = "reader@example.com") void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { - mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isForbidden()); } @@ -138,7 +139,7 @@ class UserControllerTest { @Test void createUser_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/users") + mockMvc.perform(post("/api/users").with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isUnauthorized()); @@ -146,7 +147,7 @@ class UserControllerTest { @Test void adminUpdateUser_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(put("/api/users/" + UUID.randomUUID()) + mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf()) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); @@ -154,7 +155,7 @@ class UserControllerTest { @Test void deleteUser_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) + mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } } -- 2.49.1 From 38818998e50b53e9fe967a25bd1e202e6625b6c4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:36:19 +0200 Subject: [PATCH 02/26] feat(auth): add revokeOtherSessions and revokeAllSessions to AuthService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses JdbcIndexedSessionRepository (optional field — null-safe in non-web test contexts) to delete all sessions for a principal except the current one (revokeOtherSessions) or all sessions unconditionally (revokeAllSessions). Both methods return the count of deleted sessions for audit payloads. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/auth/AuthService.java | 22 ++++++++++ .../familienarchiv/auth/AuthServiceTest.java | 43 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java index 11c34d2d..076eb8d6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java @@ -7,10 +7,12 @@ import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.UserService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; import org.springframework.stereotype.Service; import java.util.Map; @@ -25,6 +27,9 @@ public class AuthService { private final UserService userService; private final AuditService auditService; + @Autowired(required = false) + private JdbcIndexedSessionRepository sessionRepository; + /** * Validates credentials and returns the authenticated user plus the Spring Security * Authentication object. The caller is responsible for persisting the Authentication @@ -53,6 +58,23 @@ public class AuthService { } } + public int revokeOtherSessions(String currentSessionId, String principalName) { + int count = 0; + for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) { + if (!id.equals(currentSessionId)) { + sessionRepository.deleteById(id); + count++; + } + } + return count; + } + + public int revokeAllSessions(String principalName) { + var sessions = sessionRepository.findByPrincipalName(principalName); + sessions.keySet().forEach(sessionRepository::deleteById); + return sessions.size(); + } + public void logout(String email, String ip, String ua) { AppUser user = userService.findByEmail(email); auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of( diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java index 9ae0182a..feacebac 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java @@ -15,11 +15,16 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; +import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.test.util.ReflectionTestUtils; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; @@ -31,11 +36,17 @@ class AuthServiceTest { @Mock AuthenticationManager authenticationManager; @Mock UserService userService; @Mock AuditService auditService; + @Mock JdbcIndexedSessionRepository sessionRepository; @InjectMocks AuthService authService; private static final String IP = "127.0.0.1"; private static final String UA = "Mozilla/5.0 (Test)"; + @BeforeEach + void injectSessionRepository() { + ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository); + } + @Test void login_returns_user_on_valid_credentials() { UUID userId = UUID.randomUUID(); @@ -129,4 +140,36 @@ class AuthServiceTest { && !payload.containsKey("password")) ); } + + @SuppressWarnings("unchecked") + @Test + void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() { + var sessions = new HashMap(); + sessions.put("session-keep", null); + sessions.put("session-del-1", null); + sessions.put("session-del-2", null); + doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de"); + + int count = authService.revokeOtherSessions("session-keep", "user@test.de"); + + assertThat(count).isEqualTo(2); + verify(sessionRepository, never()).deleteById("session-keep"); + verify(sessionRepository).deleteById("session-del-1"); + verify(sessionRepository).deleteById("session-del-2"); + } + + @SuppressWarnings("unchecked") + @Test + void revokeAllSessions_deletes_all_sessions_for_principal() { + var sessions = new HashMap(); + sessions.put("session-1", null); + sessions.put("session-2", null); + doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de"); + + int count = authService.revokeAllSessions("user@test.de"); + + assertThat(count).isEqualTo(2); + verify(sessionRepository).deleteById("session-1"); + verify(sessionRepository).deleteById("session-2"); + } } -- 2.49.1 From 99a4230bb9218311a87fd1eeb9671788c185d956 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:43:19 +0200 Subject: [PATCH 03/26] feat(auth): revoke other sessions on password change; add force-logout endpoint changePassword now calls authService.revokeOtherSessions() after the password is updated and emits a LOGOUT audit with reason=password_change. POST /api/users/{id}/force-logout (ADMIN_USER permission) revokes all sessions for the target user and emits ADMIN_FORCE_LOGOUT audit. Returns {"revokedCount": N} with 200. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/user/UserController.java | 23 ++++++ .../user/UserControllerTest.java | 72 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java index 543074e1..69eba03d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/UserController.java @@ -4,7 +4,11 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.user.AdminUpdateUserRequest; import org.raddatz.familienarchiv.user.ChangePasswordDTO; import org.raddatz.familienarchiv.user.CreateUserRequest; @@ -33,6 +37,8 @@ import lombok.AllArgsConstructor; @AllArgsConstructor public class UserController { private UserService userService; + private AuthService authService; + private AuditService auditService; @GetMapping("users/me") public ResponseEntity getCurrentUser(Authentication authentication) { @@ -56,9 +62,14 @@ public class UserController { @PostMapping("users/me/password") @ResponseStatus(HttpStatus.NO_CONTENT) public void changePassword(Authentication authentication, + HttpSession session, @RequestBody ChangePasswordDTO dto) { AppUser current = userService.findByEmail(authentication.getName()); userService.changePassword(current.getId(), dto); + int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName()); + auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of( + "reason", "password_change", + "revokedCount", revoked)); } @GetMapping("users/{id}") @@ -101,6 +112,18 @@ public class UserController { return ResponseEntity.ok().build(); } + @PostMapping("/users/{id}/force-logout") + @RequirePermission(Permission.ADMIN_USER) + public ResponseEntity> forceLogout(Authentication authentication, + @PathVariable UUID id) { + AppUser target = userService.getById(id); + int revoked = authService.revokeAllSessions(target.getEmail()); + auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of( + "targetUserId", target.getId().toString(), + "revokedCount", revoked)); + return ResponseEntity.ok(Map.of("revokedCount", revoked)); + } + private UUID actorId(Authentication auth) { return userService.findByEmail(auth.getName()).getId(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java index ccdda803..1adb2cc5 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/UserControllerTest.java @@ -1,6 +1,8 @@ package org.raddatz.familienarchiv.user; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.security.PermissionAspect; @@ -10,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -33,6 +36,8 @@ class UserControllerTest { @Autowired MockMvc mockMvc; @MockitoBean UserService userService; + @MockitoBean AuthService authService; + @MockitoBean AuditService auditService; @MockitoBean CustomUserDetailsService customUserDetailsService; // ─── GET /api/users/me ──────────────────────────────────────────────────────── @@ -158,4 +163,71 @@ class UserControllerTest { mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) .andExpect(status().isUnauthorized()); } + + // ─── POST /api/users/me/password (changePassword + session revocation) ──── + + @Test + @WithMockUser(username = "user@example.com") + void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception { + AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build(); + when(userService.findByEmail("user@example.com")).thenReturn(user); + when(authService.revokeOtherSessions(any(), any())).thenReturn(1); + + mockMvc.perform(post("/api/users/me/password").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}")) + .andExpect(status().isNoContent()); + + org.mockito.Mockito.verify(authService).revokeOtherSessions(any(), org.mockito.ArgumentMatchers.eq("user@example.com")); + } + + @Test + void changePassword_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/users/me/password").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}")) + .andExpect(status().isUnauthorized()); + } + + // ─── POST /api/users/{id}/force-logout ──────────────────────────────────── + + @Test + @WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER") + void forceLogout_returns200_and_revokes_target_sessions() throws Exception { + UUID targetId = UUID.randomUUID(); + AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build(); + AppUser target = AppUser.builder().id(targetId).email("target@example.com").build(); + when(userService.findByEmail("admin@example.com")).thenReturn(actor); + when(userService.getById(targetId)).thenReturn(target); + when(authService.revokeAllSessions("target@example.com")).thenReturn(2); + + mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf())) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2)); + } + + @Test + void forceLogout_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void forceLogout_returns403_whenMissingPermission() throws Exception { + mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_USER") + void forceLogout_returns404_whenUserNotFound() throws Exception { + UUID targetId = UUID.randomUUID(); + when(userService.getById(targetId)).thenThrow( + org.raddatz.familienarchiv.exception.DomainException.notFound( + org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found")); + + mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf())) + .andExpect(status().isNotFound()); + } } -- 2.49.1 From 924c76f99fa6e415cbbc9610a31776c6a14f1e62 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:47:28 +0200 Subject: [PATCH 04/26] feat(auth): revoke all sessions on password reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After updating the user password during a reset flow, calls authService.revokeAllSessions(email) to invalidate every active session for the account — prevents an attacker with a stolen session from retaining access after the owner resets their password. Co-Authored-By: Claude Sonnet 4.6 --- .../user/PasswordResetService.java | 4 ++++ .../user/PasswordResetServiceTest.java | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java b/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java index 850ea94d..9cc25c50 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/PasswordResetService.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.HexFormat; import java.util.Optional; +import org.raddatz.familienarchiv.auth.AuthService; import org.raddatz.familienarchiv.user.ResetPasswordRequest; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -32,6 +33,7 @@ public class PasswordResetService { private final UserService userService; private final PasswordResetTokenRepository tokenRepository; private final PasswordEncoder passwordEncoder; + private final AuthService authService; @Autowired(required = false) private JavaMailSender mailSender; @@ -85,6 +87,8 @@ public class PasswordResetService { resetToken.setUsed(true); tokenRepository.save(resetToken); + + authService.revokeAllSessions(user.getEmail()); } /** diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java index 00baef9c..86cbb3b7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/PasswordResetServiceTest.java @@ -27,6 +27,7 @@ import org.springframework.mail.MailSendException; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.security.crypto.password.PasswordEncoder; +import org.raddatz.familienarchiv.auth.AuthService; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -36,8 +37,10 @@ class PasswordResetServiceTest { @Mock PasswordResetTokenRepository tokenRepository; @Mock PasswordEncoder passwordEncoder; @Mock JavaMailSender mailSender; + @Mock AuthService authService; @InjectMocks PasswordResetService service; + private AppUser makeUser(String email) { return AppUser.builder() .id(UUID.randomUUID()) @@ -176,6 +179,27 @@ class PasswordResetServiceTest { verify(mailSender).send(any(SimpleMailMessage.class)); } + @Test + void resetPassword_revokes_all_sessions_after_password_reset() { + AppUser user = makeUser("user@example.com"); + PasswordResetToken token = PasswordResetToken.builder() + .id(UUID.randomUUID()) + .token("validtoken123") + .user(user) + .expiresAt(LocalDateTime.now().plusHours(1)) + .used(false) + .build(); + when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token)); + when(passwordEncoder.encode(any())).thenReturn("hashed"); + + ResetPasswordRequest req = new ResetPasswordRequest(); + req.setToken("validtoken123"); + req.setNewPassword("newpass"); + service.resetPassword(req); + + verify(authService).revokeAllSessions("user@example.com"); + } + // ─── cleanupExpiredTokens ───────────────────────────────────────────────── @Test -- 2.49.1 From 14deae962a8ccac55df03e3f713c392abbaf34a3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:52:56 +0200 Subject: [PATCH 05/26] feat(auth): add Bucket4j + Caffeine login rate limiter (10/15 min per IP+email, 20/15 min per IP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoginRateLimiter uses two Caffeine LoadingCaches of Bucket4j buckets — one keyed on IP:email (10 attempts/15 min) and one on IP alone (20/15 min backstop). Exceeding either throws DomainException(TOO_MANY_LOGIN_ATTEMPTS) and emits LOGIN_RATE_LIMITED audit. Successful login invalidates both buckets via invalidateOnSuccess. Buckets expire after windowMinutes of inactivity (no clock advance needed — Caffeine handles eviction). AuthService integrates it as an optional @Autowired field so non-web test contexts still work without a Caffeine dependency. Co-Authored-By: Claude Sonnet 4.6 --- backend/pom.xml | 7 +- .../familienarchiv/auth/AuthService.java | 16 +++++ .../familienarchiv/auth/LoginRateLimiter.java | 61 +++++++++++++++++ .../auth/RateLimitProperties.java | 14 ++++ backend/src/main/resources/application.yaml | 6 ++ .../familienarchiv/auth/AuthServiceTest.java | 44 +++++++++++- .../auth/LoginRateLimiterTest.java | 67 +++++++++++++++++++ 7 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 6e9b389b..cb1d2024 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -180,11 +180,16 @@ flyway-database-postgresql - + com.github.ben-manes.caffeine caffeine + + com.bucket4j + bucket4j-core + 8.10.1 + diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java index 076eb8d6..8ce1219d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/AuthService.java @@ -30,12 +30,25 @@ public class AuthService { @Autowired(required = false) private JdbcIndexedSessionRepository sessionRepository; + @Autowired(required = false) + private LoginRateLimiter loginRateLimiter; + /** * Validates credentials and returns the authenticated user plus the Spring Security * Authentication object. The caller is responsible for persisting the Authentication * to the session via SecurityContextRepository. */ public LoginResult login(String email, String password, String ip, String ua) { + if (loginRateLimiter != null) { + try { + loginRateLimiter.checkAndConsume(ip, email); + } catch (DomainException ex) { + auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of( + "ip", ip, + "email", email)); + throw ex; + } + } try { Authentication auth = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(email, password)); @@ -45,6 +58,9 @@ public class AuthService { "userId", user.getId().toString(), "ip", ip, "ua", truncateUa(ua))); + if (loginRateLimiter != null) { + loginRateLimiter.invalidateOnSuccess(ip, email); + } return new LoginResult(user, auth); } catch (AuthenticationException ex) { // Audit login failure — intentionally does NOT log the attempted password. diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java new file mode 100644 index 00000000..2571dd06 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/LoginRateLimiter.java @@ -0,0 +1,61 @@ +package org.raddatz.familienarchiv.auth; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +public class LoginRateLimiter { + + private final LoadingCache byIpEmail; + private final LoadingCache byIp; + private final int maxPerIpEmail; + private final int maxPerIp; + private final int windowMinutes; + + public LoginRateLimiter(RateLimitProperties props) { + this.maxPerIpEmail = props.getMaxAttemptsPerIpEmail(); + this.maxPerIp = props.getMaxAttemptsPerIp(); + this.windowMinutes = props.getWindowMinutes(); + + this.byIpEmail = Caffeine.newBuilder() + .expireAfterAccess(windowMinutes, TimeUnit.MINUTES) + .build(key -> newBucket(maxPerIpEmail, windowMinutes)); + + this.byIp = Caffeine.newBuilder() + .expireAfterAccess(windowMinutes, TimeUnit.MINUTES) + .build(key -> newBucket(maxPerIp, windowMinutes)); + } + + public void checkAndConsume(String ip, String email) { + boolean ipEmailOk = byIpEmail.get(ip + ":" + email).tryConsume(1); + boolean ipOk = byIp.get(ip).tryConsume(1); + if (!ipEmailOk || !ipOk) { + throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, + "Too many login attempts from " + ip); + } + } + + public void invalidateOnSuccess(String ip, String email) { + byIpEmail.invalidate(ip + ":" + email); + byIp.invalidate(ip); + } + + private static Bucket newBucket(int limit, int minutes) { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(limit) + .refillGreedy(limit, Duration.ofMinutes(minutes)) + .build()) + .build(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java b/backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java new file mode 100644 index 00000000..76060ff6 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/auth/RateLimitProperties.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.auth; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("rate-limit.login") +@Data +public class RateLimitProperties { + private int maxAttemptsPerIpEmail = 10; + private int maxAttemptsPerIp = 20; + private int windowMinutes = 15; +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 2a764e8e..e74f4d41 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -150,3 +150,9 @@ sentry: enable-tracing: true ignored-exceptions-for-type: - org.raddatz.familienarchiv.exception.DomainException + +rate-limit: + login: + max-attempts-per-ip-email: 10 + max-attempts-per-ip: 20 + window-minutes: 15 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java index feacebac..3dc4d018 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/AuthServiceTest.java @@ -16,6 +16,7 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.session.jdbc.JdbcIndexedSessionRepository; +import org.raddatz.familienarchiv.exception.ErrorCode; import java.util.HashMap; import java.util.Map; @@ -37,14 +38,16 @@ class AuthServiceTest { @Mock UserService userService; @Mock AuditService auditService; @Mock JdbcIndexedSessionRepository sessionRepository; + @Mock LoginRateLimiter loginRateLimiter; @InjectMocks AuthService authService; private static final String IP = "127.0.0.1"; private static final String UA = "Mozilla/5.0 (Test)"; @BeforeEach - void injectSessionRepository() { + void injectOptionalFields() { ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository); + ReflectionTestUtils.setField(authService, "loginRateLimiter", loginRateLimiter); } @Test @@ -141,6 +144,45 @@ class AuthServiceTest { ); } + @Test + void login_checks_rate_limit_before_authenticating() { + doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited")) + .when(loginRateLimiter).checkAndConsume(IP, "user@test.de"); + + assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA)) + .isInstanceOf(DomainException.class) + .satisfies(ex -> assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS)); + + verify(authenticationManager, never()).authenticate(any()); + } + + @Test + void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() { + UUID userId = UUID.randomUUID(); + doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited")) + .when(loginRateLimiter).checkAndConsume(IP, "user@test.de"); + + assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA)) + .isInstanceOf(DomainException.class); + + verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(), + argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email")))); + } + + @Test + void login_invalidates_rate_limit_on_success() { + UUID userId = UUID.randomUUID(); + AppUser user = AppUser.builder().id(userId).email("user@test.de").build(); + Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of()); + when(authenticationManager.authenticate(any())).thenReturn(auth); + when(userService.findByEmail("user@test.de")).thenReturn(user); + + authService.login("user@test.de", "pass123", IP, UA); + + verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de"); + } + @SuppressWarnings("unchecked") @Test void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java new file mode 100644 index 00000000..77e4ebb6 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/auth/LoginRateLimiterTest.java @@ -0,0 +1,67 @@ +package org.raddatz.familienarchiv.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatNoException; + +class LoginRateLimiterTest { + + private LoginRateLimiter rateLimiter; + + @BeforeEach + void setUp() { + RateLimitProperties props = new RateLimitProperties(); + props.setMaxAttemptsPerIpEmail(10); + props.setMaxAttemptsPerIp(20); + props.setWindowMinutes(15); + rateLimiter = new LoginRateLimiter(props); + } + + @Test + void tenth_attempt_from_same_ip_email_succeeds() { + for (int i = 0; i < 10; i++) { + assertThatNoException().isThrownBy( + () -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com")); + } + } + + @Test + void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() { + for (int i = 0; i < 10; i++) { + rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"); + } + + assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com")) + .isInstanceOf(DomainException.class) + .satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS)); + } + + @Test + void success_after_10_failures_resets_ip_email_bucket() { + for (int i = 0; i < 10; i++) { + rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"); + } + + rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com"); + + assertThatNoException().isThrownBy( + () -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com")); + } + + @Test + void twentyfirst_attempt_from_same_ip_across_different_emails_throws() { + for (int i = 0; i < 20; i++) { + rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com"); + } + + assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com")) + .isInstanceOf(DomainException.class) + .satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode()) + .isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS)); + } +} -- 2.49.1 From fdb9ae31ae7b82ccdd317712771103165da01da0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:59:56 +0200 Subject: [PATCH 06/26] feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling - handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating backend API requests (double-submit cookie pattern); generates a fresh UUID when no XSRF-TOKEN cookie exists yet - ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS; getErrorMessage maps both to i18n keys - de/en/es messages add error_csrf_token_missing and error_too_many_login_attempts translations - Login action maps HTTP 429 to fail(429, { ..., rateLimited: true }); page shows a muted clock icon with aria-invalid on rate-limit errors Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + frontend/src/hooks.server.ts | 72 +++++++++++-------- frontend/src/lib/shared/errors.ts | 6 ++ frontend/src/routes/login/+page.server.ts | 4 ++ frontend/src/routes/login/+page.svelte | 28 +++++++- frontend/src/routes/login/page.server.test.ts | 19 +++++ 8 files changed, 105 insertions(+), 30 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 03d63f48..da101292 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.", "error_unauthorized": "Sie sind nicht angemeldet.", "error_forbidden": "Sie haben keine Berechtigung für diese Aktion.", + "error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.", + "error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.", "error_validation_error": "Die Eingabe ist ungültig.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d52ddbf5..0eebd0d7 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.", "error_unauthorized": "You are not logged in.", "error_forbidden": "You do not have permission for this action.", + "error_csrf_token_missing": "Session error. Please reload the page.", + "error_too_many_login_attempts": "Too many login attempts. Please try again later.", "error_validation_error": "The input is invalid.", "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 177dcdff..6d29297a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.", "error_unauthorized": "No ha iniciado sesión.", "error_forbidden": "No tiene permiso para realizar esta acción.", + "error_csrf_token_missing": "Error de sesión. Recargue la página.", + "error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.", "error_validation_error": "La entrada no es válida.", "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index de71e2b9..3db312bc 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -96,42 +96,58 @@ const userGroup: Handle = async ({ event, resolve }) => { return resolve(event); }; +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +// Auth endpoints that establish/check their own credentials — skip fa_session injection +// but still need CSRF tokens on mutating requests. +const PUBLIC_API_PATHS = [ + '/api/auth/login', + '/api/auth/logout', + '/api/auth/forgot-password', + '/api/auth/reset-password', + '/api/auth/invite/', + '/api/auth/register' +]; + export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); - if (isApi) { - // Auth endpoints that establish/check their own credentials manage cookies themselves; - // don't double-inject a stale fa_session. - const PUBLIC_API_PATHS = [ - '/api/auth/login', - '/api/auth/logout', - '/api/auth/forgot-password', - '/api/auth/reset-password', - '/api/auth/invite/', - '/api/auth/register' - ]; - if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) { - return fetch(request); - } + if (!isApi) return fetch(request); - const sessionId = event.cookies.get('fa_session'); - if (!sessionId) { - return new Response('Unauthorized', { status: 401 }); - } + const isMutating = MUTATING_METHODS.has(request.method); + const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p)); - // Clone first so the body stream is preserved on the new Request. - const cloned = request.clone(); - const modified = new Request(cloned, { - headers: { - ...Object.fromEntries(cloned.headers), - Cookie: `fa_session=${sessionId}` - } - }); - return fetch(modified); + const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null; + if (!isPublicAuthApi && !sessionId) { + return new Response('Unauthorized', { status: 401 }); } - return fetch(request); + // Read the browser's XSRF-TOKEN cookie; fall back to a fresh UUID for the + // double-submit cookie pattern (both cookie and header must match — no server secret). + const xsrfToken = isMutating ? (event.cookies.get('XSRF-TOKEN') ?? crypto.randomUUID()) : null; + + const cookieParts: string[] = []; + if (sessionId) cookieParts.push(`fa_session=${sessionId}`); + if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`); + + if (cookieParts.length === 0 && !xsrfToken) { + return fetch(request); + } + + // Clone first so the body stream is preserved on the new Request. + const cloned = request.clone(); + const extraHeaders: Record = {}; + if (cookieParts.length > 0) extraHeaders['Cookie'] = cookieParts.join('; '); + if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken; + + const modified = new Request(cloned, { + headers: { + ...Object.fromEntries(cloned.headers), + ...extraHeaders + } + }); + return fetch(modified); }; export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index ab79487f..96700120 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -49,6 +49,8 @@ export type ErrorCode = | 'MISSING_CREDENTIALS' | 'UNAUTHORIZED' | 'FORBIDDEN' + | 'CSRF_TOKEN_MISSING' + | 'TOO_MANY_LOGIN_ATTEMPTS' | 'VALIDATION_ERROR' | 'BATCH_TOO_LARGE' | 'BULK_EDIT_TOO_MANY_IDS' @@ -166,6 +168,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_unauthorized(); case 'FORBIDDEN': return m.error_forbidden(); + case 'CSRF_TOKEN_MISSING': + return m.error_csrf_token_missing(); + case 'TOO_MANY_LOGIN_ATTEMPTS': + return m.error_too_many_login_attempts(); case 'VALIDATION_ERROR': return m.error_validation_error(); case 'BATCH_TOO_LARGE': diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 244711d0..022e4ecd 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -45,6 +45,10 @@ export const actions = { return fail(401, { error: getErrorMessage(code) }); } + if (response.status === 429) { + return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true }); + } + if (!response.ok) { return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') }); } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index b2ee6b2e..540d69b6 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -7,7 +7,7 @@ let { form }: { data: { registered: boolean; reason?: string | null }; - form?: { error?: string; success?: boolean }; + form?: { error?: string; rateLimited?: boolean; success?: boolean }; } = $props(); @@ -106,7 +106,31 @@ let { {#if form?.error} -
{form.error}
+ {#if form?.rateLimited} + + {:else} +
{form.error}
+ {/if} {/if}