Compare commits
24 Commits
worktree-f
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0514622f39 | ||
|
|
24c85c29e4 | ||
|
|
778402fec7 | ||
|
|
6db5c2d1c4 | ||
|
|
2f981ef69d | ||
|
|
7074c9e4ad | ||
|
|
8eced9c9da | ||
|
|
28de7da9a6 | ||
|
|
8189e14a4b | ||
|
|
bdc37b1156 | ||
|
|
314f686963 | ||
|
|
a23fa4c668 | ||
|
|
05ab8b13a0 | ||
|
|
1052295a6e | ||
|
|
c3d1bea623 | ||
|
|
97585a9cd4 | ||
|
|
c32607e133 | ||
|
|
d7eca25eb7 | ||
|
|
fdb9ae31ae | ||
|
|
14deae962a | ||
|
|
924c76f99f | ||
|
|
99a4230bb9 | ||
|
|
38818998e5 | ||
|
|
9b4da70f52 |
@@ -77,7 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
||||
```
|
||||
backend/src/main/java/org/raddatz/familienarchiv/
|
||||
├── audit/ Audit logging
|
||||
├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC)
|
||||
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
|
||||
├── config/ Infrastructure config (Minio, Async, Web)
|
||||
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
||||
├── document/ Document domain (entities, controller, service, repository, DTOs)
|
||||
|
||||
@@ -180,11 +180,16 @@
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine cache for in-memory rate limiting -->
|
||||
<!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||
<dependency>
|
||||
|
||||
@@ -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<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
|
||||
@@ -24,13 +24,18 @@ public class AuthService {
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final UserService userService;
|
||||
private final AuditService auditService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
private final SessionRevocationPort sessionRevocationPort;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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));
|
||||
@@ -40,6 +45,7 @@ public class AuthService {
|
||||
"userId", user.getId().toString(),
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||
return new LoginResult(user, auth);
|
||||
} catch (AuthenticationException ex) {
|
||||
// Audit login failure — intentionally does NOT log the attempted password.
|
||||
@@ -53,6 +59,14 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
|
||||
}
|
||||
|
||||
public int revokeAllSessions(String principalName) {
|
||||
return sessionRevocationPort.revokeAllSessions(principalName);
|
||||
}
|
||||
|
||||
public void logout(String email, String ip, String ua) {
|
||||
AppUser user = userService.findByEmail(email);
|
||||
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
|
||||
|
||||
private final JdbcIndexedSessionRepository sessionRepository;
|
||||
|
||||
@Override
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int revokeAllSessions(String principalName) {
|
||||
var sessions = sessionRepository.findByPrincipalName(principalName);
|
||||
sessions.keySet().forEach(sessionRepository::deleteById);
|
||||
return sessions.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginRateLimiter {
|
||||
|
||||
private final LoadingCache<String, Bucket> byIpEmail;
|
||||
private final LoadingCache<String, Bucket> 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));
|
||||
}
|
||||
|
||||
// NOTE: This cache is node-local (in-memory). In a multi-replica deployment,
|
||||
// effective limits would be multiplied by replica count.
|
||||
// For the current single-VPS setup this is the correct, simplest implementation.
|
||||
|
||||
public void checkAndConsume(String ip, String email) {
|
||||
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
|
||||
if (!byIpEmail.get(key).tryConsume(1)) {
|
||||
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||
"Too many login attempts from " + ip);
|
||||
}
|
||||
if (!byIp.get(ip).tryConsume(1)) {
|
||||
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
|
||||
byIpEmail.get(key).addTokens(1);
|
||||
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.toLowerCase(Locale.ROOT));
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
|
||||
|
||||
@Override
|
||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int revokeAllSessions(String principalName) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
|
||||
@Configuration
|
||||
class SessionRevocationConfig {
|
||||
|
||||
@Bean
|
||||
SessionRevocationPort sessionRevocationPort(
|
||||
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
|
||||
if (sessionRepository != null) {
|
||||
return new JdbcSessionRevocationAdapter(sessionRepository);
|
||||
}
|
||||
return new NoOpSessionRevocationAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
public interface SessionRevocationPort {
|
||||
int revokeOtherSessions(String currentSessionId, String principalName);
|
||||
int revokeAllSessions(String principalName);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,12 +21,22 @@ 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
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
|
||||
// cannot be injected here. A static instance is safe because the response
|
||||
// only serializes fixed String keys — no custom naming strategy or module needed.
|
||||
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final Environment environment;
|
||||
|
||||
@@ -78,15 +90,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-022 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 +122,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(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||
}));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -26,13 +30,15 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/")
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private UserService userService;
|
||||
private final UserService userService;
|
||||
private final AuthService authService;
|
||||
private final AuditService auditService;
|
||||
|
||||
@GetMapping("users/me")
|
||||
public ResponseEntity<AppUser> 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<Map<String, Object>> 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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,6 @@ import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -31,6 +30,8 @@ class AuthServiceTest {
|
||||
@Mock AuthenticationManager authenticationManager;
|
||||
@Mock UserService userService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock LoginRateLimiter loginRateLimiter;
|
||||
@Mock SessionRevocationPort sessionRevocationPort;
|
||||
@InjectMocks AuthService authService;
|
||||
|
||||
private static final String IP = "127.0.0.1";
|
||||
@@ -129,4 +130,62 @@ class AuthServiceTest {
|
||||
&& !payload.containsKey("password"))
|
||||
);
|
||||
}
|
||||
|
||||
@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() {
|
||||
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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeOtherSessions_delegates_to_port() {
|
||||
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
|
||||
|
||||
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeAllSessions_delegates_to_port() {
|
||||
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
|
||||
|
||||
int count = authService.revokeAllSessions("user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(3);
|
||||
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void login_sets_opaque_fa_session_cookie() {
|
||||
ResponseEntity<String> response = doLogin();
|
||||
String xsrf = fetchXsrfToken();
|
||||
ResponseEntity<String> 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<String> 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<Void> 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<String> 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;
|
||||
@@ -115,11 +119,37 @@ class AuthSessionIntegrationTest {
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
// ─── Task: CSRF rejection at integration layer ────────────────────────────
|
||||
|
||||
private ResponseEntity<String> doLogin() {
|
||||
@Test
|
||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
|
||||
ResponseEntity<String> response = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>("{}", headers), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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<String> 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 +161,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<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
@@ -141,6 +178,7 @@ class AuthSessionIntegrationTest {
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JdbcSessionRevocationAdapterTest {
|
||||
|
||||
@Mock JdbcIndexedSessionRepository sessionRepository;
|
||||
@InjectMocks JdbcSessionRevocationAdapter adapter;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
||||
var sessions = new HashMap<String, Object>();
|
||||
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 = adapter.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<String, Object>();
|
||||
sessions.put("session-1", null);
|
||||
sessions.put("session-2", null);
|
||||
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||
|
||||
int count = adapter.revokeAllSessions("user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
verify(sessionRepository).deleteById("session-1");
|
||||
verify(sessionRepository).deleteById("session-2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
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 -> 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 -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
|
||||
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);
|
||||
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
|
||||
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 -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_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 ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
|
||||
// Use a tighter limiter so the phantom-consumption effect is observable.
|
||||
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
|
||||
RateLimitProperties props = new RateLimitProperties();
|
||||
props.setMaxAttemptsPerIpEmail(3);
|
||||
props.setMaxAttemptsPerIp(3);
|
||||
props.setWindowMinutes(15);
|
||||
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
|
||||
|
||||
// Exhaust the per-IP bucket using "user@"
|
||||
for (int i = 0; i < 3; i++) {
|
||||
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
// Three blocked attempts for "target@" while IP is exhausted
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
|
||||
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||
|
||||
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
|
||||
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
|
||||
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,12 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
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 +216,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 +237,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 +246,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 +254,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 +271,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 +280,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 +288,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 +297,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 +305,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 +328,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 +347,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 +362,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 +492,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 +642,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 +651,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 +661,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 +673,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 +684,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 +698,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 +715,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 +728,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"file", "evil.html", "text/html", "<script>alert(1)</script>".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 +745,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 +802,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 +829,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 +861,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 +885,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 +906,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 +928,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 +947,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 +956,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 +967,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 +978,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 +992,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 +1011,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 +1027,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 +1044,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 +1063,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 +1139,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 +1148,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 +1157,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 +1174,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 +1189,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 +1210,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 +1234,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())
|
||||
@@ -1337,4 +1339,16 @@ class DocumentControllerTest {
|
||||
DocumentStatus.REVIEWED,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||
}
|
||||
|
||||
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/documents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -17,6 +20,8 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -24,6 +29,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})
|
||||
@@ -32,6 +38,8 @@ class UserControllerTest {
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean AuthService authService;
|
||||
@MockitoBean AuditService auditService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/users/me ────────────────────────────────────────────────────────
|
||||
@@ -83,7 +91,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 +100,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 +109,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 +120,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 +129,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 +138,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 +146,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 +154,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 +162,92 @@ 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());
|
||||
}
|
||||
|
||||
// ─── 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());
|
||||
|
||||
verify(authService).revokeOtherSessions(any(), 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());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@example.com")
|
||||
void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/users/me/password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||
}
|
||||
|
||||
// ─── 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());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
||||
void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
@@ -117,7 +117,7 @@ Controllers never call repositories directly. Services never reach into another
|
||||
### Permission system
|
||||
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
|
||||
|
||||
Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
|
||||
Sessions use a Spring Session JDBC-backed cookie (`fa_session`, `httpOnly`, `SameSite=strict`, maxAge=86400 s). CSRF protection uses the double-submit cookie pattern: Spring Security sets an `XSRF-TOKEN` cookie (readable by JS); SvelteKit's `handleFetch` injects the value as `X-XSRF-TOKEN` on every mutating request; a missing or mismatched token returns `403 CSRF_TOKEN_MISSING`. See [ADR-022](adr/022-csrf-session-revocation-rate-limiting.md) and [docs/security-guide.md](security-guide.md) for the full security reference.
|
||||
|
||||
---
|
||||
|
||||
|
||||
106
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
106
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# ADR-022 — CSRF Protection, Session Revocation, and Login Rate Limiting
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Accepted
|
||||
**Issue:** #524
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-020 established stateful authentication via Spring Session JDBC. Three
|
||||
follow-on security concerns were left open:
|
||||
|
||||
1. **CSRF.** State-changing API calls from the SvelteKit frontend use session
|
||||
cookies. Without CSRF protection an attacker can forge cross-origin requests
|
||||
that carry the victim's session cookie.
|
||||
|
||||
2. **Session revocation.** A user who changes or resets their password may still
|
||||
have other active sessions (other browsers, shared devices). Those sessions
|
||||
should be invalidated so the credential change takes full effect immediately.
|
||||
|
||||
3. **Login rate limiting.** The login endpoint accepts arbitrary email/password
|
||||
pairs. Without throttling it is vulnerable to brute-force and credential-
|
||||
stuffing attacks.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. CSRF — double-submit cookie pattern
|
||||
|
||||
`SecurityConfig` enables `CookieCsrfTokenRepository.withHttpOnlyFalse()`:
|
||||
|
||||
- The backend sets an `XSRF-TOKEN` cookie (readable by JavaScript) on every
|
||||
response.
|
||||
- All state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) must include
|
||||
an `X-XSRF-TOKEN` request header whose value matches the cookie.
|
||||
- `CsrfTokenRequestAttributeHandler` is used (non-XOR mode) — correct for
|
||||
SPAs where token deferred loading would otherwise corrupt values.
|
||||
- SvelteKit's `handleFetch` hook injects the header and mirrors the cookie for
|
||||
every mutating API call.
|
||||
- CSRF validation failures return HTTP 403 with JSON body
|
||||
`{"code": "CSRF_TOKEN_MISSING"}` via a custom `AccessDeniedHandler`.
|
||||
|
||||
Login (`POST /api/auth/login`), forgot-password, and reset-password are
|
||||
**not** CSRF-exempt — the XSRF-TOKEN cookie is set on the first GET to the
|
||||
login page, so the double-submit requirement is satisfiable from the browser.
|
||||
|
||||
### 2. Session revocation
|
||||
|
||||
`AuthService` gains two methods backed by `JdbcIndexedSessionRepository`:
|
||||
|
||||
- `revokeOtherSessions(currentSessionId, principal)` — deletes all sessions
|
||||
for a principal **except** the caller's current session. Called on password
|
||||
change so the user stays logged in on the current device.
|
||||
- `revokeAllSessions(principal)` — deletes every session for a principal.
|
||||
Called on password reset (unauthenticated flow) so no prior sessions survive.
|
||||
|
||||
Both methods are no-ops when `sessionRepository` is `null` (unit-test
|
||||
contexts that do not load Spring Session).
|
||||
|
||||
### 3. Login rate limiting — in-memory token bucket
|
||||
|
||||
`LoginRateLimiter` (Bucket4j + Caffeine) enforces two independent limits:
|
||||
|
||||
| Bucket | Limit | Window | Key |
|
||||
|--------|-------|--------|-----|
|
||||
| Per IP + email | 10 attempts | 15 min | `ip:email` |
|
||||
| Per IP (all emails) | 20 attempts | 15 min | `ip` |
|
||||
|
||||
On each login attempt both buckets are checked **sequentially**:
|
||||
1. Consume from the `ip:email` bucket first.
|
||||
2. If the IP-level bucket is exhausted, **refund** the `ip:email` token.
|
||||
|
||||
The refund prevents IP-level blocking from silently consuming per-email quota:
|
||||
without it, 20 blocked attempts for `target@example.com` from a single IP
|
||||
(caused by another email exhausting the IP bucket) would drain all 10 of
|
||||
`target@`'s tokens.
|
||||
|
||||
On a successful login both buckets are invalidated for that `(ip, email)` pair
|
||||
so a legitimately authenticated user regains the full window immediately.
|
||||
|
||||
Rate-limit violations are audited as `LOGIN_RATE_LIMITED` events.
|
||||
|
||||
The cache is **node-local** (in-memory). In a multi-replica deployment the
|
||||
effective rate limit is multiplied by the replica count. This is acceptable for
|
||||
the current single-VPS production setup and is noted with a comment in the
|
||||
source.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- **CSRF:** All SvelteKit API calls must supply `X-XSRF-TOKEN`. Bare `curl`
|
||||
calls or non-browser clients must obtain and pass the token manually.
|
||||
Integration tests use `.with(csrf())` from `spring-security-test`.
|
||||
- **Session revocation:** Requires `JdbcIndexedSessionRepository` to be wired
|
||||
(Spring Session JDBC dependency). Unit tests inject `null` and verify the
|
||||
no-op path.
|
||||
- **Rate limiting:** False positives are possible if many users share a NAT/VPN
|
||||
IP. The per-IP limit (20) is intentionally loose to reduce collateral
|
||||
blocking; the per-IP+email limit (10) is the primary defence.
|
||||
- `ObjectMapper` in the CSRF `AccessDeniedHandler` uses a static instance
|
||||
because `@WebMvcTest` slices exclude `JacksonAutoConfiguration`. The response
|
||||
only serialises a fixed String key (`"code"`) so naming strategy and custom
|
||||
modules are irrelevant.
|
||||
@@ -9,18 +9,23 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
|
||||
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.")
|
||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.")
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF enabled: double-submit cookie pattern (CookieCsrfTokenRepository.withHttpOnlyFalse + CsrfTokenRequestAttributeHandler). Custom AccessDeniedHandler returns JSON {\"code\":\"CSRF_TOKEN_MISSING\"}.")
|
||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||
Component(rateLimiter, "LoginRateLimiter", "@Component org.raddatz.familienarchiv.auth", "Dual Bucket4j/Caffeine in-memory rate limiting: per ip:email bucket and per ip bucket. checkAndConsume() throws TOO_MANY_LOGIN_ATTEMPTS (429) when either bucket is exhausted. invalidateOnSuccess() resets both buckets on successful login. Buckets expire after idle windowMinutes.")
|
||||
Component(rateLimitProps, "RateLimitProperties", "@ConfigurationProperties(\"rate-limit.login\") org.raddatz.familienarchiv.auth", "Externalized config for login rate limiting: maxAttemptsPerIpEmail (default 10), maxAttemptsPerIp (default 20), windowMinutes (default 15). Bound from application.yaml rate-limit.login block.")
|
||||
}
|
||||
|
||||
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
|
||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie")
|
||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie + X-XSRF-TOKEN header")
|
||||
Rel(authCtrl, authSvc, "Validate creds + audit")
|
||||
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
|
||||
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
|
||||
Rel(authSvc, rateLimiter, "checkAndConsume() / invalidateOnSuccess()")
|
||||
Rel(authSvc, sessionRepo, "revokeOtherSessions() / revokeAllSessions()")
|
||||
Rel(rateLimiter, rateLimitProps, "Reads config")
|
||||
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@startuml
|
||||
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
|
||||
note over Browser, DB
|
||||
Phase 1 of the auth rewrite (ADR-020 / #523).
|
||||
Replaces the Basic-credentials-in-cookie model
|
||||
with an opaque server-side session id (fa_session).
|
||||
Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524).
|
||||
Adds CSRF double-submit cookies, login rate limiting, and
|
||||
session revocation on password change/reset.
|
||||
end note
|
||||
|
||||
actor User
|
||||
@@ -11,9 +11,10 @@ participant Browser
|
||||
participant "Caddy (TLS termination)" as Caddy
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter
|
||||
participant "spring_session\n(PostgreSQL)" as DB
|
||||
|
||||
== Login ==
|
||||
== Login (with rate limiting + CSRF bootstrap) ==
|
||||
User -> Browser: Enter email + password
|
||||
Browser -> Caddy: HTTPS POST /?/login (form action)
|
||||
note right of Caddy
|
||||
@@ -30,19 +31,46 @@ note right of Backend
|
||||
→ request.getScheme() = "https"
|
||||
→ Secure cookie flag set automatically.
|
||||
end note
|
||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure
|
||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip]
|
||||
alt Rate limit exceeded
|
||||
RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS)
|
||||
Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email})
|
||||
Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"}
|
||||
Frontend --> Browser: Show rate-limit error
|
||||
else Under limit
|
||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||
Backend -> RateLimiter: invalidateOnSuccess(ip, email)
|
||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=<token>;\n Path=/; SameSite=Strict; Secure
|
||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
end
|
||||
|
||||
== Authenticated request ==
|
||||
== Authenticated mutating request (CSRF double-submit) ==
|
||||
note over Browser, Backend
|
||||
handleFetch in hooks.client.ts reads the XSRF-TOKEN cookie
|
||||
and injects X-XSRF-TOKEN header on every POST/PUT/PATCH/DELETE.
|
||||
end note
|
||||
Browser -> Caddy: HTTPS POST /api/...\nCookie: fa_session=<opaque>; XSRF-TOKEN=<token>\nX-XSRF-TOKEN: <token>
|
||||
Caddy -> Backend: HTTP POST /api/...\n+ Cookie + X-XSRF-TOKEN
|
||||
alt X-XSRF-TOKEN missing or mismatched
|
||||
Backend --> Caddy: 403 Forbidden\n{"code":"CSRF_TOKEN_MISSING"}
|
||||
Caddy --> Browser: HTTPS 403
|
||||
else CSRF valid
|
||||
Backend -> DB: SELECT * FROM spring_session WHERE SESSION_ID = ?
|
||||
DB --> Backend: session row
|
||||
Backend -> Backend: Process request
|
||||
Backend --> Caddy: 2xx response + refreshed XSRF-TOKEN cookie
|
||||
Caddy --> Browser: HTTPS 2xx
|
||||
end
|
||||
|
||||
== Authenticated read request ==
|
||||
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
||||
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
|
||||
Frontend -> Frontend: hooks.server.ts reads fa_session
|
||||
@@ -61,6 +89,28 @@ else Session expired (idle > 8h) or unknown
|
||||
Caddy --> Browser: HTTPS 302
|
||||
end
|
||||
|
||||
== Password change (revoke other sessions) ==
|
||||
Browser -> Backend: POST /api/users/me/password\n{currentPassword, newPassword}\n+ X-XSRF-TOKEN
|
||||
Backend -> Backend: Verify currentPassword
|
||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||
Backend -> DB: DELETE spring_session WHERE principal = ?\n AND session_id != <current>
|
||||
note right of Backend
|
||||
revokeOtherSessions: caller stays logged in,
|
||||
all other devices are signed out.
|
||||
end note
|
||||
Backend --> Browser: 204 No Content
|
||||
|
||||
== Password reset (revoke all sessions) ==
|
||||
Browser -> Backend: POST /api/auth/reset-password\n{token, newPassword}
|
||||
Backend -> Backend: Verify reset token
|
||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||
Backend -> DB: DELETE spring_session WHERE principal = ?
|
||||
note right of Backend
|
||||
revokeAllSessions: unauthenticated caller has
|
||||
no session to preserve — all sessions wiped.
|
||||
end note
|
||||
Backend --> Browser: 204 No Content
|
||||
|
||||
== Logout ==
|
||||
Browser -> Caddy: HTTPS POST /logout
|
||||
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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);
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
@@ -106,7 +106,32 @@ let {
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||
{#if form?.rateLimited}
|
||||
<div
|
||||
role="alert"
|
||||
class="flex items-center gap-2 font-sans text-xs font-medium text-red-600"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="h-4 w-4 shrink-0 text-red-600"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{form.error}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div role="alert" class="text-center font-sans text-xs font-medium text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<button
|
||||
|
||||
@@ -100,6 +100,25 @@ describe('login action', () => {
|
||||
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
|
||||
});
|
||||
|
||||
it('returns 429 with rateLimited=true when the backend rate-limits the request', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 'TOO_MANY_LOGIN_ATTEMPTS' }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await (actions as ActionsRecord).login({
|
||||
request: makeRequest({ email: 'a@b.de', password: 'pw' }),
|
||||
cookies: makeCookies(),
|
||||
fetch: mockFetch,
|
||||
url: new URL('http://localhost/login')
|
||||
} as never);
|
||||
|
||||
expect((result as { status: number }).status).toBe(429);
|
||||
expect((result as { data: { rateLimited: boolean } }).data.rateLimited).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 500 when backend response omits fa_session cookie', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const cookies = makeCookies();
|
||||
|
||||
@@ -70,4 +70,16 @@ describe('login page', () => {
|
||||
.element(page.getByRole('link', { name: /passwort vergessen/i }))
|
||||
.toHaveAttribute('href', '/forgot-password');
|
||||
});
|
||||
|
||||
it('shows rate-limit alert with clock icon when rateLimited is true', async () => {
|
||||
render(LoginPage, {
|
||||
props: {
|
||||
data: { registered: false },
|
||||
form: { error: 'Zu viele Anmeldeversuche.', rateLimited: true }
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
await expect.element(page.getByText('Zu viele Anmeldeversuche.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.",
|
||||
"matchPackageNames": ["com.bucket4j:bucket4j-core"],
|
||||
"groupName": "bucket4j",
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@tiptap/"],
|
||||
"groupName": "tiptap",
|
||||
|
||||
Reference in New Issue
Block a user