Compare commits

..

24 Commits

Author SHA1 Message Date
Marcel
0514622f39 devops(deps): add bucket4j-core to Renovate package rules
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m13s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m10s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
bucket4j-core 8.10.1 is manually pinned in pom.xml outside the Spring BOM.
Adds a packageRules entry so Renovate tracks it: patch updates auto-merge,
minor/major updates open PRs for manual review.

Addresses Tobias Concern 1 from PR #617 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:32:54 +02:00
Marcel
24c85c29e4 test(login): add browser component test for rate-limited login UI state
Renders LoginPage with form.rateLimited=true and asserts that the
role="alert" div (clock icon + error message) is visible in the browser.
Previously only the form action's rateLimited=true return value was tested;
now the rendered UI is also verified.

Addresses Sara Concern 4 / Elicit open question from PR #617 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:32:07 +02:00
Marcel
778402fec7 test(auth): add integration-level CSRF rejection test; fix SessionRevocationPort wiring
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m11s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m21s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
Integration test:
- Adds post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING to
  AuthSessionIntegrationTest, verifying CSRF is active end-to-end (not just
  in @WebMvcTest slices).

SessionRevocationConfig (new):
- Replaces fragile @ConditionalOnBean/@ConditionalOnMissingBean on @Service
  beans with a single @Configuration @Bean method that accepts
  JdbcIndexedSessionRepository as @Autowired(required=false). Spring
  resolves the optional parameter reliably after auto-configuration fires,
  choosing JdbcSessionRevocationAdapter when available and
  NoOpSessionRevocationAdapter otherwise.
- JdbcSessionRevocationAdapter and NoOpSessionRevocationAdapter are now
  plain implementation classes (no @Service/@Conditional annotations).

Addresses Sara Concern 2 from PR #617 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:30:50 +02:00
Marcel
6db5c2d1c4 test(user): add CSRF failure tests for changePassword and forceLogout endpoints
Adds two @WebMvcTest assertions verifying that POST /api/users/me/password
and POST /api/users/{id}/force-logout without an XSRF-TOKEN header return
403 with code CSRF_TOKEN_MISSING.

Addresses Nora Concern 9 from PR #617 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:26:42 +02:00
Marcel
2f981ef69d refactor(test): use static imports for verify/assertThat in controller and rate-limiter tests
UserControllerTest: replaces fully-qualified org.mockito.Mockito.verify() and
ArgumentMatchers.eq() with the static imports already present in the file.
LoginRateLimiterTest: replaces three org.assertj.core.api.Assertions.assertThat()
calls with the static-import form; adds missing assertThat import.

Addresses Felix Suggestions 2 and 4 from PR #617 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:25:06 +02:00
Marcel
7074c9e4ad docs(architecture): update CSRF section and add CSRF_TOKEN_MISSING / TOO_MANY_LOGIN_ATTEMPTS error codes
- Remove stale "CSRF protection is disabled" claim; describe the double-submit
  cookie pattern now in use (CookieCsrfTokenRepository + X-XSRF-TOKEN header)
- Link to ADR-022 for the full rationale
- Add CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS to the exception row

Fixes Markus's blocker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:13 +02:00
Marcel
8eced9c9da refactor(auth): replace @Autowired(required=false) with SessionRevocationPort + constructor injection
Extract SessionRevocationPort interface with JdbcSessionRevocationAdapter
(@ConditionalOnBean) and NoOpSessionRevocationAdapter (@ConditionalOnMissingBean).
AuthService now uses @RequiredArgsConstructor with final fields for both
LoginRateLimiter and SessionRevocationPort, removing all null guards.
AuthServiceTest drops ReflectionTestUtils.setField and uses @Mock on the port.

