Compare commits

..

2 Commits

Author SHA1 Message Date
Marcel
22589e4729 feat(admin): surface skipped file count in ImportStatusCard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m4s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m2s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Semgrep Security Scan (pull_request) Successful in 18s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Adds SkippedFile to the local ImportStatus type and updates
ImportStatusCard to show an amber skipped-count section with a
collapsible filename list in the DONE state. Only rendered when
skipped > 0. i18n keys added for de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:14:32 +02:00
Marcel
e124c68cf4 feat(import): validate PDF magic bytes before S3 upload
Reads first 4 bytes of each candidate file before upload; rejects any
file whose header does not match %PDF (0x25 0x50 0x44 0x46). Skipped
files are counted and collected in ImportStatus.skippedFiles so
operators can see what was rejected without querying Loki.

Breaking: ImportStatus record gains skipped + skippedFiles fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:51:29 +02:00
165 changed files with 2600 additions and 7113 deletions

View File

@@ -13,7 +13,7 @@ jobs:
name: Unit & Component Tests name: Unit & Component Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.60.0-noble image: mcr.microsoft.com/playwright:v1.58.2-noble
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -29,10 +29,6 @@ jobs:
run: npm ci run: npm ci
working-directory: frontend working-directory: frontend
- name: Security audit (no dev deps)
run: npm audit --audit-level=high --omit=dev
working-directory: frontend
- name: Compile Paraglide i18n - name: Compile Paraglide i18n
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend working-directory: frontend

View File

@@ -79,7 +79,6 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
EOF EOF
- name: Verify backend /import:ro mount is wired - name: Verify backend /import:ro mount is wired
@@ -253,20 +252,20 @@ jobs:
URL="https://$HOST" URL="https://$HOST"
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route) HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; } [ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
RESOLVE=(--resolve "$HOST:443:$HOST_IP") RESOLVE="--resolve $HOST:443:$HOST_IP"
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence: # Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently. # fail this check rather than pass it silently.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera, # Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the # microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step. # header now fails the smoke step.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed" echo "All smoke checks passed"

View File

@@ -181,31 +181,28 @@ jobs:
- name: Smoke test deployed environment - name: Smoke test deployed environment
# See nightly.yml — same three checks, against the prod vhost. # See nightly.yml — same three checks, against the prod vhost.
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two # --resolve pins to the bridge gateway IP (the host), not 127.0.0.1
# separate arguments; a quoted string would pass the flag and its value # — see nightly.yml for the full network topology explanation.
# as one token and curl would reject it as an unknown option.
# Gateway detection via /proc/net/route — no iproute2 dependency.
# See nightly.yml for the full network topology explanation.
run: | run: |
set -e set -e
HOST="archiv.raddatz.cloud" HOST="archiv.raddatz.cloud"
URL="https://$HOST" URL="https://$HOST"
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route) HOST_IP=$(ip route show default | awk '/default/ {print $3}')
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; } [ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; }
RESOLVE=(--resolve "$HOST:443:$HOST_IP") RESOLVE="--resolve $HOST:443:$HOST_IP"
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence: # Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently. # fail this check rather than pass it silently.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera, # Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the # microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step. # header now fails the smoke step.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed" echo "All smoke checks passed"

View File

@@ -77,7 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
``` ```
backend/src/main/java/org/raddatz/familienarchiv/ backend/src/main/java/org/raddatz/familienarchiv/
├── audit/ Audit logging ├── audit/ Audit logging
├── auth/ AuthService, AuthSessionController, LoginRequest, 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)
@@ -160,7 +160,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). **LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
### Security / Permissions ### Security / Permissions
@@ -267,7 +267,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). **LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
--- ---

View File

