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