Fixes Felix's blocker: @Autowired(required=false) field injection in AuthService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:22:17 +02:00
Marcel
28de7da9a6 refactor(user): migrate UserController to @RequiredArgsConstructor + final fields
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m5s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 2m58s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Semgrep Security Scan (pull_request) Successful in 18s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
The circular-dependency that originally forced @AllArgsConstructor was
removed when changePassword orchestration moved into the controller.
No cycle now exists between UserController, UserService, AuthService,
or AuditService — final fields and constructor injection are safe again.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:34:34 +02:00
Marcel
8189e14a4b fix(auth): normalise email to lowercase before rate-limit key lookup
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m2s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m1s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Case variants of the same address (e.g. User@EXAMPLE.COM vs user@example.com)
now share a single Bucket4j bucket, preventing a trivial bypass of per-email
limits via mixed-case submissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:43:19 +02:00
Marcel
bdc37b1156 docs(claude): add LoginRateLimiter and RateLimitProperties to auth package entry
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m8s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m4s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:27:08 +02:00
Marcel
314f686963 docs(arch): update security C4 diagram for CSRF + rate limiting
Remove stale "CSRF is disabled pending #524" note; update secFilter
description to reflect the enabled double-submit cookie pattern.
Add LoginRateLimiter and RateLimitProperties components with their
relationships to AuthService. Update frontend→secFilter rel to show
X-XSRF-TOKEN header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:26:29 +02:00
Marcel
a23fa4c668 fix(login): add role=alert to error divs; fix clock icon color to red
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m3s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m4s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
Regular error div was missing role="alert" — screen readers did not
announce it on dynamic display. Rate-limited clock icon used text-ink-3
(muted grey) instead of text-red-600, visually inconsistent with the
surrounding error text. Also removes the erroneous aria-invalid="true"
from the rate-limit alert div (not a permitted attribute on role=alert).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:02:24 +02:00
Marcel
05ab8b13a0 docs(arch): update auth sequence diagram to Phase 2 (CSRF, rate limit, revocation)
Extends the diagram from ADR-020 Phase 1 to cover:
- Rate limiter gate before credential validation in login
- CSRF double-submit cookie handshake for mutating requests
- Session revocation on password change (revokeOtherSessions) and
  password reset (revokeAllSessions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:41:15 +02:00
Marcel
1052295a6e docs(adr): add ADR-022 for CSRF, session revocation, and rate limiting
Documents the double-submit cookie CSRF pattern, sequential token-bucket
rate limiter with refund mechanic, and session revocation on password
change/reset — all implemented as part of issue #524.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:40:19 +02:00
Marcel
c3d1bea623 refactor(security): extract static ERROR_WRITER; update ADR ref to ADR-022
Replaces per-invocation new ObjectMapper() in the accessDeniedHandler
lambda with a static field (avoids repeated allocation). ObjectMapper
cannot be injected in SecurityConfig because @WebMvcTest slices exclude
JacksonAutoConfiguration; the static instance is safe since the response
only serialises fixed String keys.

Also corrects the ADR cross-reference in the CSRF comment from ADR-020
(Spring Session JDBC) to ADR-022 (CSRF + session revocation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:39:14 +02:00
Marcel
97585a9cd4 test(security): add CSRF rejection test to DocumentControllerTest
Adds regression coverage for the custom accessDeniedHandler in
SecurityConfig: a POST without X-XSRF-TOKEN returns 403 with error
code CSRF_TOKEN_MISSING, not a generic Spring 403.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:33:04 +02:00
Marcel
c32607e133 fix(auth): sequential rate-limit check with ipEmail token refund on IP failure
Addresses Felix (blocker 1): the old implementation consumed from both buckets
before checking either result, silently eroding the per-email quota when only the
per-IP limit was blocking. The fix checks ipEmail first, then IP; on IP failure it
refunds the ipEmail token so legitimate users behind a shared IP are not penalised.

Also adds two new test cases:
- different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion (Sara)
- ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts (red → green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:29:36 +02:00
Marcel
d7eca25eb7 fix(auth): guard revokeOtherSessions/revokeAllSessions against null sessionRepository
Addresses Nora (blocker 1) and Felix (suggestion): both revocation methods
now return 0 immediately when sessionRepository is unavailable (non-web
test contexts where JdbcHttpSessionAutoConfiguration does not fire).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:27:29 +02:00
Marcel
fdb9ae31ae feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m7s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m19s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
- handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating
  backend API requests (double-submit cookie pattern); generates a fresh
  UUID when no XSRF-TOKEN cookie exists yet
- ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS;
  getErrorMessage maps both to i18n keys
- de/en/es messages add error_csrf_token_missing and
  error_too_many_login_attempts translations
- Login action maps HTTP 429 to fail(429, { ..., rateLimited: true });
  page shows a muted clock icon with aria-invalid on rate-limit errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
14deae962a feat(auth): add Bucket4j + Caffeine login rate limiter (10/15 min per IP+email, 20/15 min per IP)
LoginRateLimiter uses two Caffeine LoadingCaches of Bucket4j buckets —
one keyed on IP:email (10 attempts/15 min) and one on IP alone (20/15 min
backstop). Exceeding either throws DomainException(TOO_MANY_LOGIN_ATTEMPTS)
and emits LOGIN_RATE_LIMITED audit. Successful login invalidates both
buckets via invalidateOnSuccess. Buckets expire after windowMinutes of
inactivity (no clock advance needed — Caffeine handles eviction).
AuthService integrates it as an optional @Autowired field so non-web
test contexts still work without a Caffeine dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
924c76f99f feat(auth): revoke all sessions on password reset
After updating the user password during a reset flow, calls
authService.revokeAllSessions(email) to invalidate every active session
for the account — prevents an attacker with a stolen session from
retaining access after the owner resets their password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
99a4230bb9 feat(auth): revoke other sessions on password change; add force-logout endpoint
changePassword now calls authService.revokeOtherSessions() after the
password is updated and emits a LOGOUT audit with reason=password_change.

POST /api/users/{id}/force-logout (ADMIN_USER permission) revokes all
sessions for the target user and emits ADMIN_FORCE_LOGOUT audit. Returns
{"revokedCount": N} with 200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
38818998e5 feat(auth): add revokeOtherSessions and revokeAllSessions to AuthService
Uses JdbcIndexedSessionRepository (optional field — null-safe in non-web
test contexts) to delete all sessions for a principal except the current
one (revokeOtherSessions) or all sessions unconditionally (revokeAllSessions).
Both methods return the count of deleted sessions for audit payloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
9b4da70f52 feat(security): enable CSRF protection with CookieCsrfTokenRepository
Re-enables Spring Security's CSRF filter (was disabled with a TODO comment).
Uses CookieCsrfTokenRepository so the frontend can read the XSRF-TOKEN
cookie and send it as X-XSRF-TOKEN on state-mutating requests.
Returns CSRF_TOKEN_MISSING error code on 403 instead of generic FORBIDDEN.
Updates all WebMvcTest classes to include .with(csrf()) on POST/PUT/PATCH/
DELETE/multipart requests, and fixes integration tests to supply the
XSRF-TOKEN cookie + header directly (lazy generation in Spring Security 7).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
55 changed files with 1277 additions and 640 deletions

View File

@@ -77,7 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
``` ```
backend/src/main/java/org/raddatz/familienarchiv/ backend/src/main/java/org/raddatz/familienarchiv/
├── audit/ Audit logging ├── 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) ├── config/ Infrastructure config (Minio, Async, Web)
├── dashboard/ Dashboard analytics + StatsController/StatsService ├── dashboard/ Dashboard analytics + StatsController/StatsService
├── document/ Document domain (entities, controller, service, repository, DTOs) ├── document/ Document domain (entities, controller, service, repository, DTOs)

View File

@@ -180,11 +180,16 @@
<artifactId>flyway-database-postgresql</artifactId> <artifactId>flyway-database-postgresql</artifactId>
</dependency> </dependency>
<!-- Caffeine cache for in-memory rate limiting --> <!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
</dependency> </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 --> <!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
<dependency> <dependency>

View File

@@ -43,8 +43,14 @@ public enum AuditKind {
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */ /** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
LOGIN_FAILED, LOGIN_FAILED,
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */ /** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */
LOGOUT; 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( public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -24,13 +24,18 @@ public class AuthService {
private final AuthenticationManager authenticationManager; private final AuthenticationManager authenticationManager;
private final UserService userService; private final UserService userService;
private final AuditService auditService; 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) { 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 { try {
Authentication auth = authenticationManager.authenticate( Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(email, password)); new UsernamePasswordAuthenticationToken(email, password));
@@ -40,6 +45,7 @@ public class AuthService {
"userId", user.getId().toString(), "userId", user.getId().toString(),
"ip", ip, "ip", ip,
"ua", truncateUa(ua))); "ua", truncateUa(ua)));
loginRateLimiter.invalidateOnSuccess(ip, email);
return new LoginResult(user, auth); return new LoginResult(user, auth);
} catch (AuthenticationException ex) { } catch (AuthenticationException ex) {
// Audit login failure — intentionally does NOT log the attempted password. // 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) { public void logout(String email, String ip, String ua) {
AppUser user = userService.findByEmail(email); AppUser user = userService.findByEmail(email);
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of( auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.auth;
public interface SessionRevocationPort {
int revokeOtherSessions(String currentSessionId, String principalName);
int revokeAllSessions(String principalName);
}

View File

@@ -55,4 +55,8 @@ public class DomainException extends RuntimeException {
public static DomainException internal(ErrorCode code, String message) { public static DomainException internal(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, 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);
}
} }

View File

@@ -68,6 +68,10 @@ public enum ErrorCode {
SESSION_EXPIRED, SESSION_EXPIRED,
/** The password-reset token is missing, expired, or already used. 400 */ /** The password-reset token is missing, expired, or already used. 400 */
INVALID_RESET_TOKEN, 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 --- // --- Annotations ---
/** The annotation with the given ID does not exist. 404 */ /** The annotation with the given ID does not exist. 404 */

View File

@@ -1,8 +1,6 @@
package org.raddatz.familienarchiv.importing; package org.raddatz.familienarchiv.importing;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*; import org.apache.poi.ss.usermodel.*;
@@ -33,7 +31,6 @@ import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -56,27 +53,9 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED } public enum State { IDLE, RUNNING, DONE, FAILED }
public record SkippedFile( public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
) {}
public record ImportStatus( private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
@JsonIgnore String message,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
LocalDateTime startedAt
) {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@JsonProperty("skipped")
public int skipped() { return skippedFiles.size(); }
}
record ProcessResult(int processed, List<SkippedFile> skippedFiles) {}
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
public ImportStatus getStatus() { public ImportStatus getStatus() {
return currentStatus; return currentStatus;
@@ -138,22 +117,22 @@ public class MassImportService {
if (currentStatus.state() == State.RUNNING) { if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress"); throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
} }
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now()); currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
try { try {
File spreadsheet = findSpreadsheetFile(); File spreadsheet = findSpreadsheetFile();
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath()); log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
ProcessResult result = processRows(readSpreadsheet(spreadsheet)); int processed = processRows(readSpreadsheet(spreadsheet));
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE", currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.", "Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
result.processed(), result.skippedFiles(), currentStatus.startedAt()); processed, currentStatus.startedAt());
} catch (NoSpreadsheetException e) { } catch (NoSpreadsheetException e) {
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e); log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET", currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt()); "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
} catch (Exception e) { } catch (Exception e) {
log.error("Massenimport fehlgeschlagen", e); log.error("Massenimport fehlgeschlagen", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL", currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt()); "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
} }
} }
@@ -275,10 +254,8 @@ public class MassImportService {
// --- Import logic (works on neutral List<String> rows) --- // --- Import logic (works on neutral List<String> rows) ---
private ProcessResult processRows(List<List<String>> rows) { private int processRows(List<List<String>> rows) {
int processed = 0; int count = 0;
List<SkippedFile> skippedFiles = new ArrayList<>();
for (int i = 1; i < rows.size(); i++) { // skip header row for (int i = 1; i < rows.size(); i++) { // skip header row
List<String> cells = rows.get(i); List<String> cells = rows.get(i);
String index = getCell(cells, colIndex); String index = getCell(cells, colIndex);
@@ -289,51 +266,18 @@ public class MassImportService {
if (fileOnDisk.isEmpty()) { if (fileOnDisk.isEmpty()) {
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename); log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
} }
importSingleDocument(cells, fileOnDisk, filename, index);
if (fileOnDisk.isPresent()) { count++;
try {
if (!isPdfMagicBytes(fileOnDisk.get())) {
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
continue;
}
} catch (IOException e) {
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
continue;
}
}
boolean imported = importSingleDocument(cells, fileOnDisk, filename, index);
if (imported) {
processed++;
}
}
return new ProcessResult(processed, skippedFiles);
}
// package-private: Mockito spy in tests can override to inject IOException
InputStream openFileStream(File file) throws IOException {
return new FileInputStream(file);
}
private boolean isPdfMagicBytes(File file) throws IOException {
try (InputStream is = openFileStream(file)) {
byte[] header = is.readNBytes(4);
return header.length == 4
&& header[0] == 0x25 // %
&& header[1] == 0x50 // P
&& header[2] == 0x44 // D
&& header[3] == 0x46; // F
} }
return count;
} }
@Transactional @Transactional
protected boolean importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) { protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename); Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) { if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename); log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
return false; return;
} }
String archiveBox = getCell(cells, colBox); String archiveBox = getCell(cells, colBox);
@@ -369,7 +313,7 @@ public class MassImportService {
status = DocumentStatus.UPLOADED; status = DocumentStatus.UPLOADED;
} catch (Exception e) { } catch (Exception e) {
log.error("S3 Upload Fehler für {}", file.get().getName(), e); log.error("S3 Upload Fehler für {}", file.get().getName(), e);
return false; return;
} }
} }
@@ -411,7 +355,6 @@ public class MassImportService {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
} }
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename); log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
return true;
} }
// --- Helpers --- // --- Helpers ---

View File