@@ -263,7 +263,7 @@ if (!result.response.ok) {
return { person: result.data! }; // non-null assertion is safe after the ok check return { person: result.data! }; // non-null assertion is safe after the ok check
``` ```
For multipart/form-data (file uploads): bypass the typed client and use `event.fetch` directly — never global `fetch`. The typed client cannot handle multipart bodies, but `event.fetch` is still required so that `handleFetch` injects the session cookie. For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
### Date handling ### Date handling

View File

@@ -97,10 +97,7 @@ public class MyEntity {
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`. - Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
- Write methods: `@Transactional`. - Write methods: `@Transactional`.
- Read methods: no annotation (default non-transactional)**except** when the method returns - Read methods: no annotation (default non-transactional).
an entity whose lazy associations must remain accessible to the caller after the method
returns. In that case, use `@Transactional(readOnly = true)` to keep the Hibernate session
open. Removing this annotation causes `LazyInitializationException` in production. See ADR-022.
- Cross-domain access goes through the other domain's service, never its repository. - Cross-domain access goes through the other domain's service, never its repository.
## Error Handling ## Error Handling

View File

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

View File

@@ -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,

View File

@@ -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(

View File

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

View File

@@ -1,72 +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) {
long retryAfterSeconds = windowMinutes * 60L;
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, retryAfterSeconds);
}
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, retryAfterSeconds);
}
}
public void invalidateOnSuccess(String ip, String email) {
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
byIp.invalidate(ip);
}
private static Bucket newBucket(int limit, int minutes) {
return Bucket.builder()
.addLimit(Bandwidth.builder()
.capacity(limit)
.refillGreedy(limit, Duration.ofMinutes(minutes))
.build())
.build();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,6 @@ public class RateLimitInterceptor implements HandlerInterceptor {
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0)); AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) { if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "60");
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}"); response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
return false; return false;
} }

View File

@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.document;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
@@ -22,18 +21,6 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@NamedEntityGraph(name = "Document.full", attributeNodes = {
@NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"),
@NamedAttributeNode("tags"),
@NamedAttributeNode("trainingLabels")
})
@NamedEntityGraph(name = "Document.list", attributeNodes = {
@NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"),
@NamedAttributeNode("tags"),
@NamedAttributeNode("trainingLabels")
})
@Entity @Entity
@Table(name = "documents") @Table(name = "documents")
@Data // Lombok: Generiert Getter, Setter, ToString, etc. @Data // Lombok: Generiert Getter, Setter, ToString, etc.
@@ -131,27 +118,24 @@ public class Document {
@Builder.Default @Builder.Default
private ScriptType scriptType = ScriptType.UNKNOWN; private ScriptType scriptType = ScriptType.UNKNOWN;
@ManyToMany(fetch = FetchType.LAZY) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id")) @JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
@BatchSize(size = 50)
@Builder.Default @Builder.Default
private Set<Person> receivers = new HashSet<>(); private Set<Person> receivers = new HashSet<>();
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne
@JoinColumn(name = "sender_id") @JoinColumn(name = "sender_id")
private Person sender; private Person sender;
@ManyToMany(fetch = FetchType.LAZY) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) @JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
@BatchSize(size = 50)
@Builder.Default @Builder.Default
private Set<Tag> tags = new HashSet<>(); private Set<Tag> tags = new HashSet<>();
@ElementCollection(fetch = FetchType.LAZY) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id")) @CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
@Column(name = "label") @Column(name = "label")
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@BatchSize(size = 50)
@Builder.Default @Builder.Default
private Set<TrainingLabel> trainingLabels = new HashSet<>(); private Set<TrainingLabel> trainingLabels = new HashSet<>();

View File

@@ -7,8 +7,6 @@ import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
@@ -25,18 +23,6 @@ import java.util.UUID;
@Repository @Repository
public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSpecificationExecutor<Document> { public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSpecificationExecutor<Document> {
@EntityGraph("Document.full")
Optional<Document> findById(UUID id);
@EntityGraph("Document.list")
Page<Document> findAll(Specification<Document> spec, Pageable pageable);
@EntityGraph("Document.list")
List<Document> findAll(Specification<Document> spec);
@EntityGraph("Document.list")
Page<Document> findAll(Pageable pageable);
// Findet ein Dokument anhand des ursprünglichen Dateinamens // Findet ein Dokument anhand des ursprünglichen Dateinamens
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload // Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename); Optional<Document> findByOriginalFilename(String originalFilename);
@@ -44,21 +30,17 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren // Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
Optional<Document> findFirstByOriginalFilename(String originalFilename); Optional<Document> findFirstByOriginalFilename(String originalFilename);
// Callers access only status/id scalar fields — no graph needed. // Findet alle Dokumente mit einem bestimmten Status
// z.B. um alle offenen "PLACEHOLDER" zu finden
List<Document> findByStatus(DocumentStatus status); List<Document> findByStatus(DocumentStatus status);
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück) // Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
boolean existsByOriginalFilename(String originalFilename); boolean existsByOriginalFilename(String originalFilename);
// lazy @BatchSize(50) fallback active; see ADR-022
@EntityGraph("Document.full")
List<Document> findBySenderId(UUID senderId); List<Document> findBySenderId(UUID senderId);
// lazy @BatchSize(50) fallback active; see ADR-022
@EntityGraph("Document.full")
List<Document> findByReceiversId(UUID receiverId); List<Document> findByReceiversId(UUID receiverId);
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
List<Document> findByTags_Id(UUID tagId); List<Document> findByTags_Id(UUID tagId);
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)") @Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
@@ -73,15 +55,12 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
long countByMetadataCompleteFalse(); long countByMetadataCompleteFalse();
// No production callers — only used if a future export path iterates the full list; no graph needed.
List<Document> findByMetadataCompleteFalse(Sort sort); List<Document> findByMetadataCompleteFalse(Sort sort);
// Callers map to IncompleteDocumentDTO using only scalar fields (id, title, createdAt) — no graph needed.
Page<Document> findByMetadataCompleteFalse(Pageable pageable); Page<Document> findByMetadataCompleteFalse(Pageable pageable);
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort); Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
@EntityGraph("Document.full")
@Query("SELECT DISTINCT d FROM Document d " + @Query("SELECT DISTINCT d FROM Document d " +
"JOIN d.receivers r " + "JOIN d.receivers r " +
"WHERE " + "WHERE " +
@@ -96,7 +75,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Param("to") LocalDate to, @Param("to") LocalDate to,
Sort sort); Sort sort);
@EntityGraph("Document.full")
@Query("SELECT DISTINCT d FROM Document d " + @Query("SELECT DISTINCT d FROM Document d " +
"LEFT JOIN d.receivers r " + "LEFT JOIN d.receivers r " +
"WHERE (d.sender.id = :personId OR r.id = :personId) " + "WHERE (d.sender.id = :personId OR r.id = :personId) " +

View File

@@ -447,7 +447,6 @@ public class DocumentService {
return saved; return saved;
} }
@Transactional
public Document updateDocumentTags(UUID docId, List<String> tagNames) { public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId) Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId)); .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
@@ -636,7 +635,7 @@ public class DocumentService {
return saved; return saved;
} }
@Transactional(readOnly = true) // 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
public List<Document> getRecentActivity(int size) { public List<Document> getRecentActivity(int size) {
return documentRepository.findAll( return documentRepository.findAll(
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt")) PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
@@ -844,7 +843,6 @@ public class DocumentService {
documentRepository.save(doc); documentRepository.save(doc);
} }
@Transactional(readOnly = true)
public Document getDocumentById(UUID id) { public Document getDocumentById(UUID id) {
Document doc = documentRepository.findById(id) Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));

View File

@@ -43,7 +43,7 @@ public class TranscriptionBlockController {
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock( public TranscriptionBlock createBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@Valid @RequestBody CreateTranscriptionBlockDTO dto, @Valid @RequestBody CreateTranscriptionBlockDTO dto,
@@ -53,7 +53,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/{blockId}") @PutMapping("/{blockId}")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock updateBlock( public TranscriptionBlock updateBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@PathVariable UUID blockId, @PathVariable UUID blockId,
@@ -65,7 +65,7 @@ public class TranscriptionBlockController {
@DeleteMapping("/{blockId}") @DeleteMapping("/{blockId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public void deleteBlock( public void deleteBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@PathVariable UUID blockId) { @PathVariable UUID blockId) {
@@ -73,7 +73,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/reorder") @PutMapping("/reorder")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> reorderBlocks( public List<TranscriptionBlock> reorderBlocks(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@RequestBody ReorderTranscriptionBlocksDTO dto) { @RequestBody ReorderTranscriptionBlocksDTO dto) {
@@ -82,7 +82,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/{blockId}/review") @PutMapping("/{blockId}/review")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock reviewBlock( public TranscriptionBlock reviewBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@PathVariable UUID blockId, @PathVariable UUID blockId,
@@ -92,7 +92,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/review-all") @PutMapping("/review-all")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed( public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId, @PathVariable UUID documentId,
Authentication authentication) { Authentication authentication) {

View File

@@ -10,21 +10,11 @@ public class DomainException extends RuntimeException {
private final ErrorCode code; private final ErrorCode code;
private final HttpStatus status; private final HttpStatus status;
/** Seconds until the rate-limit window resets; {@code null} when not applicable. */
private final Long retryAfterSeconds;
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) { public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
super(developerMessage); super(developerMessage);
this.code = code; this.code = code;
this.status = status; this.status = status;
this.retryAfterSeconds = null;
}
private DomainException(ErrorCode code, HttpStatus status, String developerMessage, Long retryAfterSeconds) {
super(developerMessage);
this.code = code;
this.status = status;
this.retryAfterSeconds = retryAfterSeconds;
} }
public ErrorCode getCode() { public ErrorCode getCode() {
@@ -35,11 +25,6 @@ public class DomainException extends RuntimeException {
return status; return status;
} }
/** Returns the {@code Retry-After} value in seconds, or {@code null} if not set. */
public Long getRetryAfterSeconds() {
return retryAfterSeconds;
}
// --- Static factories for common cases --- // --- Static factories for common cases ---
public static DomainException notFound(ErrorCode code, String message) { public static DomainException notFound(ErrorCode code, String message) {
@@ -70,12 +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);
}
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
}
} }

View File

@@ -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 */

View File

@@ -23,11 +23,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class) @ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) { public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
var builder = ResponseEntity.status(ex.getStatus()); return ResponseEntity
if (ex.getRetryAfterSeconds() != null) { .status(ex.getStatus())
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds())); .body(new ErrorResponse(ex.getCode(), ex.getMessage()));
}
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
} }
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)

View File

@@ -1,8 +1,6 @@
package org.raddatz.familienarchiv.importing; package org.raddatz.familienarchiv.importing;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*; import org.apache.poi.ss.usermodel.*;
@@ -56,41 +54,13 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED } public enum State { IDLE, RUNNING, DONE, FAILED }
public enum SkipReason { public record SkippedFile(String filename, String reason) {}
INVALID_FILENAME_PATH_TRAVERSAL,
INVALID_PDF_SIGNATURE,
FILE_READ_ERROR,
ALREADY_EXISTS,
S3_UPLOAD_FAILED
}
public record SkippedFile( public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, int skipped, List<SkippedFile> skippedFiles, LocalDateTime startedAt) {}
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) SkipReason reason
) {}
public record ImportStatus( record ProcessResult(int processed, int skipped, List<SkippedFile> skippedFiles) {}
@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
) {
// Note: @Schema on a record accessor method is not picked up by SpringDoc; the
// "skipped" count is a computed convenience field derived from skippedFiles.size().
@JsonProperty("skipped")
public int skipped() { return skippedFiles.size(); }
/** Defensive-copy constructor — callers cannot mutate the stored list after construction. */ private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 0, List.of(), null);
public ImportStatus {
skippedFiles = List.copyOf(skippedFiles);
}
}
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;
@@ -152,22 +122,22 @@ public class MassImportService {
if (currentStatus.state() == State.RUNNING) { if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress"); throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
} }
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now()); currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, 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());
ProcessResult result = processRows(readSpreadsheet(spreadsheet)); ProcessResult result = processRows(readSpreadsheet(spreadsheet));
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE", currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.", "Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
result.processed(), result.skippedFiles(), currentStatus.startedAt()); result.processed(), result.skipped(), 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, List.of(), currentStatus.startedAt()); "Fehler: " + e.getMessage(), 0, 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, List.of(), currentStatus.startedAt()); "Fehler: " + e.getMessage(), 0, 0, List.of(), currentStatus.startedAt());
} }
} }
@@ -299,11 +269,6 @@ public class MassImportService {
if (index.isBlank()) continue; if (index.isBlank()) continue;
String filename = index.contains(".") ? index : index + ".pdf"; String filename = index.contains(".") ? index : index + ".pdf";
if (!isValidImportFilename(filename)) {
log.warn("Skipping import row {}: filename rejected — {}", i, filename);
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_FILENAME_PATH_TRAVERSAL));
continue;
}
Optional<File> fileOnDisk = findFileRecursive(filename); Optional<File> fileOnDisk = findFileRecursive(filename);
if (fileOnDisk.isEmpty()) { if (fileOnDisk.isEmpty()) {
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename); log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
@@ -313,50 +278,26 @@ public class MassImportService {
try { try {
if (!isPdfMagicBytes(fileOnDisk.get())) { if (!isPdfMagicBytes(fileOnDisk.get())) {
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename); log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_PDF_SIGNATURE)); skippedFiles.add(new SkippedFile(filename, "Keine gültige PDF-Signatur"));
continue; continue;
} }
} catch (IOException e) { } catch (IOException e) {
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e); log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
skippedFiles.add(new SkippedFile(filename, SkipReason.FILE_READ_ERROR)); skippedFiles.add(new SkippedFile(filename, "Fehler beim Lesen der Datei"));
continue; continue;
} }
} }
Optional<SkipReason> skipReason = importSingleDocument(cells, fileOnDisk, filename, index); boolean imported = importSingleDocument(cells, fileOnDisk, filename, index);
if (skipReason.isPresent()) { if (imported) {
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
} else {
processed++; processed++;
} }
} }
return new ProcessResult(processed, skippedFiles); return new ProcessResult(processed, skippedFiles.size(), skippedFiles);
}
private boolean isValidImportFilename(String filename) {
if (filename == null || filename.isBlank()) return false;
if (filename.contains("/")) return false;
if (filename.contains("\\")) return false;
if (filename.contains("")) return false; // U+2215 DIVISION SLASH
if (filename.contains("")) return false; // U+FF0F FULLWIDTH SOLIDUS
if (filename.contains("")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
if (filename.contains("..")) return false;
if (filename.equals(".")) return false;
if (filename.contains("\0")) return false;
// Paths.get() is safe here on Linux for all inputs that passed the checks above;
// it may throw InvalidPathException for OS-specific illegal chars on Windows,
// but those are not reachable in production.
if (Paths.get(filename).isAbsolute()) return false;
return true;
}
// 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 { private boolean isPdfMagicBytes(File file) throws IOException {
try (InputStream is = openFileStream(file)) { try (InputStream is = new FileInputStream(file)) {
byte[] header = is.readNBytes(4); byte[] header = is.readNBytes(4);
return header.length == 4 return header.length == 4
&& header[0] == 0x25 // % && header[0] == 0x25 // %
@@ -366,17 +307,12 @@ public class MassImportService {
} }
} }
/**
* Imports a single document row.
*
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
*/
@Transactional @Transactional
protected Optional<SkipReason> 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 Optional.of(SkipReason.ALREADY_EXISTS); return false;
} }
String archiveBox = getCell(cells, colBox); String archiveBox = getCell(cells, colBox);
@@ -412,7 +348,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 Optional.of(SkipReason.S3_UPLOAD_FAILED); return false;
} }
} }
@@ -454,7 +390,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 Optional.empty(); return true;
} }
// --- Helpers --- // --- Helpers ---
@@ -490,18 +426,11 @@ public class MassImportService {
} }
private Optional<File> findFileRecursive(String filename) { private Optional<File> findFileRecursive(String filename) {
File baseDir = new File(importDir); try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
try (Stream<Path> walk = Files.walk(baseDir.toPath())) { return walk.filter(p -> !Files.isDirectory(p))
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.map(Path::toFile)
.findFirst(); .findFirst();
if (match.isEmpty()) return Optional.empty();
File candidate = match.get().toFile();
String baseDirCanonical = baseDir.getCanonicalPath();
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
}
return Optional.of(candidate);
} catch (IOException e) { } catch (IOException e) {
return Optional.empty(); return Optional.empty();
} }

View File

@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.person; package org.raddatz.familienarchiv.person;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
@@ -10,9 +9,6 @@ import org.raddatz.familienarchiv.user.DisplayNameFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
@Entity @Entity
@Table(name = "persons") @Table(name = "persons")
@Data @Data

View File

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

View File

@@ -2,13 +2,10 @@ package org.raddatz.familienarchiv.tag;
import java.util.UUID; import java.util.UUID;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
@Entity @Entity
@Data @Data
@NoArgsConstructor @NoArgsConstructor

View File

@@ -31,6 +31,5 @@ public class InviteListItemDTO {
private String status; private String status;
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String shareableUrl; private String shareableUrl;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

@@ -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.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test for {@link JdbcSessionRevocationAdapter} that verifies
* session rows are actually written to / removed from the {@code spring_session}
* table backed by a real PostgreSQL container.
*
* <p>Sessions are inserted via raw JDBC to avoid the module-access restriction on
* {@code JdbcIndexedSessionRepository.JdbcSession}. The {@link SessionRevocationPort}
* bean injected here is the real {@link JdbcSessionRevocationAdapter} wired by Spring.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JdbcSessionRevocationAdapterIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired SessionRevocationPort adapter;
@Autowired JdbcTemplate jdbcTemplate;
@Autowired TransactionTemplate transactionTemplate;
private static final String PRINCIPAL = "revocation-it@test.de";
@BeforeEach
void clearSessions() {
// spring_session_attributes cascades on delete
transactionTemplate.execute(status -> {
jdbcTemplate.update("DELETE FROM spring_session");
return null;
});
}
// ── helper ─────────────────────────────────────────────────────────────────
/**
* Inserts a minimal {@code spring_session} row attributed to {@value #PRINCIPAL}
* and returns its opaque primary-key ID (the value the repository uses as the
* session identifier, not the {@code SESSION_ID} column which holds the public token).
*
* <p>Column layout mirrors the Flyway-managed schema shipped with the app:
* PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, MAX_INACTIVE_INTERVAL,
* EXPIRY_TIME, PRINCIPAL_NAME.
*/
/**
* Inserts a persisted session row for {@value #PRINCIPAL} and returns the
* {@code SESSION_ID} column value — this is the opaque identifier that
* {@link JdbcIndexedSessionRepository} uses as the session's public key
* (returned by {@code JdbcSession.getId()} and expected by
* {@link JdbcIndexedSessionRepository#deleteById}).
*
* <p>The inserts run inside a {@link TransactionTemplate} so the rows are
* committed before {@code findByPrincipalName} opens its own transaction and
* can see the data via Read Committed isolation.
*/
private String insertSession() {
String primaryId = UUID.randomUUID().toString();
// SESSION_ID is the value used by JdbcSession.getId() and findByPrincipalName map keys.
String sessionId = UUID.randomUUID().toString();
long now = Instant.now().toEpochMilli();
long expiry = now + 8L * 3600 * 1000; // 8-hour TTL
transactionTemplate.execute(status -> {
jdbcTemplate.update("""
INSERT INTO spring_session
(PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME,
MAX_INACTIVE_INTERVAL, EXPIRY_TIME, PRINCIPAL_NAME)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
primaryId, sessionId, now, now, 28800, expiry, PRINCIPAL);
// Spring Session's listSessionsByPrincipalName query joins spring_session_attributes;
// insert a minimal attribute row so the session appears in the result set.
jdbcTemplate.update("""
INSERT INTO spring_session_attributes
(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
VALUES (?, ?, ?)
""",
primaryId, "test_attr", new byte[]{0});
return null;
});
return sessionId; // the public key used by JdbcSession.getId() and deleteById()
}
// ── tests ──────────────────────────────────────────────────────────────────
@Test
void revokeAllSessions_removes_every_row_from_spring_session_table() {
insertSession();
insertSession();
int count = adapter.revokeAllSessions(PRINCIPAL);
assertThat(count).isEqualTo(2);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
Long.class, PRINCIPAL))
.isZero();
}
@Test
void revokeOtherSessions_deletes_non_current_rows_and_keeps_current_session() {
String keepId = insertSession();
insertSession();
insertSession();
int count = adapter.revokeOtherSessions(keepId, PRINCIPAL);
assertThat(count).isEqualTo(2);
// The current session row must still be present (keyed by SESSION_ID)
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM spring_session WHERE SESSION_ID = ?",
Long.class, keepId))
.isEqualTo(1L);
// The total for this principal is now exactly 1
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
Long.class, PRINCIPAL))
.isEqualTo(1L);
}
}

View File

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

View File

@@ -1,148 +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 blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() {
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).getRetryAfterSeconds())
.isEqualTo(15 * 60L)); // windowMinutes=15 → 900 seconds
}
@Test
void success_after_10_failures_resets_ip_email_bucket() {
for (int i = 0; i < 10; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
}
rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
assertThatNoException().isThrownBy(
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
}
@Test
void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
for (int i = 0; i < 20; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
}
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
}
@Test
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
for (int i = 0; i < 10; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
}
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
.isInstanceOf(DomainException.class);
assertThatNoException().isThrownBy(
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
}
@Test
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
for (int i = 0; i < 10; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
}
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
}
@Test
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
for (int i = 0; i < 10; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
}
rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");
assertThatNoException().isThrownBy(
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
}
@Test
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
// Use a tighter limiter so the phantom-consumption effect is observable.
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
RateLimitProperties props = new RateLimitProperties();
props.setMaxAttemptsPerIpEmail(3);
props.setMaxAttemptsPerIp(3);
props.setWindowMinutes(15);
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
// Exhaust the per-IP bucket using "user@"
for (int i = 0; i < 3; i++) {
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
}
// Three blocked attempts for "target@" while IP is exhausted
for (int i = 0; i < 3; i++) {
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
.isInstanceOf(DomainException.class);
}
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
assertThatNoException().isThrownBy(
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
}
}

View File

@@ -45,15 +45,6 @@ class RateLimitInterceptorTest {
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
} }
@Test
void blocked_response_includes_retry_after_header() throws Exception {
for (int i = 0; i < 10; i++) {
interceptor.preHandle(request, response, null);
}
interceptor.preHandle(request, response, null);
verify(response).setHeader("Retry-After", "60");
}
@Test @Test
void different_ips_have_independent_limits() throws Exception { void different_ips_have_independent_limits() throws Exception {
HttpServletRequest other = mock(HttpServletRequest.class); HttpServletRequest other = mock(HttpServletRequest.class);

View File

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

View File

@@ -1,178 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.dashboard.DashboardService;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Verifies that lazy-loaded associations on {@link Document} are accessible after a service
* method returns — i.e. no {@link org.hibernate.LazyInitializationException} is thrown outside
* the Hibernate session that loaded the entity.
*
* <p><b>Known limitation:</b> calling {@code getDocumentById} (or any other service method) from
* within an already-open transaction is not covered here. When an outer transaction is active,
* the service's own {@code @Transactional} merges into it and Hibernate keeps the same session
* open, so the lazy-init guard behaves differently than in a non-transactional caller. This is a
* known constraint of the test setup, not a bug in the production code.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class DocumentLazyLoadingTest {
@MockitoBean
S3Client s3Client;
@Autowired
DocumentRepository documentRepository;
@Autowired
PersonRepository personRepository;
@Autowired
TagRepository tagRepository;
@Autowired
DocumentService documentService;
@Autowired
DashboardService dashboardService;
@MockitoBean
AuditLogQueryService auditLogQueryService;
@AfterEach
void cleanup() {
documentRepository.deleteAll();
tagRepository.deleteAll();
personRepository.deleteAll();
}
@Test
void getDocumentById_tagsAndReceiversAccessible_afterReturnFromService() {
Person sender = savedPerson("Max", "LzSender");
Person receiver = savedPerson("Anna", "LzReceiver");
Tag tag = savedTag("LzTag");
Document doc = savedDocument("LazyTest", "lazy_test.pdf", sender, Set.of(receiver), Set.of(tag));
Document result = documentService.getDocumentById(doc.getId());
// Only the collection access itself is in assertThatCode — guards against LazyInitializationException.
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
assertThatCode(() -> {
result.getTags().size();
result.getReceivers().size();
}).doesNotThrowAnyException();
assertThat(result.getTags()).isNotEmpty();
result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
assertThat(result.getReceivers()).isNotEmpty();
result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
}
@Test
void getRecentActivity_collectionsAccessibleAfterReturn() {
Person sender = savedPerson("Hans", "RaSender");
Tag tag = savedTag("RaTag");
for (int i = 0; i < 3; i++) {
savedDocument("RaDoc " + i, "ra_doc" + i + ".pdf", sender, Set.of(), Set.of(tag));
}
List<Document> results = documentService.getRecentActivity(3);
// Access lazy fields inside assertThatCode — guards against LazyInitializationException.
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
assertThatCode(() -> {
results.forEach(d -> d.getSender().getLastName());
results.forEach(d -> d.getTags().size());
}).doesNotThrowAnyException();
results.forEach(d -> assertThat(d.getSender()).isNotNull());
results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull());
results.forEach(d -> assertThat(d.getTags()).isNotEmpty());
}
@Test
void searchDocuments_receiverSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SrSender");
Person receiver = savedPerson("Anna", "SrReceiver");
Tag tag = savedTag("SrTag");
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.RECEIVER, "asc", null,
PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() ->
result.items().forEach(i -> i.document().getSender().getLastName()))
.doesNotThrowAnyException();
}
@Test
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SsSender");
Tag tag = savedTag("SsTag");
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null,
PageRequest.of(0, 20)))
.doesNotThrowAnyException();
}
@Test
void dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException() {
Person sender = savedPerson("Max", "DsSender");
Person receiver = savedPerson("Anna", "DsReceiver");
Document doc = savedDocument("DashboardTest", "dashboard_test.pdf", sender, Set.of(receiver), Set.of());
UUID fakeUserId = UUID.randomUUID();
when(auditLogQueryService.findMostRecentDocumentForUser(any())).thenReturn(Optional.of(doc.getId()));
when(auditLogQueryService.findRecentContributorsPerDocument(any())).thenReturn(java.util.Map.of());
assertThatCode(() -> dashboardService.getResume(fakeUserId))
.doesNotThrowAnyException();
}
private Person savedPerson(String firstName, String lastName) {
return personRepository.save(Person.builder().firstName(firstName).lastName(lastName).build());
}
private Tag savedTag(String name) {
return tagRepository.save(Tag.builder().name(name).build());
}
private Document savedDocument(String title, String filename, Person sender,
Set<Person> receivers, Set<Tag> tags) {
return documentRepository.save(Document.builder()
.title(title).originalFilename(filename)
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(receivers))
.tags(new HashSet<>(tags))
.build());
}
}

View File

@@ -1,9 +1,5 @@
package org.raddatz.familienarchiv.document; package org.raddatz.familienarchiv.document;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig; import org.raddatz.familienarchiv.config.FlywayConfig;
@@ -25,7 +21,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -60,12 +55,6 @@ class DocumentRepositoryTest {
@Autowired @Autowired
private TranscriptionBlockRepository transcriptionBlockRepository; private TranscriptionBlockRepository transcriptionBlockRepository;
@Autowired
private EntityManagerFactory entityManagerFactory;
@Autowired
private EntityManager entityManager;
// ─── save and findById ──────────────────────────────────────────────────── // ─── save and findById ────────────────────────────────────────────────────
@Test @Test
@@ -501,117 +490,6 @@ class DocumentRepositoryTest {
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId()); assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
} }
// ─── query-count — entity-graph assertions ────────────────────────────────
@Test
void findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements() {
Person sender = personRepository.save(Person.builder().firstName("Hans").lastName("QcSender").build());
Person receiver = personRepository.save(Person.builder().firstName("Anna").lastName("QcReceiver").build());
Tag tag = tagRepository.save(Tag.builder().name("QcTag").build());
for (int i = 0; i < 10; i++) {
documentRepository.save(Document.builder()
.title("QcDoc " + i).originalFilename("qcdoc" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver)))
.tags(new HashSet<>(Set.of(tag)))
.build());
}
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
Specification<Document> allDocs = (root, query, cb) -> null;
documentRepository.findAll(allDocs, PageRequest.of(0, 10));
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1")
.isLessThanOrEqualTo(5);
}
@Test
void findById_loadsSenderReceiversAndTagsInAtMostTwoStatements() {
Person sender = personRepository.save(Person.builder().firstName("Max").lastName("FbSender").build());
Set<Person> receivers = new HashSet<>();
for (int i = 0; i < 3; i++) {
receivers.add(personRepository.save(
Person.builder().firstName("R" + i).lastName("FbReceiver").build()));
}
Set<Tag> tags = new HashSet<>();
for (int i = 0; i < 5; i++) {
tags.add(tagRepository.save(Tag.builder().name("FbTag" + i).build()));
}
Document doc = documentRepository.save(Document.builder()
.title("FindByIdQc").originalFilename("findbyid_qc.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(receivers).tags(tags)
.build());
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
documentRepository.findById(doc.getId());
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4")
.isLessThanOrEqualTo(2);
}
@Test
void findAll_withPageable_loadsSenderWithoutNPlusOne() {
Person sender = personRepository.save(Person.builder().firstName("Maria").lastName("RaSender").build());
Tag tag = tagRepository.save(Tag.builder().name("RaTag2").build());
for (int i = 0; i < 5; i++) {
documentRepository.save(Document.builder()
.title("RaDoc2 " + i).originalFilename("radoc2_" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.tags(new HashSet<>(Set.of(tag)))
.build());
}
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
documentRepository.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "updatedAt")));
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.list) via findAll(Pageable) must not N+1 sender for 5 docs")
.isLessThanOrEqualTo(5);
}
@Test
void findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements() {
Person sender = personRepository.save(Person.builder().firstName("Otto").lastName("SoSender").build());
Tag tag = tagRepository.save(Tag.builder().name("SoTag").build());
for (int i = 0; i < 5; i++) {
documentRepository.save(Document.builder()
.title("SoDoc " + i).originalFilename("sodoc_" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.tags(new HashSet<>(Set.of(tag)))
.build());
}
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
Specification<Document> allDocs = (root, query, cb) -> null;
documentRepository.findAll(allDocs);
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.list) via findAll(Spec) must not N+1 sender for 5 docs")
.isLessThanOrEqualTo(5);
}
// ─── seeding helpers ───────────────────────────────────────────────────── // ─── seeding helpers ─────────────────────────────────────────────────────
private Document uploaded(String title) { private Document uploaded(String title) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -135,7 +135,7 @@ class MassImportServiceTest {
@Test @Test
void runImportAsync_throwsConflict_whenAlreadyRunning() { void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus( MassImportService.ImportStatus running = new MassImportService.ImportStatus(
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now()); MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, 0, List.of(), LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running); ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync()) assertThatThrownBy(() -> service.runImportAsync())
@@ -154,76 +154,9 @@ class MassImportServiceTest {
.build(); .build();
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing)); when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
Optional<MassImportService.SkipReason> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001"); service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentService, never()).save(any()); verify(documentService, never()).save(any());
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
}
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
@Test
void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception {
// Document already exists with status UPLOADED (not PLACEHOLDER).
// A physical PDF file is also present on disk (valid magic bytes).
// Expected: ALREADY_EXISTS is returned and no S3 upload is attempted —
// the guard fires before any file I/O, so no partial processing occurs.
Document existing = Document.builder()
.id(UUID.randomUUID())
.originalFilename("present.pdf")
.status(DocumentStatus.UPLOADED)
.build();
when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing));
Path physicalFile = tempDir.resolve("present.pdf");
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(physicalFile, pdfHeader);
Optional<MassImportService.SkipReason> result = service.importSingleDocument(
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentService, never()).save(any());
}
// ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────
@Test
void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
doThrow(new RuntimeException("S3 unavailable"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", MassImportService.SkipReason.S3_UPLOAD_FAILED));
}
@Test
void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
buildMinimalImportXlsx(tempDir, "existing.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
Document existing = Document.builder()
.id(UUID.randomUUID())
.originalFilename("existing.pdf")
.status(DocumentStatus.UPLOADED)
.build();
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.ALREADY_EXISTS);
} }
// ─── importSingleDocument — create new document (metadata only) ─────────── // ─── importSingleDocument — create new document (metadata only) ───────────
@@ -275,7 +208,7 @@ class MassImportServiceTest {
} }
@Test @Test
void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception { void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("fail.pdf"); Path tempFile = tempDir.resolve("fail.pdf");
Files.write(tempFile, "data".getBytes()); Files.write(tempFile, "data".getBytes());
@@ -283,11 +216,10 @@ class MassImportServiceTest {
doThrow(new RuntimeException("S3 error")) doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
Optional<MassImportService.SkipReason> result = service.importSingleDocument( service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail"); minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentService, never()).save(any()); verify(documentService, never()).save(any());
assertThat(result).isPresent().contains(MassImportService.SkipReason.S3_UPLOAD_FAILED);
} }
// ─── importSingleDocument — sender handling ─────────────────────────────── // ─── importSingleDocument — sender handling ───────────────────────────────
@@ -438,110 +370,6 @@ class MassImportServiceTest {
verify(documentService).findByOriginalFilename("doc002.pdf"); verify(documentService).findByOriginalFilename("doc002.pdf");
} }
// ─── isValidImportFilename — security regression — do not remove ─────────
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsNull() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", (String) null);
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsBlank() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", " ");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsForwardSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "etc/passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsBackslash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..\\etc\\passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsDotDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "doc..evil.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsDotDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsAbsolutePath() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "/etc/passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsNullByte() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "file\0.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameIsPlainBasename() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "document.pdf");
assertThat(result).isTrue();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeDivisionSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsFullwidthSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeReverseSolidus() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameHasLeadingDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", ".hidden.pdf");
assertThat(result).isTrue();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameHasSpaces() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "Brief an Oma.pdf");
assertThat(result).isTrue();
}
@Test
void processRows_skipsRowAndContinues_whenFilenameIsPathTraversal() {
when(documentService.findByOriginalFilename("legitimate.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of(
List.of("header"),
minimalCells("../evil"), // row 1: path traversal — should be skipped
minimalCells("legitimate.pdf") // row 2: valid — should be processed
);
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1);
assertThat(result.skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
}
// ─── importSingleDocument — non-blank optional fields ──────────────────── // ─── importSingleDocument — non-blank optional fields ────────────────────
@Test @Test
@@ -701,7 +529,14 @@ class MassImportServiceTest {
@Test @Test
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception { void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir); 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));
service.runImportAsync(); service.runImportAsync();
@@ -710,7 +545,14 @@ class MassImportServiceTest {
@Test @Test
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception { void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir); 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));
service.runImportAsync(); service.runImportAsync();
@@ -719,7 +561,14 @@ class MassImportServiceTest {
@Test @Test
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception { void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir); 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));
service.runImportAsync(); service.runImportAsync();
@@ -733,46 +582,12 @@ class MassImportServiceTest {
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
buildMinimalImportXlsx(tempDir, "tiny.pdf"); buildMinimalImportXlsx(tempDir, "tiny.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString()); ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
service.runImportAsync(); service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1); 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(MassImportService.SkipReason.FILE_READ_ERROR);
}
// ─── findFileRecursive — symlink escape security regression — do not remove ─
@Test
void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir(
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
Path outsideFile = outsideDir.resolve("secret.pdf");
Files.writeString(outsideFile, "sensitive content");
Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile);
ReflectionTestUtils.setField(service, "importDir", importDirPath.toString());
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "findFileRecursive", "secret.pdf"))
.isInstanceOf(DomainException.class);
}
// ─── readOds — XXE security regression ─────────────────────────────────── // ─── readOds — XXE security regression ───────────────────────────────────
// Security regression — do not remove. // Security regression — do not remove.
@@ -870,16 +685,6 @@ 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 { private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
Path xlsx = dir.resolve("import.xlsx"); Path xlsx = dir.resolve("import.xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook()) { try (XSSFWorkbook wb = new XSSFWorkbook()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.List;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -27,7 +28,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 +47,7 @@ class AdminControllerTest {
@WithMockUser(authorities = "ADMIN") @WithMockUser(authorities = "ADMIN")
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception { void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus( MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null); MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 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 +61,7 @@ class AdminControllerTest {
@WithMockUser(authorities = "ADMIN") @WithMockUser(authorities = "ADMIN")
void importStatus_messageField_notPresentInApiResponse() throws Exception { void importStatus_messageField_notPresentInApiResponse() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus( MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null); MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 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 +84,14 @@ class AdminControllerTest {
@Test @Test
void backfillVersions_returns401_whenUnauthenticated() throws Exception { void backfillVersions_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-versions").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 +101,7 @@ class AdminControllerTest {
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build())); when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1); when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
mockMvc.perform(post("/api/admin/backfill-versions").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 +110,14 @@ class AdminControllerTest {
@Test @Test
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception { void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes").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 +126,7 @@ class AdminControllerTest {
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception { void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
when(documentService.backfillFileHashes()).thenReturn(3); when(documentService.backfillFileHashes()).thenReturn(3);
mockMvc.perform(post("/api/admin/backfill-file-hashes").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 +135,14 @@ class AdminControllerTest {
@Test @Test
void generateThumbnails_returns401_whenUnauthenticated() throws Exception { void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/generate-thumbnails").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 +153,7 @@ class AdminControllerTest {
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now()); ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
when(thumbnailBackfillService.getStatus()).thenReturn(status); when(thumbnailBackfillService.getStatus()).thenReturn(status);
mockMvc.perform(post("/api/admin/generate-thumbnails").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));

View File

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

View File

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

View File

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

View File

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

View File

@@ -252,8 +252,6 @@ services:
OTEL_METRICS_EXPORTER: none OTEL_METRICS_EXPORTER: none
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1} MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
SENTRY_DSN: ${SENTRY_DSN:-}
LOGGING_STRUCTURED_FORMAT_CONSOLE: ecs
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:
@@ -268,10 +266,6 @@ services:
build: build:
context: ./frontend context: ./frontend
target: production target: production
args:
# Vite build-time variable — baked into the JS bundle at build time.
# Empty default so deploys succeed before the secret is configured.
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
backend: backend:
@@ -282,9 +276,6 @@ services:
# SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN} # SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN}
API_INTERNAL_URL: http://backend:8080 API_INTERNAL_URL: http://backend:8080
ORIGIN: https://${APP_DOMAIN} ORIGIN: https://${APP_DOMAIN}
# Enforce upload size limit in the adapter-node layer (fixes GHSA-2crg-3p73-43xp bypass).
# Must be ≤ client_max_body_size in the Caddy reverse proxy to avoid 413 mismatches.
BODY_SIZE_LIMIT: 50M
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:

View File

@@ -228,9 +228,6 @@ services:
API_INTERNAL_URL: http://backend:8080 API_INTERNAL_URL: http://backend:8080
# Vite dev proxy forwards /api from browser to the backend container # Vite dev proxy forwards /api from browser to the backend container
API_PROXY_TARGET: http://backend:8080 API_PROXY_TARGET: http://backend:8080
# Upload size limit for adapter-node (production target). Not enforced by Vite dev server
# but kept here to match docker-compose.prod.yml and prevent config drift.
BODY_SIZE_LIMIT: 50M
ports: ports:
- "${PORT_FRONTEND}:5173" - "${PORT_FRONTEND}:5173"
networks: networks:

View File

@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own | | `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic | | `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities | | `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. 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.
--- ---

View File

@@ -57,10 +57,6 @@ _See also [Annotation](#annotation-documentannotation)._
**Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently). **Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently).
**SkippedFile** (`MassImportService.SkippedFile`) — a file that was presented for import but not processed, recorded with a `filename` and a `reason` code. Possible reasons: `INVALID_PDF_SIGNATURE` (magic-byte validation failed), `S3_UPLOAD_FAILED` (file upload to MinIO/S3 threw an exception), `FILE_READ_ERROR` (the file could not be opened for reading), or `ALREADY_EXISTS` (a document with the same filename already exists in the archive with a status other than `PLACEHOLDER`).
**skipped count** — the total number of `SkippedFile` entries accumulated during a single import run (`ImportStatus.skipped()`). Shown in the amber warning section of the Import Status Card in the admin UI; a value of zero suppresses the section entirely.
**Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists. **Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists.
_See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._ _See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._

View File

@@ -1,115 +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.
- IP extraction uses `HttpServletRequest.getRemoteAddr()`. In deployments behind
a reverse proxy the `X-Forwarded-For` header is not trusted — doing so would
let clients spoof their IP and trivially bypass the per-IP limit. Trusting
proxy headers requires separate work (e.g. Spring's `ForwardedHeaderFilter`
with an allowlist of trusted proxy addresses).
- IPv6 and IPv4-mapped addresses (e.g. `::ffff:1.2.3.4`) are not normalised to
a canonical form. An attacker with access to multiple IPv6 addresses could
rotate addresses to bypass the per-IP bucket. This is a known limitation of
address-based rate limiting and is acceptable for the current deployment.

View File

@@ -1,110 +0,0 @@
# ADR-022 — EAGER→LAZY Fetch Strategy for Document Collections
**Date:** 2026-05-18
**Status:** Accepted
**Issue:** #467
**PR:** #622
---
## Context
A pre-production query audit of 24 HTTP requests to the document list and detail endpoints
produced **2,733 SQL statements** — primarily N+1 queries caused by `FetchType.EAGER` on
`Document.receivers`, `Document.tags`, `Document.trainingLabels`, and `Document.sender`.
With EAGER fetch, every `Document` loaded by any repository method immediately triggers
additional `SELECT` statements for each associated collection, regardless of whether the
caller needs those associations. For a list of 100 documents, this means up to 400 extra
queries for `receivers` alone.
---
## Decision
Switch all four associations to `FetchType.LAZY` and use a two-tier strategy to load exactly
what each code path needs:
**Tier 1 — Named entity graphs on `Document` + `@EntityGraph` overrides on `DocumentRepository`:**
- `Document.full` — loads `sender`, `receivers`, `tags` — used by `findById` (detail view)
- `Document.list` — loads `sender`, `tags` — used by `findAll(Spec, Pageable)`,
`findAll(Spec)`, and `findAll(Pageable)` (list/search/dashboard paths)
Each repository method that is called from a hot code path has an `@EntityGraph` override
that declares exactly which associations to JOIN-fetch, collapsing N+1 into 12 queries.
**Tier 2 — `@BatchSize(50)` fallback on all four associations:**
For any lazy access path not covered by an entity graph (e.g., a future ad-hoc query or an
in-memory sort that touches `trainingLabels`), Hibernate batches the secondary `SELECT` to
at most one statement per 50 entities instead of one per entity.
**Session lifetime for post-return lazy access:**
`getDocumentById` and `getRecentActivity` return entities to callers that may access lazy
associations after the repository call returns. Both methods are annotated
`@Transactional(readOnly = true)` to keep the Hibernate session open until the service method
returns, making those post-return accesses safe.
This is an intentional exception to the project convention that read methods are not annotated
(see `CLAUDE.md §Services`). The convention remains correct for all other read methods; this
exception applies only to methods that serve lazy-initialized associations to their callers.
---
## Alternatives Considered
### `@BatchSize`-only (no entity graphs)
`@BatchSize(50)` on all associations would eliminate the worst N+1 cases (100 documents → 2
batch queries instead of 100 individual queries) without requiring repository overrides. Simpler
to maintain — no named graph definitions, no per-method overrides.
Rejected because batch loading is best-effort: it depends on what Hibernate happens to find in
the first-level cache and produces a variable number of statements. Entity graphs produce a
deterministic, verifiable statement count that can be asserted in tests. The query-count test
suite (`DocumentRepositoryTest`) validates the exact statement bounds on every CI run.
### Single unified entity graph (`Document.full` everywhere)
Loading `receivers` on every list query is wasteful — the document list view only needs
`sender` and `tags`. `receivers` is a `@ManyToMany` collection that, when JOIN-fetched together
with `tags`, forces Hibernate to split into two queries anyway (to avoid Cartesian product).
Using a single graph on list paths would load data the UI does not display.
Rejected in favour of two graphs with distinct scopes: `Document.list` for list paths
(sender + tags), `Document.full` for detail paths (sender + receivers + tags).
### `@Transactional` on the Spring Data repository methods
Spring Data allows `@Transactional` on repository interfaces directly. This would keep the
session open for all calls to those methods without touching the service layer.
Rejected because the transaction boundary belongs at the service layer — repositories should
not own transaction lifecycle. The service methods are the natural scope for "keep the session
open long enough for the caller to use the result."
---
## Consequences
- **Query count reduced from ~2,733 to ≤10 statements per 24 HTTP requests** — verified by
`DocumentRepositoryTest` query-count assertions and `DocumentLazyLoadingTest` smoke tests.
- **Read methods that return lazily-initialized entities must carry `@Transactional(readOnly = true)`.**
Any future service method that loads a `Document` and returns it to a caller that accesses
lazy associations must follow this pattern. Removing the annotation causes
`LazyInitializationException` in production.
- **New lazy code paths need an entity graph or `@BatchSize` review.** Any new
`DocumentRepository` method added to a hot code path should be assessed for N+1 risk and
given an `@EntityGraph` override if warranted.
- **`@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` required on serialized lazy-proxy entities.**
`Person` and `Tag` carry this annotation to prevent Jackson from attempting to serialize
Hibernate proxy internals when the association is not initialized. Any new entity that is
used as a lazy association and serialized directly (without a DTO) needs the same annotation.
- **Named graph strings in `Document.java` and `DocumentRepository.java` must stay in sync.**
The `@NamedEntityGraph(name = "Document.full")` / `@NamedEntityGraph(name = "Document.list")`
definitions on `Document` are referenced by string in every `@EntityGraph(value = "...")` on
`DocumentRepository`. If the names diverge (e.g. a graph is renamed in one place but not the
other), Spring Data throws at application startup. Always update both files together when
renaming or restructuring a named graph.

View File

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

View File

@@ -1,9 +1,9 @@
@startuml @startuml
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy) title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
note over Browser, DB note over Browser, DB
Phase 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>

