Compare commits
8 Commits
feat/issue
...
worktree-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f144b025b0 | ||
|
|
b38b8c39b8 | ||
|
|
93da5914a8 | ||
|
|
53dba772ae | ||
|
|
e770b81ea5 | ||
|
|
50441f558f | ||
|
|
22589e4729 | ||
|
|
e124c68cf4 |
@@ -77,7 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
||||
```
|
||||
backend/src/main/java/org/raddatz/familienarchiv/
|
||||
├── audit/ Audit logging
|
||||
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
|
||||
├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC)
|
||||
├── config/ Infrastructure config (Minio, Async, Web)
|
||||
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
||||
├── document/ Document domain (entities, controller, service, repository, DTOs)
|
||||
|
||||
@@ -180,16 +180,11 @@
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
|
||||
<!-- Caffeine cache for in-memory rate limiting -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||
<dependency>
|
||||
|
||||
@@ -43,14 +43,8 @@ public enum AuditKind {
|
||||
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
||||
LOGIN_FAILED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */
|
||||
LOGOUT,
|
||||
|
||||
/** Payload: {@code {"actorId": "uuid", "targetUserId": "uuid", "revokedCount": 3}} */
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED;
|
||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
|
||||
LOGOUT;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
|
||||
@@ -24,18 +24,13 @@ public class AuthService {
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final UserService userService;
|
||||
private final AuditService auditService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
private final SessionRevocationPort sessionRevocationPort;
|
||||
|
||||
/**
|
||||
* Validates credentials and returns the authenticated user plus the Spring Security
|
||||
* Authentication object. The caller is responsible for persisting the Authentication
|
||||
* to the session via SecurityContextRepository.
|
||||
*/
|
||||
public LoginResult login(String email, String password, String ip, String ua) {
|
||||
try {
|
||||
loginRateLimiter.checkAndConsume(ip, email);
|
||||
} catch (DomainException ex) {
|
||||
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
|
||||
"ip", ip,
|
||||
"email", email));
|
||||
throw ex;
|
||||
}
|
||||
try {
|
||||
Authentication auth = authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(email, password));
|
||||
@@ -45,7 +40,6 @@ public class AuthService {
|
||||
"userId", user.getId().toString(),
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||
return new LoginResult(user, auth);
|
||||
} catch (AuthenticationException ex) {
|
||||
// Audit login failure — intentionally does NOT log the attempted password.
|
||||
@@ -59,14 +53,6 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
|
||||
}
|
||||
|
||||
public int revokeAllSessions(String principalName) {
|
||||
return sessionRevocationPort.revokeAllSessions(principalName);
|
||||
}
|
||||
|
||||
public void logout(String email, String ip, String ua) {
|
||||
AppUser user = userService.findByEmail(email);
|
||||
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
public interface SessionRevocationPort {
|
||||
int revokeOtherSessions(String currentSessionId, String principalName);
|
||||
int revokeAllSessions(String principalName);
|
||||
}
|
||||
@@ -55,8 +55,4 @@ public class DomainException extends RuntimeException {
|
||||
public static DomainException internal(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
||||
}
|
||||
|
||||
public static DomainException tooManyRequests(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,6 @@ public enum ErrorCode {
|
||||
SESSION_EXPIRED,
|
||||
/** The password-reset token is missing, expired, or already used. 400 */
|
||||
INVALID_RESET_TOKEN,
|
||||
/** CSRF token is missing or does not match the expected value. 403 */
|
||||
CSRF_TOKEN_MISSING,
|
||||
/** The login rate limit has been exceeded for this IP/email combination. 429 */
|
||||
TOO_MANY_LOGIN_ATTEMPTS,
|
||||
|
||||
// --- Annotations ---
|
||||
/** The annotation with the given ID does not exist. 404 */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
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.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
@@ -31,6 +33,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -53,9 +56,27 @@ public class MassImportService {
|
||||
|
||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||
|
||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
||||
public record SkippedFile(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||
) {}
|
||||
|
||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
public record ImportStatus(
|
||||
@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() {
|
||||
return currentStatus;
|
||||
@@ -117,22 +138,22 @@ public class MassImportService {
|
||||
if (currentStatus.state() == State.RUNNING) {
|
||||
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, LocalDateTime.now());
|
||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||
try {
|
||||
File spreadsheet = findSpreadsheetFile();
|
||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
||||
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
||||
processed, currentStatus.startedAt());
|
||||
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||
} catch (NoSpreadsheetException e) {
|
||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||
} catch (Exception e) {
|
||||
log.error("Massenimport fehlgeschlagen", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,8 +275,10 @@ public class MassImportService {
|
||||
|
||||
// --- Import logic (works on neutral List<String> rows) ---
|
||||
|
||||
private int processRows(List<List<String>> rows) {
|
||||
int count = 0;
|
||||
private ProcessResult processRows(List<List<String>> rows) {
|
||||
int processed = 0;
|
||||
List<SkippedFile> skippedFiles = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i < rows.size(); i++) { // skip header row
|
||||
List<String> cells = rows.get(i);
|
||||
String index = getCell(cells, colIndex);
|
||||
@@ -266,18 +289,51 @@ public class MassImportService {
|
||||
if (fileOnDisk.isEmpty()) {
|
||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
||||
}
|
||||
importSingleDocument(cells, fileOnDisk, filename, index);
|
||||
count++;
|
||||
|
||||
if (fileOnDisk.isPresent()) {
|
||||
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
|
||||
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||
protected boolean importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
String archiveBox = getCell(cells, colBox);
|
||||
@@ -313,7 +369,7 @@ public class MassImportService {
|
||||
status = DocumentStatus.UPLOADED;
|
||||
} catch (Exception e) {
|
||||
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +411,7 @@ public class MassImportService {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -21,22 +19,12 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
|
||||
// cannot be injected here. A static instance is safe because the response
|
||||
// only serializes fixed String keys — no custom naming strategy or module needed.
|
||||
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final Environment environment;
|
||||
|
||||
@@ -90,13 +78,15 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
||||
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
||||
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
||||
// See ADR-022 and issue #524 for the full security rationale.
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||
// CSRF is intentionally disabled. The session model relies on:
|
||||
// 1. SameSite=Strict on the fa_session cookie — a cross-site POST from
|
||||
// evil.com cannot include the cookie.
|
||||
// 2. CORS — Spring's default rejects cross-origin requests with credentials
|
||||
// unless explicitly allowed (no allowedOrigins config).
|
||||
//
|
||||
// If either of those is ever weakened, CSRF protection MUST be re-enabled.
|
||||
// Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524).
|
||||
.csrf(csrf -> csrf.disable())
|
||||
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||
@@ -122,18 +112,10 @@ public class SecurityConfig {
|
||||
// erlaubt pdf im Iframe
|
||||
.headers(headers -> headers
|
||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||
// Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures.
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(
|
||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
|
||||
.accessDeniedHandler((req, res, e) -> {
|
||||
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
res.setContentType("application/json;charset=UTF-8");
|
||||
ErrorCode code = (e instanceof CsrfException)
|
||||
? ErrorCode.CSRF_TOKEN_MISSING
|
||||
: ErrorCode.FORBIDDEN;
|
||||
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||
}));
|
||||
// Return 401 (not 302 redirect to /login) for unauthenticated API requests.
|
||||
// httpBasic and formLogin are removed — authentication is via Spring Session only.
|
||||
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -33,7 +32,6 @@ public class PasswordResetService {
|
||||
private final UserService userService;
|
||||
private final PasswordResetTokenRepository tokenRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AuthService authService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JavaMailSender mailSender;
|
||||
@@ -87,8 +85,6 @@ public class PasswordResetService {
|
||||
|
||||
resetToken.setUsed(true);
|
||||
tokenRepository.save(resetToken);
|
||||
|
||||
authService.revokeAllSessions(user.getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
||||
@@ -30,15 +26,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/")
|
||||
@RequiredArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final AuthService authService;
|
||||
private final AuditService auditService;
|
||||
private UserService userService;
|
||||
|
||||
@GetMapping("users/me")
|
||||
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
||||
@@ -62,14 +56,9 @@ public class UserController {
|
||||
@PostMapping("users/me/password")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void changePassword(Authentication authentication,
|
||||
HttpSession session,
|
||||
@RequestBody ChangePasswordDTO dto) {
|
||||
AppUser current = userService.findByEmail(authentication.getName());
|
||||
userService.changePassword(current.getId(), dto);
|
||||
int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName());
|
||||
auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of(
|
||||
"reason", "password_change",
|
||||
"revokedCount", revoked));
|
||||
}
|
||||
|
||||
@GetMapping("users/{id}")
|
||||
@@ -112,18 +101,6 @@ public class UserController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/force-logout")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<Map<String, Object>> forceLogout(Authentication authentication,
|
||||
@PathVariable UUID id) {
|
||||
AppUser target = userService.getById(id);
|
||||
int revoked = authService.revokeAllSessions(target.getEmail());
|
||||
auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of(
|
||||
"targetUserId", target.getId().toString(),
|
||||
"revokedCount", revoked));
|
||||
return ResponseEntity.ok(Map.of("revokedCount", revoked));
|
||||
}
|
||||
|
||||
private UUID actorId(Authentication auth) {
|
||||
return userService.findByEmail(auth.getName()).getId();
|
||||
}
|
||||
|
||||
@@ -150,9 +150,3 @@ sentry:
|
||||
enable-tracing: true
|
||||
ignored-exceptions-for-type:
|
||||
- org.raddatz.familienarchiv.exception.DomainException
|
||||
|
||||
rate-limit:
|
||||
login:
|
||||
max-attempts-per-ip-email: 10
|
||||
max-attempts-per-ip: 20
|
||||
window-minutes: 15
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -30,8 +31,6 @@ class AuthServiceTest {
|
||||
@Mock AuthenticationManager authenticationManager;
|
||||
@Mock UserService userService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock LoginRateLimiter loginRateLimiter;
|
||||
@Mock SessionRevocationPort sessionRevocationPort;
|
||||
@InjectMocks AuthService authService;
|
||||
|
||||
private static final String IP = "127.0.0.1";
|
||||
@@ -130,62 +129,4 @@ class AuthServiceTest {
|
||||
&& !payload.containsKey("password"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_checks_rate_limit_before_authenticating() {
|
||||
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||
|
||||
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
|
||||
verify(authenticationManager, never()).authenticate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
|
||||
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||
|
||||
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(),
|
||||
argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_invalidates_rate_limit_on_success() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||
|
||||
authService.login("user@test.de", "pass123", IP, UA);
|
||||
|
||||
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeOtherSessions_delegates_to_port() {
|
||||
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
|
||||
|
||||
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeAllSessions_delegates_to_port() {
|
||||
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
|
||||
|
||||
int count = authService.revokeAllSessions("user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(3);
|
||||
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import java.util.UUID;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
@@ -49,7 +48,6 @@ class AuthSessionControllerTest {
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -63,7 +61,6 @@ class AuthSessionControllerTest {
|
||||
.thenThrow(DomainException.invalidCredentials());
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -80,7 +77,6 @@ class AuthSessionControllerTest {
|
||||
|
||||
// No WithMockUser — must be reachable without an active session
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -95,7 +91,6 @@ class AuthSessionControllerTest {
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -121,7 +116,6 @@ class AuthSessionControllerTest {
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -137,24 +131,12 @@ class AuthSessionControllerTest {
|
||||
.thenThrow(DomainException.invalidCredentials());
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||
}
|
||||
|
||||
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void authenticated_post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
// Red test: CSRF disabled → returns 204; after re-enabling returns 403.
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("user@test.de"))) // authenticated but no CSRF token
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||
}
|
||||
|
||||
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -162,18 +144,15 @@ class AuthSessionControllerTest {
|
||||
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
||||
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("user@test.de"))
|
||||
.with(csrf()))
|
||||
.with(user("user@test.de")))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_without_session_returns_403() throws Exception {
|
||||
// CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null,
|
||||
// ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403.
|
||||
void logout_returns_401_when_not_authenticated() throws Exception {
|
||||
// No authentication at all — Spring Security must return 401
|
||||
mockMvc.perform(post("/api/auth/logout"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -184,8 +163,7 @@ class AuthSessionControllerTest {
|
||||
.when(authService).logout(anyString(), anyString(), anyString());
|
||||
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("ghost@test.de"))
|
||||
.with(csrf()))
|
||||
.with(user("ghost@test.de")))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void login_sets_opaque_fa_session_cookie() {
|
||||
String xsrf = fetchXsrfToken();
|
||||
ResponseEntity<String> response = doLogin(xsrf);
|
||||
ResponseEntity<String> response = doLogin();
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
String cookie = extractFaSessionCookie(response);
|
||||
@@ -74,8 +73,7 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void session_cookie_authenticates_subsequent_request() {
|
||||
String xsrf = fetchXsrfToken();
|
||||
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
|
||||
ResponseEntity<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
@@ -86,17 +84,16 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
||||
String xsrf = fetchXsrfToken();
|
||||
String sessionCookie = extractFaSessionCookie(doLogin(xsrf));
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
|
||||
ResponseEntity<Void> logout = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class);
|
||||
new HttpEntity<>(cookieHeaders(cookie)), Void.class);
|
||||
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||
|
||||
ResponseEntity<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
new HttpEntity<>(cookieHeaders(sessionCookie)), String.class);
|
||||
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@@ -104,8 +101,7 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void session_expired_by_idle_timeout_returns_401() {
|
||||
String xsrf = fetchXsrfToken();
|
||||
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
|
||||
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
||||
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
||||
@@ -119,37 +115,11 @@ class AuthSessionIntegrationTest {
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// ─── Task: CSRF rejection at integration layer ────────────────────────────
|
||||
|
||||
@Test
|
||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
|
||||
ResponseEntity<String> response = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>("{}", headers), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an XSRF token for use in integration tests.
|
||||
* CookieCsrfTokenRepository validates that Cookie: XSRF-TOKEN=X matches X-XSRF-TOKEN: X.
|
||||
* By supplying both with the same value we simulate exactly what a browser does.
|
||||
*/
|
||||
private String fetchXsrfToken() {
|
||||
return java.util.UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
private ResponseEntity<String> doLogin(String xsrfToken) {
|
||||
private ResponseEntity<String> doLogin() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrfToken);
|
||||
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
||||
return http.postForEntity(baseUrl + "/api/auth/login",
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
@@ -161,13 +131,6 @@ class AuthSessionIntegrationTest {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private HttpHeaders csrfAndSessionHeaders(String sessionId, String xsrfToken) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrfToken);
|
||||
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
@@ -178,7 +141,6 @@ class AuthSessionIntegrationTest {
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LoginRateLimiterTest {
|
||||
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RateLimitProperties props = new RateLimitProperties();
|
||||
props.setMaxAttemptsPerIpEmail(10);
|
||||
props.setMaxAttemptsPerIp(20);
|
||||
props.setWindowMinutes(15);
|
||||
rateLimiter = new LoginRateLimiter(props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenth_attempt_from_same_ip_email_succeeds() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_after_10_failures_resets_ip_email_bucket() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");
|
||||
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
|
||||
// Use a tighter limiter so the phantom-consumption effect is observable.
|
||||
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
|
||||
RateLimitProperties props = new RateLimitProperties();
|
||||
props.setMaxAttemptsPerIpEmail(3);
|
||||
props.setMaxAttemptsPerIp(3);
|
||||
props.setWindowMinutes(15);
|
||||
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
|
||||
|
||||
// Exhaust the per-IP bucket using "user@"
|
||||
for (int i = 0; i < 3; i++) {
|
||||
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
// Three blocked attempts for "target@" while IP is exhausted
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
|
||||
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||
|
||||
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
|
||||
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
|
||||
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,10 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(DocumentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -216,14 +214,14 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -237,7 +235,7 @@ class DocumentControllerTest {
|
||||
.build();
|
||||
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@@ -246,7 +244,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -254,7 +252,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -271,7 +269,7 @@ class DocumentControllerTest {
|
||||
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id)
|
||||
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@@ -280,7 +278,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||
.delete("/api/documents/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -288,7 +286,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||
.delete("/api/documents/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -297,7 +295,7 @@ class DocumentControllerTest {
|
||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + id).with(csrf()))
|
||||
.delete("/api/documents/" + id))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -305,14 +303,14 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -328,7 +326,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
@@ -347,7 +345,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
||||
@@ -362,7 +360,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
||||
@@ -492,7 +490,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
@@ -642,7 +640,7 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -651,7 +649,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -661,7 +659,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||
.andExpect(status().isNoContent());
|
||||
@@ -673,7 +671,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
|
||||
.andExpect(status().isNoContent());
|
||||
@@ -684,7 +682,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -698,7 +696,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -715,7 +713,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(id.toString()))
|
||||
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
||||
@@ -728,7 +726,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -745,7 +743,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@@ -802,7 +800,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created.length()").value(3))
|
||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||
@@ -829,7 +827,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||
@@ -861,7 +859,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||
@@ -885,7 +883,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -906,7 +904,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||
@@ -928,7 +926,7 @@ class DocumentControllerTest {
|
||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||
}
|
||||
|
||||
mockMvc.perform(builder.with(csrf()))
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||
}
|
||||
@@ -947,7 +945,7 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -956,7 +954,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -967,7 +965,7 @@ class DocumentControllerTest {
|
||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"documentIds\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -978,7 +976,7 @@ class DocumentControllerTest {
|
||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -992,7 +990,7 @@ class DocumentControllerTest {
|
||||
String[] ids = new String[501];
|
||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -1011,7 +1009,7 @@ class DocumentControllerTest {
|
||||
String tooLong = "x".repeat(256);
|
||||
|
||||
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -1027,7 +1025,7 @@ class DocumentControllerTest {
|
||||
String[] ids = new String[500];
|
||||
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1044,7 +1042,7 @@ class DocumentControllerTest {
|
||||
|
||||
// Same id sent three times — controller should dedupe and call the
|
||||
// service exactly once, returning updated=1, not 3.
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1063,7 +1061,7 @@ class DocumentControllerTest {
|
||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id1.toString(), id2.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1139,7 +1137,7 @@ class DocumentControllerTest {
|
||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -1148,7 +1146,7 @@ class DocumentControllerTest {
|
||||
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -1157,7 +1155,7 @@ class DocumentControllerTest {
|
||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[]}").with(csrf()))
|
||||
.content("{\"ids\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -1174,7 +1172,7 @@ class DocumentControllerTest {
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(sb.toString()).with(csrf()))
|
||||
.content(sb.toString()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
@@ -1189,7 +1187,7 @@ class DocumentControllerTest {
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + id + "\"]}").with(csrf()))
|
||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||
@@ -1210,7 +1208,7 @@ class DocumentControllerTest {
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
||||
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1234,7 +1232,7 @@ class DocumentControllerTest {
|
||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(okId.toString(), badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1339,16 +1337,4 @@ class DocumentControllerTest {
|
||||
DocumentStatus.REVIEWED,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||
}
|
||||
|
||||
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/documents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(AnnotationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -68,7 +67,7 @@ class AnnotationControllerTest {
|
||||
|
||||
@Test
|
||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -77,7 +76,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -93,7 +92,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -102,7 +101,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -116,7 +115,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -134,7 +133,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -144,28 +143,28 @@ class AnnotationControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -175,7 +174,7 @@ class AnnotationControllerTest {
|
||||
|
||||
@Test
|
||||
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -184,7 +183,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -200,7 +199,7 @@ class AnnotationControllerTest {
|
||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isOk())
|
||||
@@ -218,7 +217,7 @@ class AnnotationControllerTest {
|
||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isOk());
|
||||
@@ -230,7 +229,7 @@ class AnnotationControllerTest {
|
||||
when(annotationService.updateAnnotation(any(), any(), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -239,7 +238,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"x\":-0.1,\"y\":0.3}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -248,7 +247,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"width\":0.005}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -257,7 +256,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"height\":0.005}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -266,7 +265,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"x\":1.1}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -277,7 +276,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
||||
// authentication == null → resolveUserId returns null
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -295,7 +294,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -313,7 +312,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
@@ -27,7 +27,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(CommentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -71,7 +70,7 @@ class CommentControllerTest {
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||
@@ -80,7 +79,7 @@ class CommentControllerTest {
|
||||
@Test
|
||||
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
@@ -89,7 +88,7 @@ class CommentControllerTest {
|
||||
@WithMockUser
|
||||
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
@@ -102,7 +101,7 @@ class CommentControllerTest {
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -117,7 +116,7 @@ class CommentControllerTest {
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -128,7 +127,7 @@ class CommentControllerTest {
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
@@ -137,7 +136,7 @@ class CommentControllerTest {
|
||||
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
@@ -152,7 +151,7 @@ class CommentControllerTest {
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -167,7 +166,7 @@ class CommentControllerTest {
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -176,7 +175,7 @@ class CommentControllerTest {
|
||||
|
||||
@Test
|
||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
@@ -188,7 +187,7 @@ class CommentControllerTest {
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
@@ -200,7 +199,7 @@ class CommentControllerTest {
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
@@ -209,14 +208,14 @@ class CommentControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(TranscriptionBlockController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -144,7 +143,7 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -153,7 +152,7 @@ class TranscriptionBlockControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -165,7 +164,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
||||
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -178,7 +177,7 @@ class TranscriptionBlockControllerTest {
|
||||
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -193,7 +192,7 @@ class TranscriptionBlockControllerTest {
|
||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -207,7 +206,7 @@ class TranscriptionBlockControllerTest {
|
||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -218,7 +217,7 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -227,7 +226,7 @@ class TranscriptionBlockControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -244,7 +243,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
||||
.thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isOk())
|
||||
@@ -260,7 +259,7 @@ class TranscriptionBlockControllerTest {
|
||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -273,7 +272,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -287,7 +286,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -298,7 +297,7 @@ class TranscriptionBlockControllerTest {
|
||||
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -308,28 +307,28 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -340,7 +339,7 @@ class TranscriptionBlockControllerTest {
|
||||
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
||||
.when(transcriptionService).deleteBlock(any(), any());
|
||||
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@@ -348,7 +347,7 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||
mockMvc.perform(put(URL_REORDER)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REORDER_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -357,7 +356,7 @@ class TranscriptionBlockControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||
mockMvc.perform(put(URL_REORDER)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REORDER_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -368,7 +367,7 @@ class TranscriptionBlockControllerTest {
|
||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
||||
|
||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||
mockMvc.perform(put(URL_REORDER)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REORDER_JSON))
|
||||
.andExpect(status().isOk())
|
||||
@@ -435,7 +434,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
||||
|
||||
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
||||
DOC_ID, BLOCK_ID).with(csrf()))
|
||||
DOC_ID, BLOCK_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reviewed").value(true));
|
||||
}
|
||||
@@ -446,14 +445,14 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -470,7 +469,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of(b1, b2));
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||
@@ -484,7 +483,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
@@ -495,7 +494,7 @@ class TranscriptionBlockControllerTest {
|
||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(GeschichteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -131,7 +130,7 @@ class GeschichteControllerTest {
|
||||
|
||||
@Test
|
||||
void create_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||
mockMvc.perform(post("/api/geschichten")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"title\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -140,7 +139,7 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void create_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||
mockMvc.perform(post("/api/geschichten")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"title\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -156,7 +155,7 @@ class GeschichteControllerTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("New");
|
||||
|
||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||
mockMvc.perform(post("/api/geschichten")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -168,7 +167,7 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void update_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -181,7 +180,7 @@ class GeschichteControllerTest {
|
||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(published(id, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"PUBLISHED\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -193,7 +192,7 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -202,7 +201,7 @@ class GeschichteControllerTest {
|
||||
void delete_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", id))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(geschichteService).delete(id);
|
||||
|
||||
@@ -135,7 +135,7 @@ class MassImportServiceTest {
|
||||
@Test
|
||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||
|
||||
assertThatThrownBy(() -> service.runImportAsync())
|
||||
@@ -325,8 +325,8 @@ class MassImportServiceTest {
|
||||
@Test
|
||||
void processRows_returnsZero_whenOnlyHeaderRow() {
|
||||
List<List<String>> rows = List.of(List.of("header", "col1"));
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result).isEqualTo(0);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result.processed()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -335,8 +335,8 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("") // blank index
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result).isEqualTo(0);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result.processed()).isEqualTo(0);
|
||||
verify(documentService, never()).findByOriginalFilename(any());
|
||||
}
|
||||
|
||||
@@ -349,9 +349,9 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("doc001") // no dot → appends ".pdf"
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
|
||||
assertThat(result).isEqualTo(1);
|
||||
assertThat(result.processed()).isEqualTo(1);
|
||||
verify(documentService).findByOriginalFilename("doc001.pdf");
|
||||
}
|
||||
|
||||
@@ -364,9 +364,9 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("doc002.pdf") // has dot → used as-is
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
|
||||
assertThat(result).isEqualTo(1);
|
||||
assertThat(result.processed()).isEqualTo(1);
|
||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
||||
}
|
||||
|
||||
@@ -525,6 +525,67 @@ class MassImportServiceTest {
|
||||
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 ───────────────────────────────────
|
||||
|
||||
// Security regression — do not remove.
|
||||
@@ -621,4 +682,28 @@ class MassImportServiceTest {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(NotificationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -142,7 +141,7 @@ class NotificationControllerTest {
|
||||
|
||||
@Test
|
||||
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||
mockMvc.perform(post("/api/notifications/read-all"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -152,7 +151,7 @@ class NotificationControllerTest {
|
||||
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
||||
when(userService.findByEmail("testuser")).thenReturn(user);
|
||||
|
||||
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||
mockMvc.perform(post("/api/notifications/read-all"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(notificationService).markAllRead(USER_ID);
|
||||
@@ -162,7 +161,7 @@ class NotificationControllerTest {
|
||||
|
||||
@Test
|
||||
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf()))
|
||||
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -177,7 +176,7 @@ class NotificationControllerTest {
|
||||
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||
.when(notificationService).markRead(notifId, USER_ID);
|
||||
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -257,7 +256,7 @@ class NotificationControllerTest {
|
||||
.notifyOnReply(true).notifyOnMention(true).build();
|
||||
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -276,7 +275,7 @@ class NotificationControllerTest {
|
||||
.notifyOnReply(true).notifyOnMention(false).build();
|
||||
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -338,7 +337,7 @@ class NotificationControllerTest {
|
||||
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||
.when(notificationService).markRead(notifId, USER_ID);
|
||||
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(OcrController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -67,7 +66,7 @@ class OcrControllerTest {
|
||||
|
||||
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
||||
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -81,7 +80,7 @@ class OcrControllerTest {
|
||||
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
||||
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
||||
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -128,7 +127,7 @@ class OcrControllerTest {
|
||||
|
||||
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/batch").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/batch")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -180,14 +179,14 @@ class OcrControllerTest {
|
||||
|
||||
@Test
|
||||
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -197,7 +196,7 @@ class OcrControllerTest {
|
||||
when(ocrTrainingService.triggerTraining(any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@@ -210,7 +209,7 @@ class OcrControllerTest {
|
||||
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
||||
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("DONE"))
|
||||
.andExpect(jsonPath("$.blockCount").value(10));
|
||||
@@ -366,7 +365,7 @@ class OcrControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":null}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -374,7 +373,7 @@ class OcrControllerTest {
|
||||
|
||||
@Test
|
||||
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -383,7 +382,7 @@ class OcrControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -396,7 +395,7 @@ class OcrControllerTest {
|
||||
when(senderModelService.triggerManualSenderTraining(unknownId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + unknownId + "\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -411,7 +410,7 @@ class OcrControllerTest {
|
||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + personId + "\"}"))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -427,7 +426,7 @@ class OcrControllerTest {
|
||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + personId + "\"}"))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -443,7 +442,7 @@ class OcrControllerTest {
|
||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + personId + "\"}"))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
@@ -36,7 +36,6 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(PersonController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -218,7 +217,7 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -227,7 +226,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -236,7 +235,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -245,7 +244,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -254,7 +253,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -266,7 +265,7 @@ class PersonControllerTest {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -279,7 +278,7 @@ class PersonControllerTest {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -294,7 +293,7 @@ class PersonControllerTest {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -308,7 +307,7 @@ class PersonControllerTest {
|
||||
when(personService.createPerson(any())).thenThrow(
|
||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -319,7 +318,7 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -328,7 +327,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -337,7 +336,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -350,7 +349,7 @@ class PersonControllerTest {
|
||||
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -361,7 +360,7 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -370,7 +369,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -379,7 +378,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\" \"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -391,7 +390,7 @@ class PersonControllerTest {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
@@ -403,7 +402,7 @@ class PersonControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -419,7 +418,7 @@ class PersonControllerTest {
|
||||
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||
@@ -437,7 +436,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
||||
String oversizedNotes = "x".repeat(5001);
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -448,7 +447,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
||||
String oversizedFirstName = "x".repeat(101);
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -459,7 +458,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -468,7 +467,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -477,7 +476,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -508,7 +507,7 @@ class PersonControllerTest {
|
||||
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
||||
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -518,7 +517,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void addAlias_returns403_withoutWritePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -532,7 +531,7 @@ class PersonControllerTest {
|
||||
UUID personId = UUID.randomUUID();
|
||||
UUID aliasId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf()))
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(personService).removeAlias(personId, aliasId);
|
||||
@@ -541,14 +540,14 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -557,7 +556,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
@@ -28,7 +28,6 @@ import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(RelationshipController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -68,7 +67,7 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -77,14 +76,14 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
||||
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf())
|
||||
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"familyMember\":true}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -126,7 +125,7 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -142,7 +141,7 @@ class RelationshipControllerTest {
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -155,7 +154,7 @@ class RelationshipControllerTest {
|
||||
UUID relId = UUID.randomUUID();
|
||||
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
||||
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(TagController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -62,7 +61,7 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -71,7 +70,7 @@ class TagControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -83,7 +82,7 @@ class TagControllerTest {
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
||||
when(tagService.update(any(), any())).thenReturn(tag);
|
||||
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -117,7 +116,7 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void mergeTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -126,7 +125,7 @@ class TagControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -135,7 +134,7 @@ class TagControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -147,7 +146,7 @@ class TagControllerTest {
|
||||
when(tagService.mergeTags(any(), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
|
||||
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -160,7 +159,7 @@ class TagControllerTest {
|
||||
Tag target = Tag.builder().id(targetId).name("Target").build();
|
||||
when(tagService.mergeTags(any(), any())).thenReturn(target);
|
||||
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + targetId + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -172,21 +171,21 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -194,21 +193,21 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(AdminController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -47,7 +46,7 @@ class AdminControllerTest {
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
@@ -61,7 +60,7 @@ class AdminControllerTest {
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
@@ -84,14 +83,14 @@ class AdminControllerTest {
|
||||
|
||||
@Test
|
||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -101,7 +100,7 @@ class AdminControllerTest {
|
||||
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(1));
|
||||
}
|
||||
@@ -110,14 +109,14 @@ class AdminControllerTest {
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -126,7 +125,7 @@ class AdminControllerTest {
|
||||
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
@@ -135,14 +134,14 @@ class AdminControllerTest {
|
||||
|
||||
@Test
|
||||
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -153,7 +152,7 @@ class AdminControllerTest {
|
||||
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.state").value("RUNNING"))
|
||||
.andExpect(jsonPath("$.total").value(10));
|
||||
|
||||
@@ -30,7 +30,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(AuthController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -118,7 +117,7 @@ class AuthControllerTest {
|
||||
req.setFirstName("Max");
|
||||
req.setLastName("Muster");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -135,7 +134,7 @@ class AuthControllerTest {
|
||||
req.setEmail("dupe@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isConflict());
|
||||
@@ -151,7 +150,7 @@ class AuthControllerTest {
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("abc");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -167,7 +166,7 @@ class AuthControllerTest {
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -184,7 +183,7 @@ class AuthControllerTest {
|
||||
req.setPassword("password123");
|
||||
|
||||
// No WithMockUser — must still succeed (no auth challenge)
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
@@ -33,7 +33,6 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(InviteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -104,7 +103,7 @@ class InviteControllerTest {
|
||||
|
||||
@Test
|
||||
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -113,7 +112,7 @@ class InviteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com")
|
||||
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -143,7 +142,7 @@ class InviteControllerTest {
|
||||
req.setLabel("Für Familie");
|
||||
req.setMaxUses(1);
|
||||
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -165,7 +164,7 @@ class InviteControllerTest {
|
||||
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
||||
|
||||
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -179,14 +178,14 @@ class InviteControllerTest {
|
||||
|
||||
@Test
|
||||
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com")
|
||||
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -195,7 +194,7 @@ class InviteControllerTest {
|
||||
void revokeInvite_returns204_whenSuccessful() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/invites/" + id).with(csrf()))
|
||||
mockMvc.perform(delete("/api/invites/" + id))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(inviteService).revokeInvite(id);
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.springframework.mail.MailSendException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -37,10 +36,8 @@ class PasswordResetServiceTest {
|
||||
@Mock PasswordResetTokenRepository tokenRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock JavaMailSender mailSender;
|
||||
@Mock AuthService authService;
|
||||
@InjectMocks PasswordResetService service;
|
||||
|
||||
|
||||
private AppUser makeUser(String email) {
|
||||
return AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
@@ -179,27 +176,6 @@ class PasswordResetServiceTest {
|
||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_revokes_all_sessions_after_password_reset() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.token("validtoken123")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
||||
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("validtoken123");
|
||||
req.setNewPassword("newpass");
|
||||
service.resetPassword(req);
|
||||
|
||||
verify(authService).revokeAllSessions("user@example.com");
|
||||
}
|
||||
|
||||
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.raddatz.familienarchiv.user;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
@@ -12,7 +10,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
@@ -20,8 +17,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -29,7 +24,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(UserController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -38,8 +32,6 @@ class UserControllerTest {
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean AuthService authService;
|
||||
@MockitoBean AuditService auditService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/users/me ────────────────────────────────────────────────────────
|
||||
@@ -91,7 +83,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -100,7 +92,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||
void createUser_returns400_whenEmailContainsColon() throws Exception {
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -109,7 +101,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||
void createUser_returns400_whenEmailIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -120,7 +112,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -129,7 +121,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -138,7 +130,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -146,7 +138,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -154,7 +146,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -162,92 +154,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
// ─── POST /api/users/me/password (changePassword + session revocation) ────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@example.com")
|
||||
void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception {
|
||||
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build();
|
||||
when(userService.findByEmail("user@example.com")).thenReturn(user);
|
||||
when(authService.revokeOtherSessions(any(), any())).thenReturn(1);
|
||||
|
||||
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(authService).revokeOtherSessions(any(), eq("user@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePassword_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@example.com")
|
||||
void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/users/me/password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||
}
|
||||
|
||||
// ─── POST /api/users/{id}/force-logout ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
||||
void forceLogout_returns200_and_revokes_target_sessions() throws Exception {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build();
|
||||
AppUser target = AppUser.builder().id(targetId).email("target@example.com").build();
|
||||
when(userService.findByEmail("admin@example.com")).thenReturn(actor);
|
||||
when(userService.getById(targetId)).thenReturn(target);
|
||||
when(authService.revokeAllSessions("target@example.com")).thenReturn(2);
|
||||
|
||||
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forceLogout_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void forceLogout_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_USER")
|
||||
void forceLogout_returns404_whenUserNotFound() throws Exception {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
when(userService.getById(targetId)).thenThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
||||
void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. 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). |
|
||||
| `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. |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
@@ -117,7 +117,7 @@ Controllers never call repositories directly. Services never reach into another
|
||||
### Permission system
|
||||
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
|
||||
|
||||
Sessions use a 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# ADR-022 — CSRF Protection, Session Revocation, and Login Rate Limiting
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Accepted
|
||||
**Issue:** #524
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-020 established stateful authentication via Spring Session JDBC. Three
|
||||
follow-on security concerns were left open:
|
||||
|
||||
1. **CSRF.** State-changing API calls from the SvelteKit frontend use session
|
||||
cookies. Without CSRF protection an attacker can forge cross-origin requests
|
||||
that carry the victim's session cookie.
|
||||
|
||||
2. **Session revocation.** A user who changes or resets their password may still
|
||||
have other active sessions (other browsers, shared devices). Those sessions
|
||||
should be invalidated so the credential change takes full effect immediately.
|
||||
|
||||
3. **Login rate limiting.** The login endpoint accepts arbitrary email/password
|
||||
pairs. Without throttling it is vulnerable to brute-force and credential-
|
||||
stuffing attacks.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. CSRF — double-submit cookie pattern
|
||||
|
||||
`SecurityConfig` enables `CookieCsrfTokenRepository.withHttpOnlyFalse()`:
|
||||
|
||||
- The backend sets an `XSRF-TOKEN` cookie (readable by JavaScript) on every
|
||||
response.
|
||||
- All state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) must include
|
||||
an `X-XSRF-TOKEN` request header whose value matches the cookie.
|
||||
- `CsrfTokenRequestAttributeHandler` is used (non-XOR mode) — correct for
|
||||
SPAs where token deferred loading would otherwise corrupt values.
|
||||
- SvelteKit's `handleFetch` hook injects the header and mirrors the cookie for
|
||||
every mutating API call.
|
||||
- CSRF validation failures return HTTP 403 with JSON body
|
||||
`{"code": "CSRF_TOKEN_MISSING"}` via a custom `AccessDeniedHandler`.
|
||||
|
||||
Login (`POST /api/auth/login`), forgot-password, and reset-password are
|
||||
**not** CSRF-exempt — the XSRF-TOKEN cookie is set on the first GET to the
|
||||
login page, so the double-submit requirement is satisfiable from the browser.
|
||||
|
||||
### 2. Session revocation
|
||||
|
||||
`AuthService` gains two methods backed by `JdbcIndexedSessionRepository`:
|
||||
|
||||
- `revokeOtherSessions(currentSessionId, principal)` — deletes all sessions
|
||||
for a principal **except** the caller's current session. Called on password
|
||||
change so the user stays logged in on the current device.
|
||||
- `revokeAllSessions(principal)` — deletes every session for a principal.
|
||||
Called on password reset (unauthenticated flow) so no prior sessions survive.
|
||||
|
||||
Both methods are no-ops when `sessionRepository` is `null` (unit-test
|
||||
contexts that do not load Spring Session).
|
||||
|
||||
### 3. Login rate limiting — in-memory token bucket
|
||||
|
||||
`LoginRateLimiter` (Bucket4j + Caffeine) enforces two independent limits:
|
||||
|
||||
| Bucket | Limit | Window | Key |
|
||||
|--------|-------|--------|-----|
|
||||
| Per IP + email | 10 attempts | 15 min | `ip:email` |
|
||||
| Per IP (all emails) | 20 attempts | 15 min | `ip` |
|
||||
|
||||
On each login attempt both buckets are checked **sequentially**:
|
||||
1. Consume from the `ip:email` bucket first.
|
||||
2. If the IP-level bucket is exhausted, **refund** the `ip:email` token.
|
||||
|
||||
The refund prevents IP-level blocking from silently consuming per-email quota:
|
||||
without it, 20 blocked attempts for `target@example.com` from a single IP
|
||||
(caused by another email exhausting the IP bucket) would drain all 10 of
|
||||
`target@`'s tokens.
|
||||
|
||||
On a successful login both buckets are invalidated for that `(ip, email)` pair
|
||||
so a legitimately authenticated user regains the full window immediately.
|
||||
|
||||
Rate-limit violations are audited as `LOGIN_RATE_LIMITED` events.
|
||||
|
||||
The cache is **node-local** (in-memory). In a multi-replica deployment the
|
||||
effective rate limit is multiplied by the replica count. This is acceptable for
|
||||
the current single-VPS production setup and is noted with a comment in the
|
||||
source.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- **CSRF:** All SvelteKit API calls must supply `X-XSRF-TOKEN`. Bare `curl`
|
||||
calls or non-browser clients must obtain and pass the token manually.
|
||||
Integration tests use `.with(csrf())` from `spring-security-test`.
|
||||
- **Session revocation:** Requires `JdbcIndexedSessionRepository` to be wired
|
||||
(Spring Session JDBC dependency). Unit tests inject `null` and verify the
|
||||
no-op path.
|
||||
- **Rate limiting:** False positives are possible if many users share a NAT/VPN
|
||||
IP. The per-IP limit (20) is intentionally loose to reduce collateral
|
||||
blocking; the per-IP+email limit (10) is the primary defence.
|
||||
- `ObjectMapper` in the CSRF `AccessDeniedHandler` uses a static instance
|
||||
because `@WebMvcTest` slices exclude `JacksonAutoConfiguration`. The response
|
||||
only serialises a fixed String key (`"code"`) so naming strategy and custom
|
||||
modules are irrelevant.
|
||||
@@ -9,23 +9,18 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
|
||||
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF enabled: double-submit cookie pattern (CookieCsrfTokenRepository.withHttpOnlyFalse + CsrfTokenRequestAttributeHandler). Custom AccessDeniedHandler returns JSON {\"code\":\"CSRF_TOKEN_MISSING\"}.")
|
||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation.")
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.")
|
||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||
Component(rateLimiter, "LoginRateLimiter", "@Component org.raddatz.familienarchiv.auth", "Dual Bucket4j/Caffeine in-memory rate limiting: per ip:email bucket and per ip bucket. checkAndConsume() throws TOO_MANY_LOGIN_ATTEMPTS (429) when either bucket is exhausted. invalidateOnSuccess() resets both buckets on successful login. Buckets expire after idle windowMinutes.")
|
||||
Component(rateLimitProps, "RateLimitProperties", "@ConfigurationProperties(\"rate-limit.login\") org.raddatz.familienarchiv.auth", "Externalized config for login rate limiting: maxAttemptsPerIpEmail (default 10), maxAttemptsPerIp (default 20), windowMinutes (default 15). Bound from application.yaml rate-limit.login block.")
|
||||
}
|
||||
|
||||
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
|
||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie + X-XSRF-TOKEN header")
|
||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie")
|
||||
Rel(authCtrl, authSvc, "Validate creds + audit")
|
||||
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
|
||||
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
|
||||
Rel(authSvc, rateLimiter, "checkAndConsume() / invalidateOnSuccess()")
|
||||
Rel(authSvc, sessionRepo, "revokeOtherSessions() / revokeAllSessions()")
|
||||
Rel(rateLimiter, rateLimitProps, "Reads config")
|
||||
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@startuml
|
||||
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
|
||||
note over Browser, DB
|
||||
Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524).
|
||||
Adds CSRF double-submit cookies, login rate limiting, and
|
||||
session revocation on password change/reset.
|
||||
Phase 1 of the auth rewrite (ADR-020 / #523).
|
||||
Replaces the Basic-credentials-in-cookie model
|
||||
with an opaque server-side session id (fa_session).
|
||||
end note
|
||||
|
||||
actor User
|
||||
@@ -11,10 +11,9 @@ participant Browser
|
||||
participant "Caddy (TLS termination)" as Caddy
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter
|
||||
participant "spring_session\n(PostgreSQL)" as DB
|
||||
|
||||
== Login (with rate limiting + CSRF bootstrap) ==
|
||||
== Login ==
|
||||
User -> Browser: Enter email + password
|
||||
Browser -> Caddy: HTTPS POST /?/login (form action)
|
||||
note right of Caddy
|
||||
@@ -31,46 +30,19 @@ note right of Backend
|
||||
→ request.getScheme() = "https"
|
||||
→ Secure cookie flag set automatically.
|
||||
end note
|
||||
Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip]
|
||||
alt Rate limit exceeded
|
||||
RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS)
|
||||
Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email})
|
||||
Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"}
|
||||
Frontend --> Browser: Show rate-limit error
|
||||
else Under limit
|
||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||
Backend -> RateLimiter: invalidateOnSuccess(ip, email)
|
||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=<token>;\n Path=/; SameSite=Strict; Secure
|
||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
end
|
||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure
|
||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
|
||||
== 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 ==
|
||||
== Authenticated request ==
|
||||
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
||||
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
|
||||
Frontend -> Frontend: hooks.server.ts reads fa_session
|
||||
@@ -89,28 +61,6 @@ else Session expired (idle > 8h) or unknown
|
||||
Caddy --> Browser: HTTPS 302
|
||||
end
|
||||
|
||||
== Password change (revoke other sessions) ==
|
||||
Browser -> Backend: POST /api/users/me/password\n{currentPassword, newPassword}\n+ X-XSRF-TOKEN
|
||||
Backend -> Backend: Verify currentPassword
|
||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||
Backend -> DB: DELETE spring_session WHERE principal = ?\n AND session_id != <current>
|
||||
note right of Backend
|
||||
revokeOtherSessions: caller stays logged in,
|
||||
all other devices are signed out.
|
||||
end note
|
||||
Backend --> Browser: 204 No Content
|
||||
|
||||
== Password reset (revoke all sessions) ==
|
||||
Browser -> Backend: POST /api/auth/reset-password\n{token, newPassword}
|
||||
Backend -> Backend: Verify reset token
|
||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||
Backend -> DB: DELETE spring_session WHERE principal = ?
|
||||
note right of Backend
|
||||
revokeAllSessions: unauthenticated caller has
|
||||
no session to preserve — all sessions wiped.
|
||||
end note
|
||||
Backend --> Browser: 204 No Content
|
||||
|
||||
== Logout ==
|
||||
Browser -> Caddy: HTTPS POST /logout
|
||||
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
"error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
|
||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
||||
"error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.",
|
||||
"error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"nav_documents": "Dokumente",
|
||||
@@ -352,6 +350,9 @@
|
||||
"admin_system_import_status_running": "Import läuft…",
|
||||
"admin_system_import_status_done": "Import abgeschlossen",
|
||||
"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_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
"error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
|
||||
"error_unauthorized": "You are not logged in.",
|
||||
"error_forbidden": "You do not have permission for this action.",
|
||||
"error_csrf_token_missing": "Session error. Please reload the page.",
|
||||
"error_too_many_login_attempts": "Too many login attempts. Please try again later.",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
"nav_documents": "Documents",
|
||||
@@ -352,6 +350,9 @@
|
||||
"admin_system_import_status_running": "Import running…",
|
||||
"admin_system_import_status_done": "Import complete",
|
||||
"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_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
"error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.",
|
||||
"error_unauthorized": "No ha iniciado sesión.",
|
||||
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
||||
"error_csrf_token_missing": "Error de sesión. Recargue la página.",
|
||||
"error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
"nav_documents": "Documentos",
|
||||
@@ -352,6 +350,9 @@
|
||||
"admin_system_import_status_running": "Importación en curso…",
|
||||
"admin_system_import_status_done": "Importación completada",
|
||||
"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_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||
|
||||
@@ -96,58 +96,42 @@ const userGroup: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
// Auth endpoints that establish/check their own credentials — skip fa_session injection
|
||||
// but still need CSRF tokens on mutating requests.
|
||||
const PUBLIC_API_PATHS = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/invite/',
|
||||
'/api/auth/register'
|
||||
];
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
|
||||
if (!isApi) return fetch(request);
|
||||
|
||||
const isMutating = MUTATING_METHODS.has(request.method);
|
||||
const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p));
|
||||
|
||||
const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null;
|
||||
if (!isPublicAuthApi && !sessionId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// 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
|
||||
if (isApi) {
|
||||
// Auth endpoints that establish/check their own credentials manage cookies themselves;
|
||||
// don't double-inject a stale fa_session.
|
||||
const PUBLIC_API_PATHS = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/invite/',
|
||||
'/api/auth/register'
|
||||
];
|
||||
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
|
||||
return fetch(request);
|
||||
}
|
||||
});
|
||||
return fetch(modified);
|
||||
|
||||
const sessionId = event.cookies.get('fa_session');
|
||||
if (!sessionId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Clone first so the body stream is preserved on the new Request.
|
||||
const cloned = request.clone();
|
||||
const modified = new Request(cloned, {
|
||||
headers: {
|
||||
...Object.fromEntries(cloned.headers),
|
||||
Cookie: `fa_session=${sessionId}`
|
||||
}
|
||||
});
|
||||
return fetch(modified);
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
};
|
||||
|
||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||
|
||||
@@ -49,8 +49,6 @@ export type ErrorCode =
|
||||
| 'MISSING_CREDENTIALS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'CSRF_TOKEN_MISSING'
|
||||
| 'TOO_MANY_LOGIN_ATTEMPTS'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'BATCH_TOO_LARGE'
|
||||
| 'BULK_EDIT_TOO_MANY_IDS'
|
||||
@@ -168,10 +166,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_unauthorized();
|
||||
case 'FORBIDDEN':
|
||||
return m.error_forbidden();
|
||||
case 'CSRF_TOKEN_MISSING':
|
||||
return m.error_csrf_token_missing();
|
||||
case 'TOO_MANY_LOGIN_ATTEMPTS':
|
||||
return m.error_too_many_login_attempts();
|
||||
case 'VALIDATION_ERROR':
|
||||
return m.error_validation_error();
|
||||
case 'BATCH_TOO_LARGE':
|
||||
|
||||
@@ -15,6 +15,12 @@ const failureMessage = $derived(
|
||||
? m.admin_system_import_failed_no_spreadsheet()
|
||||
: 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>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -48,6 +54,38 @@ const failureMessage = $derived(
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
||||
</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
|
||||
data-import-trigger
|
||||
onclick={ontrigger}
|
||||
@@ -79,3 +117,9 @@ const failureMessage = $derived(
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
details[open] .details-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,8 @@ const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
|
||||
state: 'IDLE',
|
||||
statusCode: 'IMPORT_IDLE',
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
skippedFiles: [],
|
||||
startedAt: null,
|
||||
...overrides
|
||||
});
|
||||
@@ -128,4 +130,106 @@ describe('ImportStatusCard', () => {
|
||||
await getByRole('button').click();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export type SkippedFile = {
|
||||
filename: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ImportStatus = {
|
||||
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||
statusCode: string;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
skippedFiles: SkippedFile[];
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
@@ -45,10 +45,6 @@ export const actions = {
|
||||
return fail(401, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ let {
|
||||
form
|
||||
}: {
|
||||
data: { registered: boolean; reason?: string | null };
|
||||
form?: { error?: string; rateLimited?: boolean; success?: boolean };
|
||||
form?: { error?: string; success?: boolean };
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -106,32 +106,7 @@ let {
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
{#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}
|
||||
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
|
||||
@@ -100,25 +100,6 @@ describe('login action', () => {
|
||||
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
|
||||
});
|
||||
|
||||
it('returns 429 with rateLimited=true when the backend rate-limits the request', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 'TOO_MANY_LOGIN_ATTEMPTS' }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await (actions as ActionsRecord).login({
|
||||
request: makeRequest({ email: 'a@b.de', password: 'pw' }),
|
||||
cookies: makeCookies(),
|
||||
fetch: mockFetch,
|
||||
url: new URL('http://localhost/login')
|
||||
} as never);
|
||||
|
||||
expect((result as { status: number }).status).toBe(429);
|
||||
expect((result as { data: { rateLimited: boolean } }).data.rateLimited).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 500 when backend response omits fa_session cookie', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const cookies = makeCookies();
|
||||
|
||||
@@ -70,16 +70,4 @@ describe('login page', () => {
|
||||
.element(page.getByRole('link', { name: /passwort vergessen/i }))
|
||||
.toHaveAttribute('href', '/forgot-password');
|
||||
});
|
||||
|
||||
it('shows rate-limit alert with clock icon when rateLimited is true', async () => {
|
||||
render(LoginPage, {
|
||||
props: {
|
||||
data: { registered: false },
|
||||
form: { error: 'Zu viele Anmeldeversuche.', rateLimited: true }
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
await expect.element(page.getByText('Zu viele Anmeldeversuche.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.",
|
||||
"matchPackageNames": ["com.bucket4j:bucket4j-core"],
|
||||
"groupName": "bucket4j",
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@tiptap/"],
|
||||
"groupName": "tiptap",
|
||||
|
||||
Reference in New Issue
Block a user