@@ -1,7 +1,9 @@
package org.raddatz.familienarchiv.security; package org.raddatz.familienarchiv.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.user.CustomUserDetailsService; import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean; 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.SecurityFilterChain;
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy; import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; 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 @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { 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 CustomUserDetailsService userDetailsService;
private final Environment environment; private final Environment environment;
@@ -78,15 +90,13 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
// CSRF is intentionally disabled. The session model relies on: // CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
// 1. SameSite=Strict on the fa_session cookie — a cross-site POST from // The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
// evil.com cannot include the cookie. // All state-changing requests must include X-XSRF-TOKEN matching the cookie.
// 2. CORS — Spring's default rejects cross-origin requests with credentials // See ADR-022 and issue #524 for the full security rationale.
// unless explicitly allowed (no allowedOrigins config). .csrf(csrf -> csrf
// .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// If either of those is ever weakened, CSRF protection MUST be re-enabled. .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
// Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524).
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> { .authorizeHttpRequests(auth -> {
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above. // Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
@@ -112,10 +122,18 @@ public class SecurityConfig {
// erlaubt pdf im Iframe // erlaubt pdf im Iframe
.headers(headers -> headers .headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.sameOrigin())) .frameOptions(frameOptions -> frameOptions.sameOrigin()))
// Return 401 (not 302 redirect to /login) for unauthenticated API requests. // Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures.
// httpBasic and formLogin are removed — authentication is via Spring Session only. .exceptionHandling(ex -> ex
.exceptionHandling(ex -> ex.authenticationEntryPoint( .authenticationEntryPoint(
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))); (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(); return http.build();
} }

View File

@@ -5,6 +5,7 @@ import java.time.LocalDateTime;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
import org.raddatz.familienarchiv.auth.AuthService;
import org.raddatz.familienarchiv.user.ResetPasswordRequest; import org.raddatz.familienarchiv.user.ResetPasswordRequest;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -32,6 +33,7 @@ public class PasswordResetService {
private final UserService userService; private final UserService userService;
private final PasswordResetTokenRepository tokenRepository; private final PasswordResetTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuthService authService;
@Autowired(required = false) @Autowired(required = false)
private JavaMailSender mailSender; private JavaMailSender mailSender;
@@ -85,6 +87,8 @@ public class PasswordResetService {
resetToken.setUsed(true); resetToken.setUsed(true);
tokenRepository.save(resetToken); tokenRepository.save(resetToken);
authService.revokeAllSessions(user.getEmail());
} }
/** /**

View File

@@ -4,7 +4,11 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid; 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.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.user.ChangePasswordDTO; import org.raddatz.familienarchiv.user.ChangePasswordDTO;
import org.raddatz.familienarchiv.user.CreateUserRequest; 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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import lombok.AllArgsConstructor; import lombok.RequiredArgsConstructor;
@RestController @RestController
@RequestMapping("/api/") @RequestMapping("/api/")
@AllArgsConstructor @RequiredArgsConstructor
public class UserController { public class UserController {
private UserService userService; private final UserService userService;
private final AuthService authService;
private final AuditService auditService;
@GetMapping("users/me") @GetMapping("users/me")
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) { public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
@@ -56,9 +62,14 @@ public class UserController {
@PostMapping("users/me/password") @PostMapping("users/me/password")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void changePassword(Authentication authentication, public void changePassword(Authentication authentication,
HttpSession session,
@RequestBody ChangePasswordDTO dto) { @RequestBody ChangePasswordDTO dto) {
AppUser current = userService.findByEmail(authentication.getName()); AppUser current = userService.findByEmail(authentication.getName());
userService.changePassword(current.getId(), dto); 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}") @GetMapping("users/{id}")
@@ -101,6 +112,18 @@ public class UserController {
return ResponseEntity.ok().build(); 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) { private UUID actorId(Authentication auth) {
return userService.findByEmail(auth.getName()).getId(); return userService.findByEmail(auth.getName()).getId();
} }

View File

@@ -150,3 +150,9 @@ sentry:
enable-tracing: true enable-tracing: true
ignored-exceptions-for-type: ignored-exceptions-for-type:
- org.raddatz.familienarchiv.exception.DomainException - org.raddatz.familienarchiv.exception.DomainException
rate-limit:
login:
max-attempts-per-ip-email: 10
max-attempts-per-ip: 20
window-minutes: 15

View File

@@ -16,7 +16,6 @@ import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -31,6 +30,8 @@ class AuthServiceTest {
@Mock AuthenticationManager authenticationManager; @Mock AuthenticationManager authenticationManager;
@Mock UserService userService; @Mock UserService userService;
@Mock AuditService auditService; @Mock AuditService auditService;
@Mock LoginRateLimiter loginRateLimiter;
@Mock SessionRevocationPort sessionRevocationPort;
@InjectMocks AuthService authService; @InjectMocks AuthService authService;
private static final String IP = "127.0.0.1"; private static final String IP = "127.0.0.1";
@@ -129,4 +130,62 @@ class AuthServiceTest {
&& !payload.containsKey("password")) && !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");
}
} }

View File

@@ -23,6 +23,7 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -48,6 +49,7 @@ class AuthSessionControllerTest {
.thenReturn(new LoginResult(appUser, auth)); .thenReturn(new LoginResult(appUser, auth));
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}")) .content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -61,6 +63,7 @@ class AuthSessionControllerTest {
.thenThrow(DomainException.invalidCredentials()); .thenThrow(DomainException.invalidCredentials());
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isUnauthorized())
@@ -77,6 +80,7 @@ class AuthSessionControllerTest {
// No WithMockUser — must be reachable without an active session // No WithMockUser — must be reachable without an active session
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}")) .content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -91,6 +95,7 @@ class AuthSessionControllerTest {
.thenReturn(new LoginResult(appUser, auth)); .thenReturn(new LoginResult(appUser, auth));
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}")) .content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -116,6 +121,7 @@ class AuthSessionControllerTest {
.thenReturn(new LoginResult(appUser, auth)); .thenReturn(new LoginResult(appUser, auth));
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}")) .content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -131,12 +137,24 @@ class AuthSessionControllerTest {
.thenThrow(DomainException.invalidCredentials()); .thenThrow(DomainException.invalidCredentials());
mockMvc.perform(post("/api/auth/login") mockMvc.perform(post("/api/auth/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}")) .content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isUnauthorized())
.andExpect(header().doesNotExist("Set-Cookie")); .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 ───────────────────────────────────────────────── // ─── POST /api/auth/logout ─────────────────────────────────────────────────
@Test @Test
@@ -144,15 +162,18 @@ class AuthSessionControllerTest {
doNothing().when(authService).logout(anyString(), anyString(), anyString()); doNothing().when(authService).logout(anyString(), anyString(), anyString());
mockMvc.perform(post("/api/auth/logout") mockMvc.perform(post("/api/auth/logout")
.with(user("user@test.de"))) .with(user("user@test.de"))
.with(csrf()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@Test @Test
void logout_returns_401_when_not_authenticated() throws Exception { void logout_without_session_returns_403() throws Exception {
// No authentication at all — Spring Security must return 401 // CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null,
// ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403.
mockMvc.perform(post("/api/auth/logout")) mockMvc.perform(post("/api/auth/logout"))
.andExpect(status().isUnauthorized()); .andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
} }
@Test @Test
@@ -163,7 +184,8 @@ class AuthSessionControllerTest {
.when(authService).logout(anyString(), anyString(), anyString()); .when(authService).logout(anyString(), anyString(), anyString());
mockMvc.perform(post("/api/auth/logout") mockMvc.perform(post("/api/auth/logout")
.with(user("ghost@test.de"))) .with(user("ghost@test.de"))
.with(csrf()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
} }

View File

@@ -62,7 +62,8 @@ class AuthSessionIntegrationTest {
@Test @Test
void login_sets_opaque_fa_session_cookie() { 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); assertThat(response.getStatusCode().value()).isEqualTo(200);
String cookie = extractFaSessionCookie(response); String cookie = extractFaSessionCookie(response);
@@ -73,7 +74,8 @@ class AuthSessionIntegrationTest {
@Test @Test
void session_cookie_authenticates_subsequent_request() { void session_cookie_authenticates_subsequent_request() {
String cookie = extractFaSessionCookie(doLogin()); String xsrf = fetchXsrfToken();
String cookie = extractFaSessionCookie(doLogin(xsrf));
ResponseEntity<String> me = http.exchange( ResponseEntity<String> me = http.exchange(
baseUrl + "/api/users/me", HttpMethod.GET, baseUrl + "/api/users/me", HttpMethod.GET,
@@ -84,16 +86,17 @@ class AuthSessionIntegrationTest {
@Test @Test
void logout_invalidates_session_and_cookie_returns_401_on_reuse() { 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( ResponseEntity<Void> logout = http.postForEntity(
baseUrl + "/api/auth/logout", baseUrl + "/api/auth/logout",
new HttpEntity<>(cookieHeaders(cookie)), Void.class); new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class);
assertThat(logout.getStatusCode().value()).isEqualTo(204); assertThat(logout.getStatusCode().value()).isEqualTo(204);
ResponseEntity<String> me = http.exchange( ResponseEntity<String> me = http.exchange(
baseUrl + "/api/users/me", HttpMethod.GET, baseUrl + "/api/users/me", HttpMethod.GET,
new HttpEntity<>(cookieHeaders(cookie)), String.class); new HttpEntity<>(cookieHeaders(sessionCookie)), String.class);
assertThat(me.getStatusCode().value()).isEqualTo(401); assertThat(me.getStatusCode().value()).isEqualTo(401);
} }
@@ -101,7 +104,8 @@ class AuthSessionIntegrationTest {
@Test @Test
void session_expired_by_idle_timeout_returns_401() { 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 // Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000; long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
@@ -115,11 +119,37 @@ class AuthSessionIntegrationTest {
assertThat(me.getStatusCode().value()).isEqualTo(401); 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(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); 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 + "\"}"; String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
return http.postForEntity(baseUrl + "/api/auth/login", return http.postForEntity(baseUrl + "/api/auth/login",
new HttpEntity<>(body, headers), String.class); new HttpEntity<>(body, headers), String.class);
@@ -131,6 +161,13 @@ class AuthSessionIntegrationTest {
return headers; 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) { private String extractFaSessionCookie(ResponseEntity<?> response) {
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie"); List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
if (setCookieHeader == null) return ""; if (setCookieHeader == null) return "";
@@ -141,6 +178,7 @@ class AuthSessionIntegrationTest {
.orElse(""); .orElse("");
} }
private RestTemplate noThrowRestTemplate() { private RestTemplate noThrowRestTemplate() {
RestTemplate template = new RestTemplate(); RestTemplate template = new RestTemplate();
template.setErrorHandler(new DefaultResponseErrorHandler() { template.setErrorHandler(new DefaultResponseErrorHandler() {

View File

@@ -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");
}
}

View File

@@ -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"));
}
}

View File

@@ -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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; 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.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.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(DocumentController.class) @WebMvcTest(DocumentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -214,14 +216,14 @@ class DocumentControllerTest {
@Test @Test
void createDocument_returns401_whenUnauthenticated() throws Exception { void createDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents")) mockMvc.perform(multipart("/api/documents").with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void createDocument_returns403_whenMissingWritePermission() throws Exception { void createDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents")) mockMvc.perform(multipart("/api/documents").with(csrf()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -235,7 +237,7 @@ class DocumentControllerTest {
.build(); .build();
when(documentService.createDocument(any(), any())).thenReturn(doc); when(documentService.createDocument(any(), any())).thenReturn(doc);
mockMvc.perform(multipart("/api/documents")) mockMvc.perform(multipart("/api/documents").with(csrf()))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -244,7 +246,7 @@ class DocumentControllerTest {
@Test @Test
void updateDocument_returns401_whenUnauthenticated() throws Exception { void updateDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) 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()); .andExpect(status().isUnauthorized());
} }
@@ -252,7 +254,7 @@ class DocumentControllerTest {
@WithMockUser @WithMockUser
void updateDocument_returns403_whenMissingWritePermission() throws Exception { void updateDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) 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()); .andExpect(status().isForbidden());
} }
@@ -269,7 +271,7 @@ class DocumentControllerTest {
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc); when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
mockMvc.perform(multipart("/api/documents/" + id) 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()); .andExpect(status().isOk());
} }
@@ -278,7 +280,7 @@ class DocumentControllerTest {
@Test @Test
void deleteDocument_returns401_whenUnauthenticated() throws Exception { void deleteDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID())) .delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -286,7 +288,7 @@ class DocumentControllerTest {
@WithMockUser @WithMockUser
void deleteDocument_returns403_whenMissingWritePermission() throws Exception { void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID())) .delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -295,7 +297,7 @@ class DocumentControllerTest {
void deleteDocument_returns204_whenHasWritePermission() throws Exception { void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id)) .delete("/api/documents/" + id).with(csrf()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@@ -303,14 +305,14 @@ class DocumentControllerTest {
@Test @Test
void quickUpload_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void quickUpload_returns403_whenMissingWritePermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@@ -326,7 +328,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); 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(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("scan001")) .andExpect(jsonPath("$.created[0].title").value("scan001"))
.andExpect(jsonPath("$.updated").isEmpty()) .andExpect(jsonPath("$.updated").isEmpty())
@@ -345,7 +347,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); 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(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief")) .andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
@@ -360,7 +362,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("files", "report.docx", new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1}); "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(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.errors[0].filename").value("report.docx")) .andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
@@ -490,7 +492,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception { 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(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated").isEmpty()) .andExpect(jsonPath("$.updated").isEmpty())
@@ -640,7 +642,7 @@ class DocumentControllerTest {
@Test @Test
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -649,7 +651,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -659,7 +661,7 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception { void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
UUID id = UUID.randomUUID(); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
@@ -671,7 +673,7 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception { void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
UUID id = UUID.randomUUID(); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}")) .content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
@@ -682,7 +684,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}")) .content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -696,7 +698,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); 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()); .andExpect(status().isForbidden());
} }
@@ -713,7 +715,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); 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(status().isOk())
.andExpect(jsonPath("$.id").value(id.toString())) .andExpect(jsonPath("$.id").value(id.toString()))
.andExpect(jsonPath("$.status").value("UPLOADED")); .andExpect(jsonPath("$.status").value("UPLOADED"));
@@ -726,7 +728,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile( new org.springframework.mock.web.MockMultipartFile(
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes()); "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()); .andExpect(status().isBadRequest());
} }
@@ -743,7 +745,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); 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()); .andExpect(status().isNotFound());
} }
@@ -800,7 +802,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes()); ("{\"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(status().isOk())
.andExpect(jsonPath("$.created.length()").value(3)) .andExpect(jsonPath("$.created.length()").value(3))
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString())) .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", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes()); ("{\"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(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString())) .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", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes()); "{\"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(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("Alpha")) .andExpect(jsonPath("$.created[0].title").value("Alpha"))
.andExpect(jsonPath("$.created[1].title").value("Beta")) .andExpect(jsonPath("$.created[1].title").value("Beta"))
@@ -883,7 +885,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes()); "{\"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()); .andExpect(status().isBadRequest());
} }
@@ -904,7 +906,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes()); "{\"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()); .andExpect(status().isOk());
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames()) org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
@@ -926,7 +928,7 @@ class DocumentControllerTest {
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1})); "files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
} }
mockMvc.perform(builder) mockMvc.perform(builder.with(csrf()))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE")); .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
} }
@@ -945,7 +947,7 @@ class DocumentControllerTest {
@Test @Test
void patchBulk_returns401_whenUnauthenticated() throws Exception { void patchBulk_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/bulk") mockMvc.perform(patch("/api/documents/bulk").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString()))) .content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -954,7 +956,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void patchBulk_returns403_forReadAllUser() throws Exception { void patchBulk_returns403_forReadAllUser() throws Exception {
mockMvc.perform(patch("/api/documents/bulk") mockMvc.perform(patch("/api/documents/bulk").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString()))) .content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -965,7 +967,7 @@ class DocumentControllerTest {
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception { void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"documentIds\":[]}")) .content("{\"documentIds\":[]}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -976,7 +978,7 @@ class DocumentControllerTest {
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception { void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -990,7 +992,7 @@ class DocumentControllerTest {
String[] ids = new String[501]; String[] ids = new String[501];
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString(); 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) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids))) .content(bulkBody(ids)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -1009,7 +1011,7 @@ class DocumentControllerTest {
String tooLong = "x".repeat(256); String tooLong = "x".repeat(256);
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}"; String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
mockMvc.perform(patch("/api/documents/bulk") mockMvc.perform(patch("/api/documents/bulk").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -1025,7 +1027,7 @@ class DocumentControllerTest {
String[] ids = new String[500]; String[] ids = new String[500];
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString(); 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) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids))) .content(bulkBody(ids)))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1042,7 +1044,7 @@ class DocumentControllerTest {
// Same id sent three times — controller should dedupe and call the // Same id sent three times — controller should dedupe and call the
// service exactly once, returning updated=1, not 3. // 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) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id.toString(), id.toString(), id.toString()))) .content(bulkBody(id.toString(), id.toString(), id.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1061,7 +1063,7 @@ class DocumentControllerTest {
when(documentService.applyBulkEditToDocument(any(), any(), any())) when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build()); .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) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id1.toString(), id2.toString()))) .content(bulkBody(id1.toString(), id2.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1137,7 +1139,7 @@ class DocumentControllerTest {
void batchMetadata_returns401_whenUnauthenticated() throws Exception { void batchMetadata_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}")) .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -1146,7 +1148,7 @@ class DocumentControllerTest {
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception { void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}")) .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -1155,7 +1157,7 @@ class DocumentControllerTest {
void batchMetadata_returns400_whenIdsEmpty() throws Exception { void batchMetadata_returns400_whenIdsEmpty() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[]}")) .content("{\"ids\":[]}").with(csrf()))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@@ -1172,7 +1174,7 @@ class DocumentControllerTest {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(sb.toString())) .content(sb.toString()).with(csrf()))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); .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") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + id + "\"]}")) .content("{\"ids\":[\"" + id + "\"]}").with(csrf()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString())) .andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Brief")) .andExpect(jsonPath("$[0].title").value("Brief"))
@@ -1208,7 +1210,7 @@ class DocumentControllerTest {
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
"evil\r\nFAKE LOG ENTRY: admin logged in")); "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) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(badId.toString()))) .content(bulkBody(badId.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1232,7 +1234,7 @@ class DocumentControllerTest {
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound( .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId)); 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) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(okId.toString(), badId.toString()))) .content(bulkBody(okId.toString(), badId.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1337,4 +1339,16 @@ class DocumentControllerTest {
DocumentStatus.REVIEWED, DocumentStatus.REVIEWED,
org.raddatz.familienarchiv.tag.TagOperator.AND))); 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()));
}
} }

View File

@@ -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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(AnnotationController.class) @WebMvcTest(AnnotationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -67,7 +68,7 @@ class AnnotationControllerTest {
@Test @Test
void createAnnotation_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -76,7 +77,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -92,7 +93,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
@@ -101,7 +102,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception { 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()); .andExpect(status().isNoContent());
} }
@@ -115,7 +116,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -133,7 +134,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
@@ -143,28 +144,28 @@ class AnnotationControllerTest {
@Test @Test
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { 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()); .andExpect(status().isNoContent());
} }
@@ -174,7 +175,7 @@ class AnnotationControllerTest {
@Test @Test
void patchAnnotation_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -183,7 +184,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void patchAnnotation_returns403_withoutPermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -199,7 +200,7 @@ class AnnotationControllerTest {
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); 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) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -217,7 +218,7 @@ class AnnotationControllerTest {
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); 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) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -229,7 +230,7 @@ class AnnotationControllerTest {
when(annotationService.updateAnnotation(any(), any(), any())) when(annotationService.updateAnnotation(any(), any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found")); .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) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -238,7 +239,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"x\":-0.1,\"y\":0.3}")) .content("{\"x\":-0.1,\"y\":0.3}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -247,7 +248,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"width\":0.005}")) .content("{\"width\":0.005}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -256,7 +257,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"height\":0.005}")) .content("{\"height\":0.005}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -265,7 +266,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withXAboveMaximum() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"x\":1.1}")) .content("{\"x\":1.1}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -276,7 +277,7 @@ class AnnotationControllerTest {
@Test @Test
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception { void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
// authentication == null → resolveUserId returns null // 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -294,7 +295,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
@@ -312,7 +313,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());

View File

@@ -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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(CommentController.class) @WebMvcTest(CommentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.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(); .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.blockId").value(blockId.toString())); .andExpect(jsonPath("$.blockId").value(blockId.toString()));
@@ -79,7 +80,7 @@ class CommentControllerTest {
@Test @Test
void postBlockComment_returns401_whenUnauthenticated() throws Exception { void postBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID(); 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -88,7 +89,7 @@ class CommentControllerTest {
@WithMockUser @WithMockUser
void postBlockComment_returns403_whenMissingPermission() throws Exception { void postBlockComment_returns403_whenMissingPermission() throws Exception {
UUID blockId = UUID.randomUUID(); 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -101,7 +102,7 @@ class CommentControllerTest {
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -116,7 +117,7 @@ class CommentControllerTest {
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build(); .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -127,7 +128,7 @@ class CommentControllerTest {
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception { void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID" 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@@ -136,7 +137,7 @@ class CommentControllerTest {
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception { void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID(); UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -151,7 +152,7 @@ class CommentControllerTest {
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -166,7 +167,7 @@ class CommentControllerTest {
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -175,7 +176,7 @@ class CommentControllerTest {
@Test @Test
void editComment_returns401_whenUnauthenticated() throws Exception { 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -187,7 +188,7 @@ class CommentControllerTest {
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -199,7 +200,7 @@ class CommentControllerTest {
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); 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)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -208,14 +209,14 @@ class CommentControllerTest {
@Test @Test
void deleteComment_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception { 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()); .andExpect(status().isNoContent());
} }
} }

View File

@@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(TranscriptionBlockController.class) @WebMvcTest(TranscriptionBlockController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -143,7 +144,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
void createBlock_returns401_whenUnauthenticated() throws Exception { void createBlock_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post(URL_BASE) mockMvc.perform(post(URL_BASE).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -152,7 +153,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception { void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(post(URL_BASE) mockMvc.perform(post(URL_BASE).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -164,7 +165,7 @@ class TranscriptionBlockControllerTest {
when(userService.findByEmail(any())).thenReturn(mockUser()); when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock()); 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) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -177,7 +178,7 @@ class TranscriptionBlockControllerTest {
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception { void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null); when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(post(URL_BASE) mockMvc.perform(post(URL_BASE).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -192,7 +193,7 @@ class TranscriptionBlockControllerTest {
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID() + "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
+ "\",\"displayName\":\"" + longName + "\"}]}"; + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(post(URL_BASE) mockMvc.perform(post(URL_BASE).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .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\"," String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; + "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(post(URL_BASE) mockMvc.perform(post(URL_BASE).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -217,7 +218,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
void updateBlock_returns401_whenUnauthenticated() throws Exception { void updateBlock_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_BLOCK) mockMvc.perform(put(URL_BLOCK).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -226,7 +227,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception { void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_BLOCK) mockMvc.perform(put(URL_BLOCK).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -243,7 +244,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any())) when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
.thenReturn(updated); .thenReturn(updated);
mockMvc.perform(put(URL_BLOCK) mockMvc.perform(put(URL_BLOCK).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -259,7 +260,7 @@ class TranscriptionBlockControllerTest {
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\"" String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}"; + UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(put(URL_BLOCK) mockMvc.perform(put(URL_BLOCK).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -272,7 +273,7 @@ class TranscriptionBlockControllerTest {
when(userService.findByEmail(any())).thenReturn(mockUser()); when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; 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) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -286,7 +287,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.updateBlock(any(), any(), any(), any())) when(transcriptionService.updateBlock(any(), any(), any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); .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) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -297,7 +298,7 @@ class TranscriptionBlockControllerTest {
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception { void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null); when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_BLOCK) mockMvc.perform(put(URL_BLOCK).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -307,28 +308,28 @@ class TranscriptionBlockControllerTest {
@Test @Test
void deleteBlock_returns401_whenUnauthenticated() throws Exception { void deleteBlock_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete(URL_BLOCK)) mockMvc.perform(delete(URL_BLOCK).with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception { void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK)) mockMvc.perform(delete(URL_BLOCK).with(csrf()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception { void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK)) mockMvc.perform(delete(URL_BLOCK).with(csrf()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception { void deleteBlock_returns204_whenAuthorised() throws Exception {
mockMvc.perform(delete(URL_BLOCK)) mockMvc.perform(delete(URL_BLOCK).with(csrf()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@@ -339,7 +340,7 @@ class TranscriptionBlockControllerTest {
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")) DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
.when(transcriptionService).deleteBlock(any(), any()); .when(transcriptionService).deleteBlock(any(), any());
mockMvc.perform(delete(URL_BLOCK)) mockMvc.perform(delete(URL_BLOCK).with(csrf()))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@@ -347,7 +348,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
void reorderBlocks_returns401_whenUnauthenticated() throws Exception { void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REORDER) mockMvc.perform(put(URL_REORDER).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON)) .content(REORDER_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -356,7 +357,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception { void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REORDER) mockMvc.perform(put(URL_REORDER).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON)) .content(REORDER_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -367,7 +368,7 @@ class TranscriptionBlockControllerTest {
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock())); 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) .contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON)) .content(REORDER_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -434,7 +435,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed); when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review", mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
DOC_ID, BLOCK_ID)) DOC_ID, BLOCK_ID).with(csrf()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.reviewed").value(true)); .andExpect(jsonPath("$.reviewed").value(true));
} }
@@ -445,14 +446,14 @@ class TranscriptionBlockControllerTest {
@Test @Test
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception { void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL)) mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception { void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL)) mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -469,7 +470,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of(b1, b2)); .thenReturn(List.of(b1, b2));
mockMvc.perform(put(URL_REVIEW_ALL)) mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].reviewed").value(true)) .andExpect(jsonPath("$[0].reviewed").value(true))
@@ -483,7 +484,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of()); .thenReturn(List.of());
mockMvc.perform(put(URL_REVIEW_ALL)) mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty()); .andExpect(jsonPath("$").isEmpty());
@@ -494,7 +495,7 @@ class TranscriptionBlockControllerTest {
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception { void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null); when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_REVIEW_ALL)) mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
} }

View File

@@ -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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(GeschichteController.class) @WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -130,7 +131,7 @@ class GeschichteControllerTest {
@Test @Test
void create_returns401_whenUnauthenticated() throws Exception { void create_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten") mockMvc.perform(post("/api/geschichten").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}")) .content("{\"title\":\"x\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -139,7 +140,7 @@ class GeschichteControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void create_returns403_whenLackingBlogWrite() throws Exception { void create_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten") mockMvc.perform(post("/api/geschichten").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}")) .content("{\"title\":\"x\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -155,7 +156,7 @@ class GeschichteControllerTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New"); dto.setTitle("New");
mockMvc.perform(post("/api/geschichten") mockMvc.perform(post("/api/geschichten").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto))) .content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -167,7 +168,7 @@ class GeschichteControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void update_returns403_whenLackingBlogWrite() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -180,7 +181,7 @@ class GeschichteControllerTest {
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated")); .thenReturn(published(id, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}", id) mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}")) .content("{\"status\":\"PUBLISHED\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -192,7 +193,7 @@ class GeschichteControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void delete_returns403_whenLackingBlogWrite() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@@ -201,7 +202,7 @@ class GeschichteControllerTest {
void delete_returns204_withBlogWrite() throws Exception { void delete_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}", id)) mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
verify(geschichteService).delete(id); verify(geschichteService).delete(id);

View File

@@ -135,7 +135,7 @@ class MassImportServiceTest {
@Test @Test
void runImportAsync_throwsConflict_whenAlreadyRunning() { void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus( MassImportService.ImportStatus running = new MassImportService.ImportStatus(
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now()); MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running); ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync()) assertThatThrownBy(() -> service.runImportAsync())
@@ -325,8 +325,8 @@ class MassImportServiceTest {
@Test @Test
void processRows_returnsZero_whenOnlyHeaderRow() { void processRows_returnsZero_whenOnlyHeaderRow() {
List<List<String>> rows = List.of(List.of("header", "col1")); List<List<String>> rows = List.of(List.of("header", "col1"));
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(0); assertThat(result).isEqualTo(0);
} }
@Test @Test
@@ -335,8 +335,8 @@ class MassImportServiceTest {
List.of("header"), List.of("header"),
minimalCells("") // blank index minimalCells("") // blank index
); );
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(0); assertThat(result).isEqualTo(0);
verify(documentService, never()).findByOriginalFilename(any()); verify(documentService, never()).findByOriginalFilename(any());
} }
@@ -349,9 +349,9 @@ class MassImportServiceTest {
List.of("header"), List.of("header"),
minimalCells("doc001") // no dot → appends ".pdf" minimalCells("doc001") // no dot → appends ".pdf"
); );
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1); assertThat(result).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc001.pdf"); verify(documentService).findByOriginalFilename("doc001.pdf");
} }
@@ -364,9 +364,9 @@ class MassImportServiceTest {
List.of("header"), List.of("header"),
minimalCells("doc002.pdf") // has dot → used as-is minimalCells("doc002.pdf") // has dot → used as-is
); );
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1); assertThat(result).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc002.pdf"); verify(documentService).findByOriginalFilename("doc002.pdf");
} }
@@ -525,67 +525,6 @@ class MassImportServiceTest {
assertThat(result).isEqualTo("hello"); assertThat(result).isEqualTo("hello");
} }
// ─── PDF magic byte validation regression ─────────────────────────────────
@Test
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
}
@Test
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename)
.contains("fake.pdf");
}
@Test
void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
buildMinimalImportXlsx(tempDir, "tiny.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
}
@Test
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
MassImportService spyService = spy(service);
doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
spyService.runImportAsync();
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
assertThat(spyService.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly("FILE_READ_ERROR");
}
// ─── readOds — XXE security regression ─────────────────────────────────── // ─── readOds — XXE security regression ───────────────────────────────────
// Security regression — do not remove. // Security regression — do not remove.
@@ -682,28 +621,4 @@ class MassImportServiceTest {
} }
return destination.toFile(); return destination.toFile();
} }
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
}
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
Path xlsx = dir.resolve("import.xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook()) {
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
sheet.createRow(0).createCell(0).setCellValue("Index");
for (int i = 0; i < filenames.length; i++) {
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
}
try (OutputStream out = Files.newOutputStream(xlsx)) {
wb.write(out);
}
}
}
} }

View File

@@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; 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.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(NotificationController.class) @WebMvcTest(NotificationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -141,7 +142,7 @@ class NotificationControllerTest {
@Test @Test
void markAllRead_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@@ -151,7 +152,7 @@ class NotificationControllerTest {
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build(); AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user); 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()); .andExpect(status().isNoContent());
verify(notificationService).markAllRead(USER_ID); verify(notificationService).markAllRead(USER_ID);
@@ -161,7 +162,7 @@ class NotificationControllerTest {
@Test @Test
void markOneRead_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@@ -176,7 +177,7 @@ class NotificationControllerTest {
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours")) org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
.when(notificationService).markRead(notifId, USER_ID); .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()); .andExpect(status().isForbidden());
} }
@@ -256,7 +257,7 @@ class NotificationControllerTest {
.notifyOnReply(true).notifyOnMention(true).build(); .notifyOnReply(true).notifyOnMention(true).build();
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}")) .content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -275,7 +276,7 @@ class NotificationControllerTest {
.notifyOnReply(true).notifyOnMention(false).build(); .notifyOnReply(true).notifyOnMention(false).build();
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}")) .content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -337,7 +338,7 @@ class NotificationControllerTest {
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId)) doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
.when(notificationService).markRead(notifId, USER_ID); .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()); .andExpect(status().isNotFound());
} }
} }

View File

@@ -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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(OcrController.class) @WebMvcTest(OcrController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.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); 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) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto))) .content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -80,7 +81,7 @@ class OcrControllerTest {
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean())) when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded")); .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) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -127,7 +128,7 @@ class OcrControllerTest {
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId); 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) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto))) .content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -179,14 +180,14 @@ class OcrControllerTest {
@Test @Test
void triggerTraining_returns401_whenUnauthenticated() throws Exception { void triggerTraining_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/ocr/train")) mockMvc.perform(post("/api/ocr/train").with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void triggerTraining_returns403_whenNotAdmin() throws Exception { void triggerTraining_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/ocr/train")) mockMvc.perform(post("/api/ocr/train").with(csrf()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -196,7 +197,7 @@ class OcrControllerTest {
when(ocrTrainingService.triggerTraining(any())) when(ocrTrainingService.triggerTraining(any()))
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running")); .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()); .andExpect(status().isConflict());
} }
@@ -209,7 +210,7 @@ class OcrControllerTest {
.blockCount(10).documentCount(3).modelName("german_kurrent").build(); .blockCount(10).documentCount(3).modelName("german_kurrent").build();
when(ocrTrainingService.triggerTraining(any())).thenReturn(run); when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
mockMvc.perform(post("/api/ocr/train")) mockMvc.perform(post("/api/ocr/train").with(csrf()))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("DONE")) .andExpect(jsonPath("$.status").value("DONE"))
.andExpect(jsonPath("$.blockCount").value(10)); .andExpect(jsonPath("$.blockCount").value(10));
@@ -365,7 +366,7 @@ class OcrControllerTest {
@Test @Test
@WithMockUser(authorities = "ADMIN") @WithMockUser(authorities = "ADMIN")
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":null}")) .content("{\"personId\":null}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -373,7 +374,7 @@ class OcrControllerTest {
@Test @Test
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -382,7 +383,7 @@ class OcrControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -395,7 +396,7 @@ class OcrControllerTest {
when(senderModelService.triggerManualSenderTraining(unknownId)) when(senderModelService.triggerManualSenderTraining(unknownId))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); .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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + unknownId + "\"}")) .content("{\"personId\":\"" + unknownId + "\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -410,7 +411,7 @@ class OcrControllerTest {
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + personId + "\"}")) .content("{\"personId\":\"" + personId + "\"}"))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -426,7 +427,7 @@ class OcrControllerTest {
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + personId + "\"}")) .content("{\"personId\":\"" + personId + "\"}"))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -442,7 +443,7 @@ class OcrControllerTest {
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + personId + "\"}")) .content("{\"personId\":\"" + personId + "\"}"))
.andExpect(status().isAccepted()); .andExpect(status().isAccepted());

View File

@@ -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.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(PersonController.class) @WebMvcTest(PersonController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -217,7 +218,7 @@ class PersonControllerTest {
@Test @Test
void createPerson_returns401_whenUnauthenticated() throws Exception { void createPerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/persons") mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -226,7 +227,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception { void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons") mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -235,7 +236,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons") mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -244,7 +245,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsMissing() throws Exception { void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons") mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -253,7 +254,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsBlank() throws Exception { void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons") mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -265,7 +266,7 @@ class PersonControllerTest {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); 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); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -278,7 +279,7 @@ class PersonControllerTest {
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build(); Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}")) .content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -293,7 +294,7 @@ class PersonControllerTest {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(captor.capture())).thenReturn(saved); when(personService.createPerson(captor.capture())).thenReturn(saved);
mockMvc.perform(post("/api/persons") mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -307,7 +308,7 @@ class PersonControllerTest {
when(personService.createPerson(any())).thenThrow( when(personService.createPerson(any())).thenThrow(
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type")); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}")) .content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -318,7 +319,7 @@ class PersonControllerTest {
@Test @Test
void updatePerson_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -327,7 +328,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -336,7 +337,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsNull() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -349,7 +350,7 @@ class PersonControllerTest {
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build(); Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
when(personService.updatePerson(eq(id), any())).thenReturn(updated); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -360,7 +361,7 @@ class PersonControllerTest {
@Test @Test
void mergePerson_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -369,7 +370,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -378,7 +379,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\" \"}")) .content("{\"targetPersonId\":\" \"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -390,7 +391,7 @@ class PersonControllerTest {
UUID sourceId = UUID.randomUUID(); UUID sourceId = UUID.randomUUID();
UUID targetId = 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + targetId + "\"}")) .content("{\"targetPersonId\":\"" + targetId + "\"}"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
@@ -402,7 +403,7 @@ class PersonControllerTest {
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsBlank() throws Exception { void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id) mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -418,7 +419,7 @@ class PersonControllerTest {
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build(); .alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + "\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
@@ -436,7 +437,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception { void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
String oversizedNotes = "x".repeat(5001); String oversizedNotes = "x".repeat(5001);
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id) mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -447,7 +448,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception { void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
String oversizedFirstName = "x".repeat(101); String oversizedFirstName = "x".repeat(101);
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id) mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -458,7 +459,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception { void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons") mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -467,7 +468,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -476,7 +477,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -507,7 +508,7 @@ class PersonControllerTest {
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build(); .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
when(personService.addAlias(eq(personId), any())).thenReturn(saved); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -517,7 +518,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void addAlias_returns403_withoutWritePermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -531,7 +532,7 @@ class PersonControllerTest {
UUID personId = UUID.randomUUID(); UUID personId = UUID.randomUUID();
UUID aliasId = 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()); .andExpect(status().isNoContent());
verify(personService).removeAlias(personId, aliasId); verify(personService).removeAlias(personId, aliasId);
@@ -540,14 +541,14 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void removeAlias_returns403_withoutWritePermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void addAlias_returns400_whenLastNameIsBlank() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}")) .content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -556,7 +557,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void addAlias_returns400_whenTypeIsNull() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"de Gruyter\"}")) .content("{\"lastName\":\"de Gruyter\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());

View File

@@ -28,6 +28,7 @@ import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(RelationshipController.class) @WebMvcTest(RelationshipController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -67,7 +68,7 @@ class RelationshipControllerTest {
@Test @Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"}) @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -76,14 +77,14 @@ class RelationshipControllerTest {
@Test @Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"}) @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"}) @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"familyMember\":true}")) .content("{\"familyMember\":true}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -125,7 +126,7 @@ class RelationshipControllerTest {
@Test @Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}")) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -141,7 +142,7 @@ class RelationshipControllerTest {
RelationType.PARENT_OF, null, null, null); RelationType.PARENT_OF, null, null, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -154,7 +155,7 @@ class RelationshipControllerTest {
UUID relId = UUID.randomUUID(); UUID relId = UUID.randomUUID();
doNothing().when(relationshipService).deleteRelationship(any(), any()); 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()); .andExpect(status().isNoContent());
} }
} }

View File

@@ -29,6 +29,7 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(TagController.class) @WebMvcTest(TagController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -61,7 +62,7 @@ class TagControllerTest {
@Test @Test
void updateTag_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}")) .content("{\"name\": \"New\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -70,7 +71,7 @@ class TagControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}")) .content("{\"name\": \"New\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -82,7 +83,7 @@ class TagControllerTest {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build(); Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
when(tagService.update(any(), any())).thenReturn(tag); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}")) .content("{\"name\": \"New\"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -116,7 +117,7 @@ class TagControllerTest {
@Test @Test
void mergeTag_returns401_whenUnauthenticated() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -125,7 +126,7 @@ class TagControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -134,7 +135,7 @@ class TagControllerTest {
@Test @Test
@WithMockUser(authorities = "ADMIN_TAG") @WithMockUser(authorities = "ADMIN_TAG")
void mergeTag_returns400_whenTargetIdIsNull() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -146,7 +147,7 @@ class TagControllerTest {
when(tagService.mergeTags(any(), any())) when(tagService.mergeTags(any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found")); .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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -159,7 +160,7 @@ class TagControllerTest {
Tag target = Tag.builder().id(targetId).name("Target").build(); Tag target = Tag.builder().id(targetId).name("Target").build();
when(tagService.mergeTags(any(), any())).thenReturn(target); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + targetId + "\"}")) .content("{\"targetId\": \"" + targetId + "\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -171,21 +172,21 @@ class TagControllerTest {
@Test @Test
void deleteSubtree_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "ADMIN_TAG") @WithMockUser(authorities = "ADMIN_TAG")
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception { 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()); .andExpect(status().isNoContent());
} }
@@ -193,21 +194,21 @@ class TagControllerTest {
@Test @Test
void deleteTag_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "ADMIN_TAG") @WithMockUser(authorities = "ADMIN_TAG")
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception { 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()); .andExpect(status().isOk());
} }
} }

View File

@@ -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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(AdminController.class) @WebMvcTest(AdminController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -46,7 +47,7 @@ class AdminControllerTest {
@WithMockUser(authorities = "ADMIN") @WithMockUser(authorities = "ADMIN")
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception { void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus( MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null); MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
when(massImportService.getStatus()).thenReturn(status); when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status")) mockMvc.perform(get("/api/admin/import-status"))
@@ -60,7 +61,7 @@ class AdminControllerTest {
@WithMockUser(authorities = "ADMIN") @WithMockUser(authorities = "ADMIN")
void importStatus_messageField_notPresentInApiResponse() throws Exception { void importStatus_messageField_notPresentInApiResponse() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus( MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null); MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
when(massImportService.getStatus()).thenReturn(status); when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status")) mockMvc.perform(get("/api/admin/import-status"))
@@ -83,14 +84,14 @@ class AdminControllerTest {
@Test @Test
void backfillVersions_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(roles = "USER") @WithMockUser(roles = "USER")
void backfillVersions_returns403_whenNotAdmin() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@@ -100,7 +101,7 @@ class AdminControllerTest {
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build())); when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1); 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(status().isOk())
.andExpect(jsonPath("$.count").value(1)); .andExpect(jsonPath("$.count").value(1));
} }
@@ -109,14 +110,14 @@ class AdminControllerTest {
@Test @Test
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(roles = "USER") @WithMockUser(roles = "USER")
void backfillFileHashes_returns403_whenNotAdmin() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@@ -125,7 +126,7 @@ class AdminControllerTest {
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception { void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
when(documentService.backfillFileHashes()).thenReturn(3); 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(status().isOk())
.andExpect(jsonPath("$.count").value(3)); .andExpect(jsonPath("$.count").value(3));
} }
@@ -134,14 +135,14 @@ class AdminControllerTest {
@Test @Test
void generateThumbnails_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(roles = "USER") @WithMockUser(roles = "USER")
void generateThumbnails_returns403_whenNotAdmin() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@@ -152,7 +153,7 @@ class AdminControllerTest {
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now()); ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
when(thumbnailBackfillService.getStatus()).thenReturn(status); 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(status().isAccepted())
.andExpect(jsonPath("$.state").value("RUNNING")) .andExpect(jsonPath("$.state").value("RUNNING"))
.andExpect(jsonPath("$.total").value(10)); .andExpect(jsonPath("$.total").value(10));

View File

@@ -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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(AuthController.class) @WebMvcTest(AuthController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -117,7 +118,7 @@ class AuthControllerTest {
req.setFirstName("Max"); req.setFirstName("Max");
req.setLastName("Muster"); req.setLastName("Muster");
mockMvc.perform(post("/api/auth/register") mockMvc.perform(post("/api/auth/register").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -134,7 +135,7 @@ class AuthControllerTest {
req.setEmail("dupe@test.com"); req.setEmail("dupe@test.com");
req.setPassword("password123"); req.setPassword("password123");
mockMvc.perform(post("/api/auth/register") mockMvc.perform(post("/api/auth/register").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isConflict()); .andExpect(status().isConflict());
@@ -150,7 +151,7 @@ class AuthControllerTest {
req.setEmail("new@test.com"); req.setEmail("new@test.com");
req.setPassword("abc"); req.setPassword("abc");
mockMvc.perform(post("/api/auth/register") mockMvc.perform(post("/api/auth/register").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -166,7 +167,7 @@ class AuthControllerTest {
req.setEmail("new@test.com"); req.setEmail("new@test.com");
req.setPassword("password123"); req.setPassword("password123");
mockMvc.perform(post("/api/auth/register") mockMvc.perform(post("/api/auth/register").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -183,7 +184,7 @@ class AuthControllerTest {
req.setPassword("password123"); req.setPassword("password123");
// No WithMockUser — must still succeed (no auth challenge) // 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) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated()); .andExpect(status().isCreated());

View File

@@ -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.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(InviteController.class) @WebMvcTest(InviteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -103,7 +104,7 @@ class InviteControllerTest {
@Test @Test
void createInvite_returns401_whenUnauthenticated() throws Exception { void createInvite_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/invites") mockMvc.perform(post("/api/invites").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -112,7 +113,7 @@ class InviteControllerTest {
@Test @Test
@WithMockUser(username = "user@test.com") @WithMockUser(username = "user@test.com")
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/invites") mockMvc.perform(post("/api/invites").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -142,7 +143,7 @@ class InviteControllerTest {
req.setLabel("Für Familie"); req.setLabel("Für Familie");
req.setMaxUses(1); req.setMaxUses(1);
mockMvc.perform(post("/api/invites") mockMvc.perform(post("/api/invites").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -164,7 +165,7 @@ class InviteControllerTest {
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345")); .thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
String body = "{\"groupIds\":[\"" + groupId + "\"]}"; String body = "{\"groupIds\":[\"" + groupId + "\"]}";
mockMvc.perform(post("/api/invites") mockMvc.perform(post("/api/invites").with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
@@ -178,14 +179,14 @@ class InviteControllerTest {
@Test @Test
void revokeInvite_returns401_whenUnauthenticated() throws Exception { 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()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(username = "user@test.com") @WithMockUser(username = "user@test.com")
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@@ -194,7 +195,7 @@ class InviteControllerTest {
void revokeInvite_returns204_whenSuccessful() throws Exception { void revokeInvite_returns204_whenSuccessful() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/invites/" + id)) mockMvc.perform(delete("/api/invites/" + id).with(csrf()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
verify(inviteService).revokeInvite(id); verify(inviteService).revokeInvite(id);

View File

@@ -27,6 +27,7 @@ import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.raddatz.familienarchiv.auth.AuthService;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -36,8 +37,10 @@ class PasswordResetServiceTest {
@Mock PasswordResetTokenRepository tokenRepository; @Mock PasswordResetTokenRepository tokenRepository;
@Mock PasswordEncoder passwordEncoder; @Mock PasswordEncoder passwordEncoder;
@Mock JavaMailSender mailSender; @Mock JavaMailSender mailSender;
@Mock AuthService authService;
@InjectMocks PasswordResetService service; @InjectMocks PasswordResetService service;
private AppUser makeUser(String email) { private AppUser makeUser(String email) {
return AppUser.builder() return AppUser.builder()
.id(UUID.randomUUID()) .id(UUID.randomUUID())
@@ -176,6 +179,27 @@ class PasswordResetServiceTest {
verify(mailSender).send(any(SimpleMailMessage.class)); 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 ───────────────────────────────────────────────── // ─── cleanupExpiredTokens ─────────────────────────────────────────────────
@Test @Test

View File

@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.user; package org.raddatz.familienarchiv.user;
import org.junit.jupiter.api.Test; 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.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.security.PermissionAspect; 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.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -17,6 +20,8 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID; import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; 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.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(UserController.class) @WebMvcTest(UserController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -32,6 +38,8 @@ class UserControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockitoBean UserService userService; @MockitoBean UserService userService;
@MockitoBean AuthService authService;
@MockitoBean AuditService auditService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/users/me ──────────────────────────────────────────────────────── // ─── GET /api/users/me ────────────────────────────────────────────────────────
@@ -83,7 +91,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception { 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) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -92,7 +100,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailContainsColon() throws Exception { 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) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -101,7 +109,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailIsBlank() throws Exception { 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) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -112,7 +120,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "reader@example.com") @WithMockUser(username = "reader@example.com")
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { 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) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -121,7 +129,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "reader@example.com") @WithMockUser(username = "reader@example.com")
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { 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) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -130,7 +138,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "reader@example.com") @WithMockUser(username = "reader@example.com")
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { 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()); .andExpect(status().isForbidden());
} }
@@ -138,7 +146,7 @@ class UserControllerTest {
@Test @Test
void createUser_returns401_whenUnauthenticated() throws Exception { 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) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -146,7 +154,7 @@ class UserControllerTest {
@Test @Test
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception { 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) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -154,7 +162,92 @@ class UserControllerTest {
@Test @Test
void deleteUser_returns401_whenUnauthenticated() throws Exception { 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()); .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"));
}
} }

View File

@@ -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 | | `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 | | `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 | | `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` | | `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` | | `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 | | `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 ### 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. 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.
--- ---

View 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.

View File

@@ -9,18 +9,23 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") { 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(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(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(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 in #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.")
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(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(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(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, 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, authSvc, "Validate creds + audit")
Rel(authCtrl, sessionRepo, "getSession() / invalidate()") Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager") 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, sessionRepo, "Resolves session by fa_session cookie")
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods") Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
Rel(secConf, userDetails, "Wires as UserDetailsService") Rel(secConf, userDetails, "Wires as UserDetailsService")

View File

@@ -1,9 +1,9 @@
@startuml @startuml
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy) title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
note over Browser, DB note over Browser, DB
Phase 1 of the auth rewrite (ADR-020 / #523). Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524).
Replaces the Basic-credentials-in-cookie model Adds CSRF double-submit cookies, login rate limiting, and
with an opaque server-side session id (fa_session). session revocation on password change/reset.
end note end note
actor User actor User
@@ -11,9 +11,10 @@ participant Browser
participant "Caddy (TLS termination)" as Caddy participant "Caddy (TLS termination)" as Caddy
participant "Frontend (SvelteKit)" as Frontend participant "Frontend (SvelteKit)" as Frontend
participant "Backend (Spring Boot)" as Backend participant "Backend (Spring Boot)" as Backend
participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter
participant "spring_session\n(PostgreSQL)" as DB participant "spring_session\n(PostgreSQL)" as DB
== Login == == Login (with rate limiting + CSRF bootstrap) ==
User -> Browser: Enter email + password User -> Browser: Enter email + password
Browser -> Caddy: HTTPS POST /?/login (form action) Browser -> Caddy: HTTPS POST /?/login (form action)
note right of Caddy note right of Caddy
@@ -30,19 +31,46 @@ note right of Backend
→ request.getScheme() = "https" → request.getScheme() = "https"
→ Secure cookie flag set automatically. → Secure cookie flag set automatically.
end note end note
Backend -> Backend: AuthenticationManager\nauthenticate(email, password) Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip]
Backend -> DB: SELECT user WHERE email=? alt Rate limit exceeded
DB --> Backend: AppUser + groups + permissions RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS)
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss) Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email})
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx) Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"}
Backend -> DB: INSERT spring_session\n+ spring_session_attributes Frontend --> Browser: Show rate-limit error
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua}) else Under limit
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs) Backend -> DB: SELECT user WHERE email=?
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque> DB --> Backend: AppUser + groups + permissions
Caddy --> Browser: HTTPS 303 + Set-Cookie 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> Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
Frontend -> Frontend: hooks.server.ts reads fa_session Frontend -> Frontend: hooks.server.ts reads fa_session
@@ -61,6 +89,28 @@ else Session expired (idle > 8h) or unknown
Caddy --> Browser: HTTPS 302 Caddy --> Browser: HTTPS 302
end 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 == == Logout ==
Browser -> Caddy: HTTPS POST /logout Browser -> Caddy: HTTPS POST /logout
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque> Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>

View File

@@ -19,6 +19,8 @@
"error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.", "error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
"error_unauthorized": "Sie sind nicht angemeldet.", "error_unauthorized": "Sie sind nicht angemeldet.",
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.", "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_validation_error": "Die Eingabe ist ungültig.",
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
"nav_documents": "Dokumente", "nav_documents": "Dokumente",
@@ -350,9 +352,6 @@
"admin_system_import_status_running": "Import läuft…", "admin_system_import_status_running": "Import läuft…",
"admin_system_import_status_done": "Import abgeschlossen", "admin_system_import_status_done": "Import abgeschlossen",
"admin_system_import_status_done_label": "Dokumente verarbeitet", "admin_system_import_status_done_label": "Dokumente verarbeitet",
"admin_system_import_skipped_label": "übersprungen",
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
"admin_system_import_status_failed": "Import fehlgeschlagen", "admin_system_import_status_failed": "Import fehlgeschlagen",
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.", "admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
"admin_system_import_failed_internal": "Interner Fehler beim Import.", "admin_system_import_failed_internal": "Interner Fehler beim Import.",

View File

@@ -19,6 +19,8 @@
"error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.", "error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
"error_unauthorized": "You are not logged in.", "error_unauthorized": "You are not logged in.",
"error_forbidden": "You do not have permission for this action.", "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_validation_error": "The input is invalid.",
"error_internal_error": "An unexpected error occurred.", "error_internal_error": "An unexpected error occurred.",
"nav_documents": "Documents", "nav_documents": "Documents",
@@ -350,9 +352,6 @@
"admin_system_import_status_running": "Import running…", "admin_system_import_status_running": "Import running…",
"admin_system_import_status_done": "Import complete", "admin_system_import_status_done": "Import complete",
"admin_system_import_status_done_label": "Documents processed", "admin_system_import_status_done_label": "Documents processed",
"admin_system_import_skipped_label": "skipped",
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
"import_reason_file_read_error": "File read error",
"admin_system_import_status_failed": "Import failed", "admin_system_import_status_failed": "Import failed",
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.", "admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
"admin_system_import_failed_internal": "Import failed due to an internal error.", "admin_system_import_failed_internal": "Import failed due to an internal error.",

View File

@@ -19,6 +19,8 @@
"error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.", "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_unauthorized": "No ha iniciado sesión.",
"error_forbidden": "No tiene permiso para realizar esta acció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_validation_error": "La entrada no es válida.",
"error_internal_error": "Se ha producido un error inesperado.", "error_internal_error": "Se ha producido un error inesperado.",
"nav_documents": "Documentos", "nav_documents": "Documentos",
@@ -350,9 +352,6 @@
"admin_system_import_status_running": "Importación en curso…", "admin_system_import_status_running": "Importación en curso…",
"admin_system_import_status_done": "Importación completada", "admin_system_import_status_done": "Importación completada",
"admin_system_import_status_done_label": "Documentos procesados", "admin_system_import_status_done_label": "Documentos procesados",
"admin_system_import_skipped_label": "omitidos",
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
"import_reason_file_read_error": "Error al leer el archivo",
"admin_system_import_status_failed": "Importación fallida", "admin_system_import_status_failed": "Importación fallida",
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.", "admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
"admin_system_import_failed_internal": "Error interno durante la importación.", "admin_system_import_failed_internal": "Error interno durante la importación.",

View File

@@ -96,42 +96,58 @@ const userGroup: Handle = async ({ event, resolve }) => {
return resolve(event); 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 }) => { export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
if (isApi) { if (!isApi) return fetch(request);
// 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);
}
const sessionId = event.cookies.get('fa_session'); const isMutating = MUTATING_METHODS.has(request.method);
if (!sessionId) { const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p));
return new Response('Unauthorized', { status: 401 });
}
// Clone first so the body stream is preserved on the new Request. const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null;
const cloned = request.clone(); if (!isPublicAuthApi && !sessionId) {
const modified = new Request(cloned, { return new Response('Unauthorized', { status: 401 });
headers: {
...Object.fromEntries(cloned.headers),
Cookie: `fa_session=${sessionId}`
}
});
return fetch(modified);
} }
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); export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);

View File

@@ -49,6 +49,8 @@ export type ErrorCode =
| 'MISSING_CREDENTIALS' | 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED' | 'UNAUTHORIZED'
| 'FORBIDDEN' | 'FORBIDDEN'
| 'CSRF_TOKEN_MISSING'
| 'TOO_MANY_LOGIN_ATTEMPTS'
| 'VALIDATION_ERROR' | 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE' | 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS' | 'BULK_EDIT_TOO_MANY_IDS'
@@ -166,6 +168,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_unauthorized(); return m.error_unauthorized();
case 'FORBIDDEN': case 'FORBIDDEN':
return m.error_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': case 'VALIDATION_ERROR':
return m.error_validation_error(); return m.error_validation_error();
case 'BATCH_TOO_LARGE': case 'BATCH_TOO_LARGE':

View File

@@ -15,12 +15,6 @@ const failureMessage = $derived(
? m.admin_system_import_failed_no_spreadsheet() ? m.admin_system_import_failed_no_spreadsheet()
: m.admin_system_import_failed_internal() : m.admin_system_import_failed_internal()
); );
function reasonLabel(code: string): string {
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
return code;
}
</script> </script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -54,38 +48,6 @@ function reasonLabel(code: string): string {
</p> </p>
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p> <p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
</div> </div>
{#if importStatus.skipped > 0}
<details class="mb-4 rounded-sm border border-warning/40 bg-warning/10 p-4 text-amber-900">
<summary class="flex cursor-pointer list-none items-center gap-2">
<svg
class="details-chevron h-4 w-4 shrink-0 motion-safe:transition-transform"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 4l4 4-4 4" />
</svg>
<div>
<span data-testid="skipped-count" class="block text-base font-bold"
>{importStatus.skipped}</span
>
<span class="block font-sans text-xs font-bold tracking-widest uppercase">
{m.admin_system_import_skipped_label()}
</span>
</div>
</summary>
<ul class="mt-3 space-y-1">
{#each importStatus.skippedFiles as skipped (skipped.filename)}
<li class="font-mono text-sm text-ink-2">
{skipped.filename}{reasonLabel(skipped.reason)}
</li>
{/each}
</ul>
</details>
{/if}
<button <button
data-import-trigger data-import-trigger
onclick={ontrigger} onclick={ontrigger}
@@ -117,9 +79,3 @@ function reasonLabel(code: string): string {
</button> </button>
{/if} {/if}
</div> </div>
<style>
details[open] .details-chevron {
transform: rotate(90deg);
}
</style>

View File

@@ -8,8 +8,6 @@ const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
state: 'IDLE', state: 'IDLE',
statusCode: 'IMPORT_IDLE', statusCode: 'IMPORT_IDLE',
processed: 0, processed: 0,
skipped: 0,
skippedFiles: [],
startedAt: null, startedAt: null,
...overrides ...overrides
}); });
@@ -130,106 +128,4 @@ describe('ImportStatusCard', () => {
await getByRole('button').click(); await getByRole('button').click();
expect(ontrigger).toHaveBeenCalledOnce(); expect(ontrigger).toHaveBeenCalledOnce();
}); });
it('shows skipped count when DONE and skipped > 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'DONE',
statusCode: 'IMPORT_DONE',
processed: 10,
skipped: 3,
skippedFiles: [
{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
{ filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
{ filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
]
}),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).toHaveTextContent('3');
});
it('shows skipped filenames in collapsible list when DONE and skipped > 0', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'DONE',
statusCode: 'IMPORT_DONE',
processed: 5,
skipped: 1,
skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
}),
ontrigger: () => {}
}
});
await expect.element(getByText('fake.pdf')).toBeInTheDocument();
});
it('does not show skipped section when DONE and skipped is 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
it('does not show skipped section when RUNNING even with skipped > 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'RUNNING',
statusCode: 'IMPORT_RUNNING',
processed: 5,
skipped: 2,
skippedFiles: [
{ filename: 'a.pdf', reason: 'INVALID_PDF_SIGNATURE' },
{ filename: 'b.pdf', reason: 'INVALID_PDF_SIGNATURE' }
]
}),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
it('does not show skipped section when FAILED even with skipped > 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'FAILED',
statusCode: 'IMPORT_FAILED_INTERNAL',
skipped: 1,
skippedFiles: [{ filename: 'bad.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
}),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
it('shows raw reason code for unknown skip reasons', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'DONE',
statusCode: 'IMPORT_DONE',
processed: 1,
skipped: 1,
skippedFiles: [{ filename: 'odd.pdf', reason: 'SOME_FUTURE_CODE' }]
}),
ontrigger: () => {}
}
});
await expect.element(getByText('SOME_FUTURE_CODE', { exact: false })).toBeInTheDocument();
});
}); });

View File

@@ -1,13 +1,6 @@
export type SkippedFile = {
filename: string;
reason: string;
};
export type ImportStatus = { export type ImportStatus = {
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED'; state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
statusCode: string; statusCode: string;
processed: number; processed: number;
skipped: number;
skippedFiles: SkippedFile[];
startedAt: string | null; startedAt: string | null;
}; };

View File

@@ -45,6 +45,10 @@ export const actions = {
return fail(401, { error: getErrorMessage(code) }); return fail(401, { error: getErrorMessage(code) });
} }
if (response.status === 429) {
return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true });
}
if (!response.ok) { if (!response.ok) {
return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') }); return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') });
} }

View File

@@ -7,7 +7,7 @@ let {
form form
}: { }: {
data: { registered: boolean; reason?: string | null }; data: { registered: boolean; reason?: string | null };
form?: { error?: string; success?: boolean }; form?: { error?: string; rateLimited?: boolean; success?: boolean };
} = $props(); } = $props();
</script> </script>
@@ -106,7 +106,32 @@ let {
</div> </div>
{#if form?.error} {#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} {/if}
<button <button

View File

@@ -100,6 +100,25 @@ describe('login action', () => {
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' }); 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 () => { it('returns 500 when backend response omits fa_session cookie', async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
const cookies = makeCookies(); const cookies = makeCookies();

View File

@@ -70,4 +70,16 @@ describe('login page', () => {
.element(page.getByRole('link', { name: /passwort vergessen/i })) .element(page.getByRole('link', { name: /passwort vergessen/i }))
.toHaveAttribute('href', '/forgot-password'); .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();
});
}); });

View File

@@ -1,6 +1,13 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"packageRules": [ "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/"], "matchPackagePatterns": ["^@tiptap/"],
"groupName": "tiptap", "groupName": "tiptap",