View File

@@ -16,10 +16,6 @@ CMD ["npm", "run", "dev"]
# Compiles the SvelteKit Node-adapter output to /app/build. # Compiles the SvelteKit Node-adapter output to /app/build.
FROM node:20.19.0-alpine3.21 AS build FROM node:20.19.0-alpine3.21 AS build
WORKDIR /app WORKDIR /app
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
# Passed via docker-compose build.args; empty string disables the SDK.
ARG VITE_SENTRY_DSN
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .

View File

@@ -58,20 +58,3 @@ test.describe('Language selector', () => {
await expect(deBtn).toHaveClass(/font-bold/); await expect(deBtn).toHaveClass(/font-bold/);
}); });
}); });
test.describe('Mobile nav — i18n', () => {
test('hamburger button aria-label translates to EN on narrow viewport', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 375, height: 812 },
storageState: 'e2e/.auth/user.json'
});
const page = await context.newPage();
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
await expect(page.getByRole('button', { name: 'Open menu' })).toBeVisible();
await context.close();
});
});

View File

@@ -106,31 +106,6 @@ export default defineConfig(
] ]
} }
}, },
{
// Forbid test fixtures (*.test-fixture.svelte) from being imported by
// production code. Tree-shaking keeps them out of the production bundle
// today (no route reaches them), but a lint rule makes the boundary
// explicit so an accidental autocomplete import in a route or component
// fails fast. Test files (*.spec.ts / *.test.ts) and the fixtures
// themselves are exempt — see the next block. Nora #2 on PR #629
// round 3.
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js', '**/*.ts'],
ignores: ['**/*.spec.ts', '**/*.test.ts', '**/*.test-fixture.svelte'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/*.test-fixture.svelte'],
message:
'Test fixtures (*.test-fixture.svelte) are test-only — do not import from production code. Tracked by #637.'
}
]
}
]
}
},
{ {
plugins: { boundaries }, plugins: { boundaries },
settings: { settings: {

View File

@@ -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",
@@ -28,8 +26,6 @@
"nav_conversations": "Briefwechsel", "nav_conversations": "Briefwechsel",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Abmelden", "nav_logout": "Abmelden",
"layout_menu_open": "Menü öffnen",
"layout_menu_close": "Menü schließen",
"theme_toggle_to_light": "Zu hellem Design wechseln", "theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln", "theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern", "btn_save": "Speichern",
@@ -355,10 +351,6 @@
"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", "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",
"import_reason_s3_upload_failed": "Upload-Fehler (S3)",
"import_reason_already_exists": "Bereits importiert",
"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.",
@@ -396,10 +388,6 @@
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}", "doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen", "pdf_annotations_show": "Annotierungen anzeigen",
"pdf_annotations_hide": "Annotierungen verbergen", "pdf_annotations_hide": "Annotierungen verbergen",
"viewer_previous_page": "Zurück",
"viewer_next_page": "Weiter",
"viewer_zoom_out": "Verkleinern",
"viewer_zoom_in": "Vergrößern",
"upload_action": "Hochladen", "upload_action": "Hochladen",
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen", "upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_accepted_types": "PDF, JPEG, PNG, TIFF",
@@ -445,12 +433,8 @@
"person_mention_load_error": "Person konnte nicht geladen werden.", "person_mention_load_error": "Person konnte nicht geladen werden.",
"person_mention_loading": "Lade Person…", "person_mention_loading": "Lade Person…",
"person_mention_popup_empty": "Keine Personen gefunden", "person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_search_label": "Person suchen",
"person_mention_search_prompt": "Namen eingeben…",
"person_mention_btn_label": "Person verlinken", "person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen", "person_mention_create_new": "Neue Person anlegen",
"person_mention_results_count_singular": "1 Person gefunden",
"person_mention_results_count_plural": "{count} Personen gefunden",
"transcription_editor_aria_label": "Transkriptionstext", "transcription_editor_aria_label": "Transkriptionstext",
"person_born_name_prefix": "geb.", "person_born_name_prefix": "geb.",
"page_title_home": "Archiv", "page_title_home": "Archiv",
@@ -526,7 +510,6 @@
"notification_filter_unread": "Ungelesen", "notification_filter_unread": "Ungelesen",
"notification_filter_mention": "Erwähnung", "notification_filter_mention": "Erwähnung",
"notification_filter_reply": "Antwort", "notification_filter_reply": "Antwort",
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
"notification_load_more": "Ältere laden", "notification_load_more": "Ältere laden",
"notification_empty_history": "Keine Benachrichtigungen", "notification_empty_history": "Keine Benachrichtigungen",
@@ -638,9 +621,6 @@
"transcription_block_review": "Als geprüft markieren", "transcription_block_review": "Als geprüft markieren",
"transcription_block_unreview": "Markierung aufheben", "transcription_block_unreview": "Markierung aufheben",
"transcription_reviewed_count": "{reviewed} von {total} geprüft", "transcription_reviewed_count": "{reviewed} von {total} geprüft",
"transcription_mark_all_reviewed": "Alle als fertig markieren",
"transcription_mark_all_reviewed_disabled": "Alle Blöcke sind bereits als fertig markiert",
"transcription_mark_all_reviewed_error": "Markierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"training_ocr_heading": "Kurrent-Erkennung trainieren", "training_ocr_heading": "Kurrent-Erkennung trainieren",
"training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.", "training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.",
"training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente", "training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente",
@@ -669,7 +649,6 @@
"transcription_block_segmentation_only": "Nur Segmentierung", "transcription_block_segmentation_only": "Nur Segmentierung",
"training_chip_kurrent": "Kurrent-Erkennung", "training_chip_kurrent": "Kurrent-Erkennung",
"training_chip_segmentation": "Segmentierung", "training_chip_segmentation": "Segmentierung",
"transcribe_mark_for_training": "Für Training vormerken",
"training_col_type": "Typ", "training_col_type": "Typ",
"training_type_base": "Basis", "training_type_base": "Basis",
"training_type_personalized": "Personalisiert", "training_type_personalized": "Personalisiert",

View File

@@ -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",
@@ -28,8 +26,6 @@
"nav_conversations": "Letters", "nav_conversations": "Letters",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Sign out", "nav_logout": "Sign out",
"layout_menu_open": "Open menu",
"layout_menu_close": "Close menu",
"theme_toggle_to_light": "Switch to light mode", "theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode", "theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save", "btn_save": "Save",
@@ -355,10 +351,6 @@
"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", "admin_system_import_skipped_label": "skipped",
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
"import_reason_file_read_error": "File read error",
"import_reason_s3_upload_failed": "Upload error (S3)",
"import_reason_already_exists": "Already imported",
"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.",
@@ -396,10 +388,6 @@
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}", "doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations", "pdf_annotations_show": "Show annotations",
"pdf_annotations_hide": "Hide annotations", "pdf_annotations_hide": "Hide annotations",
"viewer_previous_page": "Previous page",
"viewer_next_page": "Next page",
"viewer_zoom_out": "Zoom out",
"viewer_zoom_in": "Zoom in",
"upload_action": "Upload", "upload_action": "Upload",
"upload_drop_hint": "Drop one or multiple files at once", "upload_drop_hint": "Drop one or multiple files at once",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_accepted_types": "PDF, JPEG, PNG, TIFF",
@@ -445,12 +433,8 @@
"person_mention_load_error": "Could not load person.", "person_mention_load_error": "Could not load person.",
"person_mention_loading": "Loading person…", "person_mention_loading": "Loading person…",
"person_mention_popup_empty": "No persons found", "person_mention_popup_empty": "No persons found",
"person_mention_search_label": "Search for a person",
"person_mention_search_prompt": "Enter a name…",
"person_mention_btn_label": "Link person", "person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person", "person_mention_create_new": "Create new person",
"person_mention_results_count_singular": "1 person found",
"person_mention_results_count_plural": "{count} persons found",
"transcription_editor_aria_label": "Transcription text", "transcription_editor_aria_label": "Transcription text",
"person_born_name_prefix": "née", "person_born_name_prefix": "née",
"page_title_home": "Archive", "page_title_home": "Archive",
@@ -526,7 +510,6 @@
"notification_filter_unread": "Unread", "notification_filter_unread": "Unread",
"notification_filter_mention": "Mention", "notification_filter_mention": "Mention",
"notification_filter_reply": "Reply", "notification_filter_reply": "Reply",
"notification_error_generic": "Action failed. Please try again.",
"notification_mark_all_read_aria": "Mark all notifications as read", "notification_mark_all_read_aria": "Mark all notifications as read",
"notification_load_more": "Load older", "notification_load_more": "Load older",
"notification_empty_history": "No notifications", "notification_empty_history": "No notifications",
@@ -638,9 +621,6 @@
"transcription_block_review": "Mark as reviewed", "transcription_block_review": "Mark as reviewed",
"transcription_block_unreview": "Unmark as reviewed", "transcription_block_unreview": "Unmark as reviewed",
"transcription_reviewed_count": "{reviewed} of {total} reviewed", "transcription_reviewed_count": "{reviewed} of {total} reviewed",
"transcription_mark_all_reviewed": "Mark all as reviewed",
"transcription_mark_all_reviewed_disabled": "All blocks are already marked as reviewed",
"transcription_mark_all_reviewed_error": "Failed to mark all as reviewed. Please try again.",
"training_ocr_heading": "Train Kurrent recognition", "training_ocr_heading": "Train Kurrent recognition",
"training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.", "training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.",
"training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents", "training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents",
@@ -669,7 +649,6 @@
"transcription_block_segmentation_only": "Segmentation only", "transcription_block_segmentation_only": "Segmentation only",
"training_chip_kurrent": "Kurrent recognition", "training_chip_kurrent": "Kurrent recognition",
"training_chip_segmentation": "Segmentation", "training_chip_segmentation": "Segmentation",
"transcribe_mark_for_training": "Mark for OCR training",
"training_col_type": "Type", "training_col_type": "Type",
"training_type_base": "Base", "training_type_base": "Base",
"training_type_personalized": "Personalized", "training_type_personalized": "Personalized",

View File

@@ -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",
@@ -28,8 +26,6 @@
"nav_conversations": "Cartas", "nav_conversations": "Cartas",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Cerrar sesión", "nav_logout": "Cerrar sesión",
"layout_menu_open": "Abrir menú",
"layout_menu_close": "Cerrar menú",
"theme_toggle_to_light": "Cambiar a modo claro", "theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro", "theme_toggle_to_dark": "Cambiar a modo oscuro",
"btn_save": "Guardar", "btn_save": "Guardar",
@@ -355,10 +351,6 @@
"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", "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",
"import_reason_s3_upload_failed": "Error de carga (S3)",
"import_reason_already_exists": "Ya importado",
"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.",
@@ -396,10 +388,6 @@
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}", "doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones", "pdf_annotations_show": "Mostrar anotaciones",
"pdf_annotations_hide": "Ocultar anotaciones", "pdf_annotations_hide": "Ocultar anotaciones",
"viewer_previous_page": "Página anterior",
"viewer_next_page": "Página siguiente",
"viewer_zoom_out": "Reducir",
"viewer_zoom_in": "Ampliar",
"upload_action": "Subir", "upload_action": "Subir",
"upload_drop_hint": "Uno o varios archivos a la vez", "upload_drop_hint": "Uno o varios archivos a la vez",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_accepted_types": "PDF, JPEG, PNG, TIFF",
@@ -445,12 +433,8 @@
"person_mention_load_error": "No se pudo cargar la persona.", "person_mention_load_error": "No se pudo cargar la persona.",
"person_mention_loading": "Cargando persona…", "person_mention_loading": "Cargando persona…",
"person_mention_popup_empty": "No se encontraron personas", "person_mention_popup_empty": "No se encontraron personas",
"person_mention_search_label": "Buscar persona",
"person_mention_search_prompt": "Escribe un nombre…",
"person_mention_btn_label": "Vincular persona", "person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona", "person_mention_create_new": "Crear nueva persona",
"person_mention_results_count_singular": "1 persona encontrada",
"person_mention_results_count_plural": "{count} personas encontradas",
"transcription_editor_aria_label": "Texto de transcripción", "transcription_editor_aria_label": "Texto de transcripción",
"person_born_name_prefix": "n.", "person_born_name_prefix": "n.",
"page_title_home": "Archivo", "page_title_home": "Archivo",
@@ -526,7 +510,6 @@
"notification_filter_unread": "No leídas", "notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención", "notification_filter_mention": "Mención",
"notification_filter_reply": "Respuesta", "notification_filter_reply": "Respuesta",
"notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.",
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
"notification_load_more": "Cargar anteriores", "notification_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones", "notification_empty_history": "Sin notificaciones",
@@ -638,9 +621,6 @@
"transcription_block_review": "Marcar como revisado", "transcription_block_review": "Marcar como revisado",
"transcription_block_unreview": "Desmarcar como revisado", "transcription_block_unreview": "Desmarcar como revisado",
"transcription_reviewed_count": "{reviewed} de {total} revisados", "transcription_reviewed_count": "{reviewed} de {total} revisados",
"transcription_mark_all_reviewed": "Marcar todo como revisado",
"transcription_mark_all_reviewed_disabled": "Todos los bloques ya están marcados como revisados",
"transcription_mark_all_reviewed_error": "Error al marcar como revisado. Intente de nuevo.",
"training_ocr_heading": "Entrenar reconocimiento Kurrent", "training_ocr_heading": "Entrenar reconocimiento Kurrent",
"training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.", "training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.",
"training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos", "training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos",
@@ -669,7 +649,6 @@
"transcription_block_segmentation_only": "Solo segmentación", "transcription_block_segmentation_only": "Solo segmentación",
"training_chip_kurrent": "Reconocimiento Kurrent", "training_chip_kurrent": "Reconocimiento Kurrent",
"training_chip_segmentation": "Segmentación", "training_chip_segmentation": "Segmentación",
"transcribe_mark_for_training": "Marcar para entrenamiento de OCR",
"training_col_type": "Tipo", "training_col_type": "Tipo",
"training_type_base": "Base", "training_type_base": "Base",
"training_type_personalized": "Personalizado", "training_type_personalized": "Personalizado",

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/", "lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run", "test": "npm run test:unit -- --run",
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage", "test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed", "test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
@@ -24,9 +24,9 @@
}, },
"dependencies": { "dependencies": {
"@sentry/sveltekit": "^10.53.1", "@sentry/sveltekit": "^10.53.1",
"@tiptap/core": "3.23.4", "@tiptap/core": "3.22.5",
"@tiptap/extension-mention": "3.23.4", "@tiptap/extension-mention": "3.22.5",
"@tiptap/starter-kit": "3.23.4", "@tiptap/starter-kit": "3.22.5",
"diff": "^8.0.3", "diff": "^8.0.3",
"isomorphic-dompurify": "^3.12.0", "isomorphic-dompurify": "^3.12.0",
"openapi-fetch": "^0.13.5", "openapi-fetch": "^0.13.5",
@@ -37,9 +37,9 @@
"@eslint/compat": "^1.4.0", "@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0", "@inlang/paraglide-js": "^2.5.0",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.60.1", "@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -57,7 +57,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"openapi-typescript": "^7.8.0", "openapi-typescript": "^7.8.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"playwright": "^1.60.0", "playwright": "^1.56.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
@@ -66,7 +66,7 @@
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^7.3.3", "vite": "^7.2.2",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.0.10", "vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"

View File

@@ -1,30 +1,30 @@
diff --git a/node_modules/@vitest/browser-playwright/dist/index.js b/node_modules/@vitest/browser-playwright/dist/index.js diff --git a/node_modules/@vitest/browser-playwright/dist/index.js b/node_modules/@vitest/browser-playwright/dist/index.js
index c01e754..f1bb7be 100644 index 5d0d37b..821d7b4 100644
--- a/node_modules/@vitest/browser-playwright/dist/index.js --- a/node_modules/@vitest/browser-playwright/dist/index.js
+++ b/node_modules/@vitest/browser-playwright/dist/index.js +++ b/node_modules/@vitest/browser-playwright/dist/index.js
@@ -936,7 +936,7 @@ class PlaywrightBrowserProvider { @@ -935,7 +935,7 @@ class PlaywrightBrowserProvider {
createMocker() { createMocker() {
const idPredicates = new Map(); const idPreficates = new Map();
const sessionIds = new Map(); const sessionIds = new Map();
- function createPredicate(sessionId, url) { - function createPredicate(sessionId, url) {
+ function createPredicate(url) { + function createPredicate(url) {
const moduleUrl = new URL(url, "http://localhost"); const moduleUrl = new URL(url, "http://localhost");
const predicate = (url) => { const predicate = (url) => {
if (url.searchParams.has("_vitest_original")) { if (url.searchParams.has("_vitest_original")) {
@@ -961,11 +961,7 @@ class PlaywrightBrowserProvider { @@ -960,11 +960,7 @@ class PlaywrightBrowserProvider {
} }
return true; return true;
}; };
- const ids = sessionIds.get(sessionId) || []; - const ids = sessionIds.get(sessionId) || [];
- ids.push(moduleUrl.href); - ids.push(moduleUrl.href);
- sessionIds.set(sessionId, ids); - sessionIds.set(sessionId, ids);
- idPredicates.set(predicateKey(sessionId, moduleUrl.href), predicate); - idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate);
- return predicate; - return predicate;
+ return { url: moduleUrl.href, predicate }; + return { url: moduleUrl.href, predicate };
} }
function predicateKey(sessionId, url) { function predicateKey(sessionId, url) {
return `${sessionId}:${url}`; return `${sessionId}:${url}`;
@@ -973,7 +969,23 @@ class PlaywrightBrowserProvider { @@ -972,7 +968,23 @@ class PlaywrightBrowserProvider {
return { return {
register: async (sessionId, module) => { register: async (sessionId, module) => {
const page = this.getPage(sessionId); const page = this.getPage(sessionId);
@@ -37,19 +37,19 @@ index c01e754..f1bb7be 100644
+ // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js') + // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js')
+ // leak an orphan route whose handler crashes after the next + // leak an orphan route whose handler crashes after the next
+ // session's birpc channel closes. + // session's birpc channel closes.
+ const existingPredicate = idPredicates.get(key); + const existingPredicate = idPreficates.get(key);
+ if (existingPredicate) { + if (existingPredicate) {
+ await page.context().unroute(existingPredicate); + await page.context().unroute(existingPredicate);
+ } + }
+ const ids = sessionIds.get(sessionId) ?? new Set(); + const ids = sessionIds.get(sessionId) ?? new Set();
+ ids.add(moduleUrl); + ids.add(moduleUrl);
+ sessionIds.set(sessionId, ids); + sessionIds.set(sessionId, ids);
+ idPredicates.set(key, predicate); + idPreficates.set(key, predicate);
+ await page.context().route(predicate, async (route) => { + await page.context().route(predicate, async (route) => {
if (module.type === "manual") { if (module.type === "manual") {
const exports$1 = Object.keys(await module.resolve()); const exports$1 = Object.keys(await module.resolve());
const body = createManualModuleSource(module.url, exports$1); const body = createManualModuleSource(module.url, exports$1);
@@ -1034,8 +1046,8 @@ class PlaywrightBrowserProvider { @@ -1033,8 +1045,8 @@ class PlaywrightBrowserProvider {
}, },
clear: async (sessionId) => { clear: async (sessionId) => {
const page = this.getPage(sessionId); const page = this.getPage(sessionId);
@@ -58,5 +58,5 @@ index c01e754..f1bb7be 100644
+ const ids = sessionIds.get(sessionId) ?? new Set(); + const ids = sessionIds.get(sessionId) ?? new Set();
+ const promises = [...ids].map((id) => { + const promises = [...ids].map((id) => {
const key = predicateKey(sessionId, id); const key = predicateKey(sessionId, id);
const predicate = idPredicates.get(key); const predicate = idPreficates.get(key);
if (predicate) { if (predicate) {

View File

@@ -96,57 +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) || new URL(request.url).pathname.startsWith('/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) {
return fetch(request);
}
// Clone first so the body stream is preserved on the new Request.
const cloned = request.clone();
const extraHeaders: Record<string, string> = { 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);

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
@@ -7,13 +6,11 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
interface Props { interface Props {
unread: NotificationItem[]; unread: NotificationItem[];
optimisticMarkRead: (id: string) => void; onMarkRead: (n: NotificationItem) => void;
optimisticMarkAllRead: () => void; onMarkAllRead: () => void;
} }
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props(); const { unread, onMarkRead, onMarkAllRead }: Props = $props();
let errorMessage: string | null = $state(null);
function verb(type: NotificationItem['type'], actor: string): string { function verb(type: NotificationItem['type'], actor: string): string {
return type === 'REPLY' return type === 'REPLY'
@@ -27,9 +24,6 @@ function href(n: NotificationItem): string {
</script> </script>
<section class="rounded-sm border border-line bg-surface p-5"> <section class="rounded-sm border border-line bg-surface p-5">
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
{#if unread.length === 0} {#if unread.length === 0}
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center"> <div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
<svg <svg
@@ -72,28 +66,14 @@ function href(n: NotificationItem): string {
{m.chronik_for_you_count({ count: unread.length })} {m.chronik_for_you_count({ count: unread.length })}
</span> </span>
</div> </div>
<form <button
action="/aktivitaeten?/mark-all-read" type="button"
method="POST" data-testid="chronik-mark-all-read"
use:enhance={() => { onclick={onMarkAllRead}
errorMessage = null; class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<button {m.chronik_mark_all_read()}
type="submit" </button>
data-testid="chronik-mark-all-read"
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.chronik_mark_all_read()}
</button>
</form>
</div> </div>
<ul role="list" class="flex flex-col gap-2"> <ul role="list" class="flex flex-col gap-2">
@@ -109,7 +89,7 @@ function href(n: NotificationItem): string {
aria-hidden="true" aria-hidden="true"
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent" class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
> >
{n.type === 'MENTION' ? '@' : ''} {n.type === 'MENTION' ? '@' : '\u21A9'}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink"> <p class="font-sans text-sm leading-snug text-ink">
@@ -120,40 +100,25 @@ function href(n: NotificationItem): string {
</p> </p>
</div> </div>
</a> </a>
<form <button
action="/aktivitaeten?/dismiss-notification" type="button"
method="POST" data-testid="chronik-fuerdich-dismiss"
use:enhance={() => { aria-label={m.chronik_mark_read_aria()}
errorMessage = null; onclick={() => onMarkRead(n)}
optimisticMarkRead(n.id); class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<input type="hidden" name="notificationId" value={n.id} /> <svg
<button xmlns="http://www.w3.org/2000/svg"
type="submit" class="h-4 w-4"
data-testid="chronik-fuerdich-dismiss" fill="none"
aria-label={m.chronik_mark_read_aria()} viewBox="0 0 24 24"
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none" stroke="currentColor"
stroke-width="2"
aria-hidden="true"
> >
<svg <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
xmlns="http://www.w3.org/2000/svg" </svg>
class="h-4 w-4" </button>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
function notif(partial: Partial<NotificationItem>): NotificationItem { function notif(partial: Partial<NotificationItem>): NotificationItem {
return { return {
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => { it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull(); expect(zero).not.toBeNull();
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
it('links to the archived mentions in the inbox-zero state', async () => { it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
expect(link).not.toBeNull(); expect(link).not.toBeNull();
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
it('renders the count badge with correct total when unread exists', async () => { it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })], unread: [notif({ id: 'a' }), notif({ id: 'b' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('2 neu')).toBeInTheDocument(); await expect.element(page.getByText('2 neu')).toBeInTheDocument();
}); });
@@ -85,8 +56,8 @@ describe('ChronikFuerDichBox', () => {
it('count badge has aria-live=polite when unread exists', async () => { it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
// Wait for render // Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument(); await expect.element(page.getByText('1 neu')).toBeInTheDocument();
@@ -98,8 +69,8 @@ describe('ChronikFuerDichBox', () => {
it('does not render the "Alle gelesen" button when there are no unread items', async () => { it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
@@ -109,38 +80,38 @@ describe('ChronikFuerDichBox', () => {
it('renders the "Alle gelesen" button when unread exists', async () => { it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
}); });
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => { it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead onMarkAllRead
}); });
await userEvent.click(page.getByText('Alle gelesen')); await userEvent.click(page.getByText('Alle gelesen'));
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1); expect(onMarkAllRead).toHaveBeenCalledTimes(1);
}); });
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => { it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const n = notif({ id: 'xyz' }); const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [n], unread: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]' '[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
dismiss?.click(); dismiss?.click();
expect(optimisticMarkRead).toHaveBeenCalledTimes(1); expect(onMarkRead).toHaveBeenCalledTimes(1);
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz'); expect(onMarkRead.mock.calls[0][0]).toEqual(n);
}); });
it('mention row href includes both commentId and annotationId when annotationId is present', async () => { it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
annotationId: 'annot-9' annotationId: 'annot-9'
}) })
], ],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector( const link = document.querySelector(
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' 'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
@@ -165,8 +136,8 @@ describe('ChronikFuerDichBox', () => {
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => { it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })], unread: [notif({ id: 'x' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]'); const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
// Prevents the senior-audience tap-drag bug flagged by Leonie. // Prevents the senior-audience tap-drag bug flagged by Leonie.
expect(dismiss?.closest('a')).toBeNull(); expect(dismiss?.closest('a')).toBeNull();
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
unread: [notif({ id: 'err-1' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -4,36 +4,7 @@ import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n-1', id: 'n-1',
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
describe('ChronikFuerDichBox', () => { describe('ChronikFuerDichBox', () => {
it('renders the inbox-zero state when there are no unread', async () => { it('renders the inbox-zero state when there are no unread', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} } props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
}); });
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ actorName: 'Bertha' })], unread: [mention({ actorName: 'Bertha' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ type: 'REPLY', actorName: 'Carl' })], unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
.toBeVisible(); .toBeVisible();
}); });
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => { it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const item = mention({ id: 'n-7' }); const item = mention({ id: 'n-7' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} } props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
) as HTMLElement; ) as HTMLElement;
dismiss.click(); dismiss.click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7'); expect(onMarkRead).toHaveBeenCalledWith(item);
}); });
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => { it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention()], unread: [mention()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead onMarkAllRead
} }
}); });
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
btn.click(); btn.click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).toHaveBeenCalledOnce();
}); });
it('builds a deep-link href to the comment for each notification', async () => { it('builds a deep-link href to the comment for each notification', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toContain('doc-x'); expect(link.getAttribute('href')).toContain('doc-x');
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'err-1' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
}
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLElement;
dismiss.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -17,7 +17,6 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte';
import { bulkTitleFromFilename } from '$lib/document/filename'; import { bulkTitleFromFilename } from '$lib/document/filename';
import type { Tag } from '$lib/tag/TagInput.svelte'; import type { Tag } from '$lib/tag/TagInput.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { withCsrf } from '$lib/shared/cookies';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
@@ -184,10 +183,7 @@ async function saveUpload() {
// FormData with per-chunk progress. Session cookie is sent automatically // FormData with per-chunk progress. Session cookie is sent automatically
// by the browser for same-origin requests. // by the browser for same-origin requests.
try { try {
const res = await fetch( const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
'/api/documents/quick-upload',
withCsrf({ method: 'POST', body: formData })
);
const body = await res.json().catch(() => ({ errors: [] })); const body = await res.json().catch(() => ({ errors: [] }));
const errorFilenames = new Set<string>( const errorFilenames = new Set<string>(
(body.errors ?? []).map((err: { filename: string }) => err.filename) (body.errors ?? []).map((err: { filename: string }) => err.filename)

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import TranscriptionBlockHost from './TranscriptionBlock.test-fixture.svelte'; import TranscriptionBlockHost from './TranscriptionBlock.test-host.svelte';
import type { ConfirmService } from '$lib/shared/services/confirm.svelte.js'; import type { ConfirmService } from '$lib/shared/services/confirm.svelte.js';
afterEach(cleanup); afterEach(cleanup);

View File

@@ -6,7 +6,6 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
import { withCsrf } from '$lib/shared/cookies';
type Props = { type Props = {
documentId: string; documentId: string;
@@ -50,7 +49,6 @@ let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]); let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null); let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false); let markingAllReviewed = $state(false);
let markAllError = $state<string | null>(null);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0); const hasBlocks = $derived(blocks.length > 0);
@@ -69,11 +67,8 @@ $effect(() => {
async function handleMarkAllReviewed() { async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return; if (!onMarkAllReviewed) return;
markingAllReviewed = true; markingAllReviewed = true;
markAllError = null;
try { try {
await onMarkAllReviewed(); await onMarkAllReviewed();
} catch {
markAllError = m.transcription_mark_all_reviewed_error();
} finally { } finally {
markingAllReviewed = false; markingAllReviewed = false;
} }
@@ -114,14 +109,11 @@ function handleDelete(blockId: string) {
async function reorder(newOrder: string[]) { async function reorder(newOrder: string[]) {
try { try {
const res = await fetch( const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
`/api/documents/${documentId}/transcription-blocks/reorder`, method: 'PUT',
withCsrf({ headers: { 'Content-Type': 'application/json' },
method: 'PUT', body: JSON.stringify({ blockIds: newOrder })
headers: { 'Content-Type': 'application/json' }, });
body: JSON.stringify({ blockIds: newOrder })
})
);
if (!res.ok) return; if (!res.ok) return;
const updated = await res.json(); const updated = await res.json();
for (const b of updated) { for (const b of updated) {
@@ -177,7 +169,7 @@ async function handleLabelToggle(label: string) {
<button <button
onclick={handleMarkAllReviewed} onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed} disabled={allReviewed || markingAllReviewed}
title={allReviewed ? m.transcription_mark_all_reviewed_disabled() : undefined} title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40" class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
> >
{#if markingAllReviewed} {#if markingAllReviewed}
@@ -215,7 +207,7 @@ async function handleLabelToggle(label: string) {
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
{/if} {/if}
{m.transcription_mark_all_reviewed()} Alle als fertig markieren
</button> </button>
{/if} {/if}
</div> </div>
@@ -225,31 +217,6 @@ async function handleLabelToggle(label: string) {
style="width: {reviewProgress}%" style="width: {reviewProgress}%"
></div> ></div>
</div> </div>
{#if markAllError}
<div
role="alert"
class="mt-1.5 flex items-center gap-2 rounded-sm border border-red-200 bg-red-50 px-3 py-2 font-sans text-sm text-red-700"
>
<span class="flex-1">{markAllError}</span>
<button
onclick={() => (markAllError = null)}
aria-label={m.comp_dismiss()}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-red-600 hover:text-red-700 focus-visible:ring-2 focus-visible:ring-red-500"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div> </div>
<div class="p-4"> <div class="p-4">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -336,9 +303,7 @@ async function handleLabelToggle(label: string) {
{#if canWrite && hasBlocks} {#if canWrite && hasBlocks}
<div class="border-t border-line px-4 py-3"> <div class="border-t border-line px-4 py-3">
<p class="mb-2 font-sans text-xs font-medium text-ink-2"> <p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
{m.transcribe_mark_for_training()}
</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each [{ label: 'KURRENT_RECOGNITION', display: m.training_chip_kurrent() }, { label: 'KURRENT_SEGMENTATION', display: m.training_chip_segmentation() }] as chip (chip.label)} {#each [{ label: 'KURRENT_RECOGNITION', display: m.training_chip_kurrent() }, { label: 'KURRENT_SEGMENTATION', display: m.training_chip_segmentation() }] as chip (chip.label)}
<button <button

View File

@@ -3,7 +3,6 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte'; import TranscriptionEditView from './TranscriptionEditView.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import { m } from '$lib/paraglide/messages.js';
afterEach(cleanup); afterEach(cleanup);
@@ -313,14 +312,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
}); });
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => { it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] }); renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
}); });
@@ -330,7 +329,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
}); });
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled(); .toBeDisabled();
}); });
@@ -344,7 +343,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick // userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
// handlers when a TipTap editor is mounted in the same component tree. // handlers when a TipTap editor is mounted in the same component tree.
const btn = (await page const btn = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() }) .getByRole('button', { name: /Alle als fertig markieren/ })
.element()) as HTMLButtonElement; .element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
@@ -362,83 +361,12 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick // Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
const btnEl = (await page const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() }) .getByRole('button', { name: /Alle als fertig markieren/ })
.element()) as HTMLButtonElement; .element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled(); .toBeDisabled();
resolveMarkAll(); resolveMarkAll();
}); });
it('shows error message when onMarkAllReviewed callback rejects', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.transcription_mark_all_reviewed_error());
});
it('clears error when dismiss button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const dismissEl = (await page
.getByRole('button', { name: m.comp_dismiss() })
.element()) as HTMLButtonElement;
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('clears error on next successful markAllReviewed call', async () => {
const onMarkAllReviewed = vi
.fn()
.mockRejectedValueOnce(new Error('INTERNAL_ERROR'))
.mockResolvedValue(undefined);
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
// Wait for the button to be re-enabled before the second click — ensures the first
// async rejection has fully settled and Svelte has flushed state changes
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('re-enables button after markAllReviewed failure', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
});
}); });

View File

@@ -1,6 +1,5 @@
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/shared/types'; import type { PersonMention } from '$lib/shared/types';
import { withCsrf } from '$lib/shared/cookies';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
@@ -117,15 +116,12 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
for (const [blockId, text] of pendingTexts) { for (const [blockId, text] of pendingTexts) {
const mentions = pendingMentions.get(blockId) ?? []; const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId); clearDebounce(blockId);
void fetch( void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
`/api/documents/${documentId}/transcription-blocks/${blockId}`, method: 'PUT',
withCsrf({ headers: { 'Content-Type': 'application/json' },
method: 'PUT', body: JSON.stringify({ text, mentionedPersons: mentions }),
headers: { 'Content-Type': 'application/json' }, keepalive: true
body: JSON.stringify({ text, mentionedPersons: mentions }), });
keepalive: true
})
);
pendingTexts.delete(blockId); pendingTexts.delete(blockId);
pendingMentions.delete(blockId); pendingMentions.delete(blockId);
} }

View File

@@ -259,15 +259,12 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true); expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true);
}); });
it('throws and leaves blocks unchanged when PUT returns non-OK', async () => { it('is a no-op when PUT returns non-OK', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString(); const u = url.toString();
const method = init?.method ?? 'GET'; const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') { if (u.includes('/review-all') && method === 'PUT') {
return new Response(JSON.stringify({ code: 'INTERNAL_ERROR' }), { return new Response('', { status: 500 });
status: 500,
headers: { 'Content-Type': 'application/json' }
});
} }
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200, status: 200,
@@ -277,26 +274,7 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load(); await ctrl.load();
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR'); await ctrl.markAllReviewed();
expect(ctrl.blocks[0].reviewed).toBe(false);
});
it('throws INTERNAL_ERROR when PUT returns non-JSON body (e.g. nginx 502)', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') {
return new Response('Bad Gateway', { status: 502 });
}
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
expect(ctrl.blocks[0].reviewed).toBe(false); expect(ctrl.blocks[0].reviewed).toBe(false);
}); });
}); });

View File

@@ -2,7 +2,6 @@
lastEditedAt's $derived are scope-local to one computation; they're never lastEditedAt's $derived are scope-local to one computation; they're never
stored on $state. */ stored on $state. */
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types'; import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
import { makeCsrfFetch } from '$lib/shared/cookies';
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry'; import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
import { BlockConflictResolvedError } from './blockConflictMerge'; import { BlockConflictResolvedError } from './blockConflictMerge';
@@ -42,7 +41,7 @@ export function createTranscriptionBlocks(
options: TranscriptionBlocksOptions options: TranscriptionBlocksOptions
): TranscriptionBlocksController { ): TranscriptionBlocksController {
const { documentId } = options; const { documentId } = options;
const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch); const fetchImpl = options.fetchImpl ?? fetch;
let blocks = $state<TranscriptionBlockData[]>([]); let blocks = $state<TranscriptionBlockData[]>([]);
let annotationReloadKey = $state(0); let annotationReloadKey = $state(0);
@@ -120,11 +119,7 @@ export function createTranscriptionBlocks(
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, { const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
method: 'PUT' method: 'PUT'
}); });
if (!res.ok) { if (!res.ok) return;
const body = await res.json().catch(() => ({}));
// Never render body.message — route through getErrorMessage() to prevent leaking backend internals
throw new Error((body as { code?: string })?.code ?? 'INTERNAL_ERROR');
}
const updated = (await res.json()) as { id: string; reviewed: boolean }[]; const updated = (await res.json()) as { id: string; reviewed: boolean }[];
for (const b of updated) { for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id); const existing = blocks.find((x) => x.id === b.id);

View File

@@ -34,7 +34,7 @@ let {
<button <button
onclick={onPrev} onclick={onPrev}
disabled={currentPage <= 1} disabled={currentPage <= 1}
aria-label={m.viewer_previous_page()} aria-label="Zurück"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40" class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -51,7 +51,7 @@ let {
<button <button
onclick={onNext} onclick={onNext}
disabled={!isLoaded || currentPage >= totalPages} disabled={!isLoaded || currentPage >= totalPages}
aria-label={m.viewer_next_page()} aria-label="Weiter"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40" class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -64,7 +64,7 @@ let {
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <button
onclick={onZoomOut} onclick={onZoomOut}
aria-label={m.viewer_zoom_out()} aria-label="Verkleinern"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1" class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -74,7 +74,7 @@ let {
</button> </button>
<button <button
onclick={onZoomIn} onclick={onZoomIn}
aria-label={m.viewer_zoom_in()} aria-label="Vergrößern"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1" class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">

View File

@@ -2,7 +2,6 @@ import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import PdfControls from './PdfControls.svelte'; import PdfControls from './PdfControls.svelte';
afterEach(cleanup); afterEach(cleanup);
@@ -24,28 +23,28 @@ describe('PdfControls — annotation toggle visibility', () => {
it('renders annotation toggle when annotationCount is greater than zero', async () => { it('renders annotation toggle when annotationCount is greater than zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 3 }); render(PdfControls, { ...defaultProps, annotationCount: 3 });
await expect await expect
.element(page.getByRole('button', { name: m.pdf_annotations_show() })) .element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
it('does not render annotation toggle when annotationCount is zero', async () => { it('does not render annotation toggle when annotationCount is zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 0 }); render(PdfControls, { ...defaultProps, annotationCount: 0 });
await expect await expect
.element(page.getByRole('button', { name: m.pdf_annotations_show() })) .element(page.getByRole('button', { name: /annotierungen/i }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
}); });
}); });
describe('PdfControls — annotation toggle label', () => { describe('PdfControls — annotation toggle label', () => {
it('shows show-annotations label when annotations are hidden', async () => { it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false }); render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
const btn = page.getByRole('button', { name: m.pdf_annotations_show() }); const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
await expect.element(btn).toBeInTheDocument(); await expect.element(btn).toBeInTheDocument();
}); });
it('shows hide-annotations label when annotations are visible', async () => { it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true }); render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
const btn = page.getByRole('button', { name: m.pdf_annotations_hide() }); const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
await expect.element(btn).toBeInTheDocument(); await expect.element(btn).toBeInTheDocument();
}); });
}); });
@@ -59,9 +58,7 @@ describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
}); });
const allButtons = container.querySelectorAll('button'); const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) => const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes( b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
b.getAttribute('aria-label') ?? ''
)
); );
expect(annotationBtn).not.toBeNull(); expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('text-primary'); expect(annotationBtn!.className).toContain('text-primary');
@@ -78,9 +75,7 @@ describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
}); });
const allButtons = container.querySelectorAll('button'); const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) => const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes( b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
b.getAttribute('aria-label') ?? ''
)
); );
expect(annotationBtn).not.toBeNull(); expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('focus-visible:ring-2'); expect(annotationBtn!.className).toContain('focus-visible:ring-2');
@@ -91,12 +86,7 @@ describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
const allButtons = container.querySelectorAll('button'); const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => { const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? ''; const label = b.getAttribute('aria-label') ?? '';
return [ return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
m.viewer_previous_page(),
m.viewer_next_page(),
m.viewer_zoom_out(),
m.viewer_zoom_in()
].includes(label);
}); });
expect(iconOnlyButtons).toHaveLength(4); expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) { for (const btn of iconOnlyButtons) {
@@ -114,9 +104,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
}); });
const allButtons = container.querySelectorAll('button'); const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) => const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes( b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
b.getAttribute('aria-label') ?? ''
)
); );
expect(annotationBtn).not.toBeNull(); expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-h-[44px]'); expect(annotationBtn!.className).toContain('min-h-[44px]');
@@ -130,9 +118,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
}); });
const allButtons = container.querySelectorAll('button'); const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) => const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes( b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
b.getAttribute('aria-label') ?? ''
)
); );
expect(annotationBtn).not.toBeNull(); expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-w-[44px]'); expect(annotationBtn!.className).toContain('min-w-[44px]');
@@ -145,9 +131,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
showAnnotations: false showAnnotations: false
}); });
const btn1 = Array.from(c1.querySelectorAll('button')).find((b) => const btn1 = Array.from(c1.querySelectorAll('button')).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes( b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
b.getAttribute('aria-label') ?? ''
)
); );
expect(btn1!.getAttribute('aria-pressed')).toBe('false'); expect(btn1!.getAttribute('aria-pressed')).toBe('false');
cleanup(); cleanup();
@@ -158,9 +142,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
showAnnotations: true showAnnotations: true
}); });
const btn2 = Array.from(c2.querySelectorAll('button')).find((b) => const btn2 = Array.from(c2.querySelectorAll('button')).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes( b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
b.getAttribute('aria-label') ?? ''
)
); );
expect(btn2!.getAttribute('aria-pressed')).toBe('true'); expect(btn2!.getAttribute('aria-pressed')).toBe('true');
}); });
@@ -170,12 +152,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
const allButtons = container.querySelectorAll('button'); const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => { const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? ''; const label = b.getAttribute('aria-label') ?? '';
return [ return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
m.viewer_previous_page(),
m.viewer_next_page(),
m.viewer_zoom_out(),
m.viewer_zoom_in()
].includes(label);
}); });
expect(iconOnlyButtons).toHaveLength(4); expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) { for (const btn of iconOnlyButtons) {
@@ -188,12 +165,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
const allButtons = container.querySelectorAll('button'); const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => { const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? ''; const label = b.getAttribute('aria-label') ?? '';
return [ return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
m.viewer_previous_page(),
m.viewer_next_page(),
m.viewer_zoom_out(),
m.viewer_zoom_in()
].includes(label);
}); });
expect(iconOnlyButtons).toHaveLength(4); expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) { for (const btn of iconOnlyButtons) {

View File

@@ -180,22 +180,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/users/{id}/force-logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["forceLogout"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/me/password": { "/api/users/me/password": {
parameters: { parameters: {
query?: never; query?: never;
@@ -596,38 +580,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/auth/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["logout"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["login"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/forgot-password": { "/api/auth/forgot-password": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1897,7 +1849,7 @@ export interface components {
status: string; status: string;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
shareableUrl: string; shareableUrl?: string;
}; };
GroupDTO: { GroupDTO: {
name?: string; name?: string;
@@ -2059,17 +2011,13 @@ export interface components {
lastName?: string; lastName?: string;
notifyOnMention?: boolean; notifyOnMention?: boolean;
}; };
LoginRequest: {
email?: string;
password?: string;
};
ForgotPasswordRequest: { ForgotPasswordRequest: {
email?: string; email?: string;
}; };
ImportStatus: { ImportStatus: {
/** @enum {string} */ /** @enum {string} */
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED"; state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode?: string; message?: string;
/** Format: int32 */ /** Format: int32 */
processed?: number; processed?: number;
/** Format: date-time */ /** Format: date-time */
@@ -2307,14 +2255,14 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
totalPages?: number; totalPages?: number;
pageable?: components["schemas"]["PageableObject"]; pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
size?: number; size?: number;
content?: components["schemas"]["NotificationDTO"][]; content?: components["schemas"]["NotificationDTO"][];
/** Format: int32 */ /** Format: int32 */
number?: number; number?: number;
sort?: components["schemas"]["SortObject"]; sort?: components["schemas"]["SortObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
numberOfElements?: number; numberOfElements?: number;
empty?: boolean; empty?: boolean;
@@ -2462,7 +2410,7 @@ export interface components {
}; };
ActivityFeedItemDTO: { ActivityFeedItemDTO: {
/** @enum {string} */ /** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED"; kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
actor?: components["schemas"]["ActivityActorDTO"]; actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */ /** Format: uuid */
documentId: string; documentId: string;
@@ -3006,30 +2954,6 @@ export interface operations {
}; };
}; };
}; };
forceLogout: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: unknown;
};
};
};
};
};
changePassword: { changePassword: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3623,7 +3547,6 @@ export interface operations {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
documentId: string;
blockId: string; blockId: string;
}; };
cookie?: never; cookie?: never;
@@ -3674,7 +3597,6 @@ export interface operations {
header?: never; header?: never;
path: { path: {
documentId: string; documentId: string;
blockId: string;
commentId: string; commentId: string;
}; };
cookie?: never; cookie?: never;
@@ -3869,48 +3791,6 @@ export interface operations {
}; };
}; };
}; };
logout: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
forgotPassword: { forgotPassword: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5105,7 +4985,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["DocumentDensityResult"]; "*/*": components["schemas"]["DocumentDensityResult"];
}; };
}; };
}; };
@@ -5181,7 +5061,7 @@ export interface operations {
query?: { query?: {
limit?: number; limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */ /** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[]; kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
}; };
header?: never; header?: never;
path?: never; path?: never;

View File

@@ -1,43 +0,0 @@
import { describe, it, expect } from 'vitest';
import de from '../../messages/de.json';
import en from '../../messages/en.json';
import es from '../../messages/es.json';
describe('message key parity', () => {
it('de, en, and es have identical key sets', () => {
const deKeys = Object.keys(de).sort();
const enKeys = Object.keys(en).sort();
const esKeys = Object.keys(es).sort();
expect(enKeys).toEqual(deKeys);
expect(esKeys).toEqual(deKeys);
});
it('viewer navigation keys are present in all locales', () => {
const requiredViewerKeys = [
'viewer_previous_page',
'viewer_next_page',
'viewer_zoom_out',
'viewer_zoom_in'
];
for (const key of requiredViewerKeys) {
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
});
it('transcribe mark-for-training key is present in all locales', () => {
expect(de).toHaveProperty('transcribe_mark_for_training');
expect(en).toHaveProperty('transcribe_mark_for_training');
expect(es).toHaveProperty('transcribe_mark_for_training');
});
it('layout menu open/close keys are present in all locales', () => {
expect(de).toHaveProperty('layout_menu_open');
expect(de).toHaveProperty('layout_menu_close');
expect(en).toHaveProperty('layout_menu_open');
expect(en).toHaveProperty('layout_menu_close');
expect(es).toHaveProperty('layout_menu_open');
expect(es).toHaveProperty('layout_menu_close');
});
});

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside'; import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte'; import { notificationStore } from '$lib/notification/notifications.svelte';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import NotificationDropdown from './NotificationDropdown.svelte'; import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false); let open = $state(false);
@@ -28,6 +30,17 @@ function closeDropdown() {
bellButtonEl?.focus(); bellButtonEl?.focus();
} }
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
await stream.markRead(notification);
const url = buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
);
closeDropdown();
goto(url);
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) { if (event.key === 'Escape' && open) {
event.stopPropagation(); event.stopPropagation();
@@ -100,8 +113,8 @@ onDestroy(() => {
{#if open} {#if open}
<NotificationDropdown <NotificationDropdown
notifications={stream.notifications} notifications={stream.notifications}
optimisticMarkRead={stream.optimisticMarkRead} onMarkRead={handleMarkRead}
optimisticMarkAllRead={stream.optimisticMarkAllRead} onMarkAllRead={stream.markAllRead}
onClose={closeDropdown} onClose={closeDropdown}
/> />
{/if} {/if}

View File

@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte'; import NotificationBell from './NotificationBell.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() })); const gotoMock = vi.hoisted(() => vi.fn());
vi.mock('$app/forms', () => ({ vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {
e.preventDefault();
submit?.({ formData: new FormData(node) } as never);
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] })); const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({ vi.mock('$lib/notification/notifications.svelte', () => ({
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
get unreadCount() { get unreadCount() {
return mockNotificationList.value.length; return mockNotificationList.value.length;
}, },
optimisticMarkRead: vi.fn(), markRead: mockMarkRead,
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined), fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(), init: vi.fn(),
destroy: vi.fn() destroy: vi.fn(),
markAllRead: vi.fn()
} }
})); }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); gotoMock.mockClear();
mockMarkRead.mockClear();
mockNotificationList.value = []; mockNotificationList.value = [];
}); });
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides ...overrides
}); });
async function openDropdownAndClickFirstNotification() {
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
bellButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => { describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => { it('bell button has cursor-pointer class', async () => {
render(NotificationBell); render(NotificationBell);
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
}); });
}); });
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith(
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
);
});
});
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
mockNotificationList.value = [makeNotification({ annotationId: null })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
});
});
});

View File

@@ -1,21 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = { type Props = {
notifications: NotificationItem[]; notifications: NotificationItem[];
optimisticMarkRead: (id: string) => void; onMarkRead: (notification: NotificationItem) => void;
optimisticMarkAllRead: () => void; onMarkAllRead: () => void;
onClose: () => void; onClose: () => void;
}; };
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props(); let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
let errorMessage = $state<string | null>(null);
function handleViewAll() { function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition onClose(); // close first — avoids stale dropdown during navigation transition
@@ -35,35 +31,16 @@ function handleViewAll() {
{m.notification_bell_label()} {m.notification_bell_label()}
</span> </span>
{#if notifications.length > 0} {#if notifications.length > 0}
<form <button
action="/aktivitaeten?/mark-all-read" type="button"
method="POST" onclick={onMarkAllRead}
use:enhance={() => { class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
errorMessage = null;
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<button {m.notification_mark_all_read()}
type="submit" </button>
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
</form>
{/if} {/if}
</div> </div>
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
<!-- Notification list --> <!-- Notification list -->
{#if notifications.length === 0} {#if notifications.length === 0}
<!-- Empty state --> <!-- Empty state -->
@@ -89,93 +66,67 @@ function handleViewAll() {
<ul role="list" class="max-h-[24rem] overflow-y-auto"> <ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)} {#each notifications as notification (notification.id)}
<li> <li>
<form <button
action="/aktivitaeten?/dismiss-notification" type="button"
method="POST" onclick={() => onMarkRead(notification)}
class="contents" class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
use:enhance={() => { {!notification.read ? 'bg-accent-bg/20' : ''}"
errorMessage = null;
optimisticMarkRead(notification.id);
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
} else {
// Navigate away — no need to update the store since we're leaving the page
onClose();
goto(
buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
)
);
}
};
}}
> >
<input type="hidden" name="notificationId" value={notification.id} /> <!-- Type icon -->
<button <span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
type="submit" {#if notification.type === 'REPLY'}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas <!-- Reply icon -->
{!notification.read ? 'bg-accent-bg/20' : ''}" <svg
> xmlns="http://www.w3.org/2000/svg"
<!-- Type icon --> class="h-4 w-4"
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true"> fill="none"
{#if notification.type === 'REPLY'} viewBox="0 0 24 24"
<!-- Reply icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
stroke-width="2" />
> </svg>
<path {:else}
stroke-linecap="round" <!-- Mention icon -->
stroke-linejoin="round" <svg
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" xmlns="http://www.w3.org/2000/svg"
/> class="h-4 w-4"
</svg> fill="none"
{:else} viewBox="0 0 24 24"
<!-- Mention icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
stroke-width="2" />
> </svg>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if} {/if}
</button> </span>
</form>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if}
</button>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -6,38 +6,9 @@ import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
// Invoke the SubmitFunction and always call the returned result callback with
// mockFormResult so tests can exercise both success and failure branches.
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await cb({ result: mockFormResult, update: async () => {} } as never);
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
}); });
const makeNotification = (overrides: Record<string, unknown> = {}) => ({ const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })], notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true }) makeNotification({ id: 'n2', read: true })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
expect(unreadDots.length).toBe(1); expect(unreadDots.length).toBe(1);
}); });
it('each notification row is wrapped in a form posting to the dismiss action', async () => { it('calls onMarkRead with the notification when an item is clicked', async () => {
render(NotificationDropdown, { const onMarkRead = vi.fn();
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const input = document.querySelector<HTMLInputElement>(
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
);
expect(input?.value).toBe('n42');
});
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
const optimisticMarkRead = vi.fn();
const n = makeNotification({ id: 'n42', actorName: 'Anna' }); const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [n], notifications: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click(); await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n42'); expect(onMarkRead).toHaveBeenCalledWith(n);
}); });
it('the mark-all-read control is a form posting to the mark-all-read action', async () => { it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead,
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
const optimisticMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead,
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /alle gelesen/i }).click(); await page.getByRole('button', { name: /alle gelesen/i }).click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
mockFormResult.type = 'failure';
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
}); });
it('calls onClose when the view-all button is clicked', async () => { it('calls onClose when the view-all button is clicked', async () => {
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
it('calls onClose before navigating to /aktivitaeten', async () => { it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = []; const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close')); const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => { vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
callOrder.push('goto');
return Promise.resolve();
});
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })], notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', actorName: 'First' }), makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' }) makeNotification({ id: 'n2', actorName: 'Second' })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]'); const items = document.querySelectorAll('button[type="button"]');
expect(forms.length).toBe(2); // At least 2 items + mark-all button
}); expect(items.length).toBeGreaterThanOrEqual(2);
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
const onClose = vi.fn();
const n = makeNotification({
id: 'n42',
documentId: 'd1',
referenceId: 'c1',
annotationId: null,
actorName: 'Anna'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
});
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
const onClose = vi.fn();
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
expect(onClose).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
const n = makeNotification({
id: 'n55',
documentId: 'd1',
referenceId: 'c1',
annotationId: 'a1',
actorName: 'Eva'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
}); });
}); });

View File

@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
expect(notificationStore.unreadCount).toBe(1); expect(notificationStore.unreadCount).toBe(1);
}); });
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { it('markAllRead resets unreadCount', async () => {
notificationStore.init(); mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const notification = makeNotification({ id: 'sse-1', read: false }); await notificationStore.markAllRead();
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
notificationStore.optimisticMarkRead('sse-1'); expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(notificationStore.notifications[0].read).toBe(true);
expect(notificationStore.unreadCount).toBe(0); expect(notificationStore.unreadCount).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
});
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.unreadCount).toBe(0);
});
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
notificationStore.init();
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false }))
);
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n2', read: false }))
);
mockFetch.mockReset();
notificationStore.optimisticMarkAllRead();
expect(notificationStore.unreadCount).toBe(0);
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -35,19 +35,28 @@ async function fetchUnreadCount(): Promise<void> {
} }
} }
function optimisticMarkRead(id: string): void { async function markRead(notification: NotificationItem): Promise<void> {
const notification = notifications.find((n) => n.id === id); if (!notification.read) {
if (notification && !notification.read) { try {
notification.read = true; await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
unreadCount = Math.max(0, unreadCount - 1); notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
} }
} }
function optimisticMarkAllRead(): void { async function markAllRead(): Promise<void> {
for (const n of notifications) { try {
n.read = true; await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
} }
unreadCount = 0;
} }
function init(): void { function init(): void {
@@ -114,8 +123,8 @@ export const notificationStore = {
}, },
fetchNotifications, fetchNotifications,
fetchUnreadCount, fetchUnreadCount,
optimisticMarkRead, markRead,
optimisticMarkAllRead, markAllRead,
init, init,
destroy destroy
}; };

View File

@@ -2,7 +2,6 @@
import TrainingHistory from './TrainingHistory.svelte'; import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/ocr/training.js'; import type { TrainingRun } from '$lib/ocr/training.js';
import { withCsrf } from '$lib/shared/cookies';
interface TrainingInfo { interface TrainingInfo {
availableBlocks?: number; availableBlocks?: number;
@@ -34,7 +33,7 @@ async function startTraining() {
successMessage = null; successMessage = null;
errorMessage = null; errorMessage = null;
try { try {
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' })); const res = await fetch('/api/ocr/train', { method: 'POST' });
if (res.ok) { if (res.ok) {
successMessage = m.training_success(); successMessage = m.training_success();
setTimeout(() => { setTimeout(() => {

View File

@@ -2,7 +2,6 @@
import TrainingHistory from './TrainingHistory.svelte'; import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/ocr/training.js'; import type { TrainingRun } from '$lib/ocr/training.js';
import { withCsrf } from '$lib/shared/cookies';
interface TrainingInfo { interface TrainingInfo {
availableSegBlocks?: number; availableSegBlocks?: number;
@@ -28,7 +27,7 @@ async function startTraining() {
training = true; training = true;
successMessage = null; successMessage = null;
try { try {
const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' })); const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
if (res.ok) { if (res.ok) {
successMessage = m.training_success(); successMessage = m.training_success();
setTimeout(() => { setTimeout(() => {

View File

@@ -1,20 +0,0 @@
import { describe, it, expect } from 'vitest';
import { extractErrorCode } from './api.server';
describe('extractErrorCode', () => {
it('returns the code string when error has a code property', () => {
expect(extractErrorCode({ code: 'DOCUMENT_NOT_FOUND' })).toBe('DOCUMENT_NOT_FOUND');
});
it('returns undefined when error is undefined', () => {
expect(extractErrorCode(undefined)).toBeUndefined();
});
it('returns undefined when error is null', () => {
expect(extractErrorCode(null)).toBeUndefined();
});
it('returns undefined when error is a plain string', () => {
expect(extractErrorCode('oops')).toBeUndefined();
});
it('returns undefined when error object has no code property', () => {
expect(extractErrorCode({ message: 'fail' })).toBeUndefined();
});
});

View File

@@ -23,11 +23,3 @@ export function createApiClient(fetch: typeof globalThis.fetch) {
fetch fetch
}); });
} }
export interface ApiError {
code?: string;
}
export function extractErrorCode(error: unknown): string | undefined {
return (error as ApiError | undefined)?.code;
}

View File

@@ -1,46 +1,3 @@
/**
* Reads the XSRF-TOKEN cookie set by Spring Security's CookieCsrfTokenRepository.
* Returns null outside the browser or when the cookie is absent.
*/
export function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Merges the X-XSRF-TOKEN header into a RequestInit so Spring Security's
* CSRF filter accepts the request. Safe to call server-side (no-op when the
* cookie is absent).
*/
export function withCsrf(init?: RequestInit): RequestInit {
const token = getCsrfToken();
if (!token) return init ?? {};
const headers = new Headers(init?.headers);
headers.set('X-XSRF-TOKEN', token);
return { ...init, headers };
}
/**
* Wraps a fetch implementation so that every state-mutating call (POST, PUT,
* PATCH, DELETE) automatically includes the X-XSRF-TOKEN header. GET/HEAD
* requests pass through unchanged.
*
* Used to CSRF-protect client-side hooks that accept an injectable fetchImpl.
* In unit tests the injected mock is wrapped but getCsrfToken() returns null
* (no browser cookie), so no header is added and existing test expectations
* are unaffected.
*/
export function makeCsrfFetch(inner: typeof fetch): typeof fetch {
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const method = (init?.method ?? 'GET').toUpperCase();
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return inner(input, withCsrf(init));
}
return inner(input, init);
};
}
/** /**
* Extracts the fa_session cookie value from a list of Set-Cookie response headers. * Extracts the fa_session cookie value from a list of Set-Cookie response headers.
* *

Some files were not shown because too many files have changed in this diff Show More