Compare commits
2 Commits
main
...
22589e4729
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22589e4729 | ||
|
|
e124c68cf4 |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -180,16 +180,11 @@
|
|||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
|
<!-- Caffeine cache for in-memory rate limiting -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
<artifactId>caffeine</artifactId>
|
<artifactId>caffeine</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>com.bucket4j</groupId>
|
|
||||||
<artifactId>bucket4j-core</artifactId>
|
|
||||||
<version>8.10.1</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -43,14 +43,8 @@ public enum AuditKind {
|
|||||||
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
||||||
LOGIN_FAILED,
|
LOGIN_FAILED,
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */
|
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
|
||||||
LOGOUT,
|
LOGOUT;
|
||||||
|
|
||||||
/** Payload: {@code {"actorId": "uuid", "targetUserId": "uuid", "revokedCount": 3}} */
|
|
||||||
ADMIN_FORCE_LOGOUT,
|
|
||||||
|
|
||||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
|
||||||
LOGIN_RATE_LIMITED;
|
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
|
|||||||
@@ -24,18 +24,13 @@ public class AuthService {
|
|||||||
private final AuthenticationManager authenticationManager;
|
private final AuthenticationManager authenticationManager;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final LoginRateLimiter loginRateLimiter;
|
|
||||||
private final SessionRevocationPort sessionRevocationPort;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates credentials and returns the authenticated user plus the Spring Security
|
||||||
|
* Authentication object. The caller is responsible for persisting the Authentication
|
||||||
|
* to the session via SecurityContextRepository.
|
||||||
|
*/
|
||||||
public LoginResult login(String email, String password, String ip, String ua) {
|
public LoginResult login(String email, String password, String ip, String ua) {
|
||||||
try {
|
|
||||||
loginRateLimiter.checkAndConsume(ip, email);
|
|
||||||
} catch (DomainException ex) {
|
|
||||||
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
|
|
||||||
"ip", ip,
|
|
||||||
"email", email));
|
|
||||||
throw ex;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
Authentication auth = authenticationManager.authenticate(
|
Authentication auth = authenticationManager.authenticate(
|
||||||
new UsernamePasswordAuthenticationToken(email, password));
|
new UsernamePasswordAuthenticationToken(email, password));
|
||||||
@@ -45,7 +40,6 @@ public class AuthService {
|
|||||||
"userId", user.getId().toString(),
|
"userId", user.getId().toString(),
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"ua", truncateUa(ua)));
|
"ua", truncateUa(ua)));
|
||||||
loginRateLimiter.invalidateOnSuccess(ip, email);
|
|
||||||
return new LoginResult(user, auth);
|
return new LoginResult(user, auth);
|
||||||
} catch (AuthenticationException ex) {
|
} catch (AuthenticationException ex) {
|
||||||
// Audit login failure — intentionally does NOT log the attempted password.
|
// Audit login failure — intentionally does NOT log the attempted password.
|
||||||
@@ -59,14 +53,6 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
|
||||||
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int revokeAllSessions(String principalName) {
|
|
||||||
return sessionRevocationPort.revokeAllSessions(principalName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void logout(String email, String ip, String ua) {
|
public void logout(String email, String ip, String ua) {
|
||||||
AppUser user = userService.findByEmail(email);
|
AppUser user = userService.findByEmail(email);
|
||||||
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.auth;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
|
|
||||||
|
|
||||||
private final JdbcIndexedSessionRepository sessionRepository;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
|
||||||
int count = 0;
|
|
||||||
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
|
|
||||||
if (!id.equals(currentSessionId)) {
|
|
||||||
sessionRepository.deleteById(id);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int revokeAllSessions(String principalName) {
|
|
||||||
var sessions = sessionRepository.findByPrincipalName(principalName);
|
|
||||||
sessions.keySet().forEach(sessionRepository::deleteById);
|
|
||||||
return sessions.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.auth;
|
|
||||||
|
|
||||||
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int revokeAllSessions(String principalName) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.auth;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@ConfigurationProperties("rate-limit.login")
|
|
||||||
@Data
|
|
||||||
public class RateLimitProperties {
|
|
||||||
private int maxAttemptsPerIpEmail = 10;
|
|
||||||
private int maxAttemptsPerIp = 20;
|
|
||||||
private int windowMinutes = 15;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.auth;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
class SessionRevocationConfig {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
SessionRevocationPort sessionRevocationPort(
|
|
||||||
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
|
|
||||||
if (sessionRepository != null) {
|
|
||||||
return new JdbcSessionRevocationAdapter(sessionRepository);
|
|
||||||
}
|
|
||||||
return new NoOpSessionRevocationAdapter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.auth;
|
|
||||||
|
|
||||||
public interface SessionRevocationPort {
|
|
||||||
int revokeOtherSessions(String currentSessionId, String principalName);
|
|
||||||
int revokeAllSessions(String principalName);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<>();
|
||||||
|
|
||||||
|
|||||||
@@ -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) " +
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.auth.AuthService;
|
|
||||||
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -33,7 +32,6 @@ public class PasswordResetService {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final PasswordResetTokenRepository tokenRepository;
|
private final PasswordResetTokenRepository tokenRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuthService authService;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private JavaMailSender mailSender;
|
private JavaMailSender mailSender;
|
||||||
@@ -87,8 +85,6 @@ public class PasswordResetService {
|
|||||||
|
|
||||||
resetToken.setUsed(true);
|
resetToken.setUsed(true);
|
||||||
tokenRepository.save(resetToken);
|
tokenRepository.save(resetToken);
|
||||||
|
|
||||||
authService.revokeAllSessions(user.getEmail());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpSession;
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
|
||||||
import org.raddatz.familienarchiv.auth.AuthService;
|
|
||||||
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
||||||
@@ -30,15 +26,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/")
|
@RequestMapping("/api/")
|
||||||
@RequiredArgsConstructor
|
@AllArgsConstructor
|
||||||
public class UserController {
|
public class UserController {
|
||||||
private final UserService userService;
|
private UserService userService;
|
||||||
private final AuthService authService;
|
|
||||||
private final AuditService auditService;
|
|
||||||
|
|
||||||
@GetMapping("users/me")
|
@GetMapping("users/me")
|
||||||
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
||||||
@@ -62,14 +56,9 @@ public class UserController {
|
|||||||
@PostMapping("users/me/password")
|
@PostMapping("users/me/password")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void changePassword(Authentication authentication,
|
public void changePassword(Authentication authentication,
|
||||||
HttpSession session,
|
|
||||||
@RequestBody ChangePasswordDTO dto) {
|
@RequestBody ChangePasswordDTO dto) {
|
||||||
AppUser current = userService.findByEmail(authentication.getName());
|
AppUser current = userService.findByEmail(authentication.getName());
|
||||||
userService.changePassword(current.getId(), dto);
|
userService.changePassword(current.getId(), dto);
|
||||||
int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName());
|
|
||||||
auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of(
|
|
||||||
"reason", "password_change",
|
|
||||||
"revokedCount", revoked));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("users/{id}")
|
@GetMapping("users/{id}")
|
||||||
@@ -112,18 +101,6 @@ public class UserController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/users/{id}/force-logout")
|
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
|
||||||
public ResponseEntity<Map<String, Object>> forceLogout(Authentication authentication,
|
|
||||||
@PathVariable UUID id) {
|
|
||||||
AppUser target = userService.getById(id);
|
|
||||||
int revoked = authService.revokeAllSessions(target.getEmail());
|
|
||||||
auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of(
|
|
||||||
"targetUserId", target.getId().toString(),
|
|
||||||
"revokedCount", revoked));
|
|
||||||
return ResponseEntity.ok(Map.of("revokedCount", revoked));
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID actorId(Authentication auth) {
|
private UUID actorId(Authentication auth) {
|
||||||
return userService.findByEmail(auth.getName()).getId();
|
return userService.findByEmail(auth.getName()).getId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,9 +150,3 @@ sentry:
|
|||||||
enable-tracing: true
|
enable-tracing: true
|
||||||
ignored-exceptions-for-type:
|
ignored-exceptions-for-type:
|
||||||
- org.raddatz.familienarchiv.exception.DomainException
|
- org.raddatz.familienarchiv.exception.DomainException
|
||||||
|
|
||||||
rate-limit:
|
|
||||||
login:
|
|
||||||
max-attempts-per-ip-email: 10
|
|
||||||
max-attempts-per-ip: 20
|
|
||||||
window-minutes: 15
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.security.authentication.BadCredentialsException;
|
|||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -30,8 +31,6 @@ class AuthServiceTest {
|
|||||||
@Mock AuthenticationManager authenticationManager;
|
@Mock AuthenticationManager authenticationManager;
|
||||||
@Mock UserService userService;
|
@Mock UserService userService;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
@Mock LoginRateLimiter loginRateLimiter;
|
|
||||||
@Mock SessionRevocationPort sessionRevocationPort;
|
|
||||||
@InjectMocks AuthService authService;
|
@InjectMocks AuthService authService;
|
||||||
|
|
||||||
private static final String IP = "127.0.0.1";
|
private static final String IP = "127.0.0.1";
|
||||||
@@ -130,62 +129,4 @@ class AuthServiceTest {
|
|||||||
&& !payload.containsKey("password"))
|
&& !payload.containsKey("password"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void login_checks_rate_limit_before_authenticating() {
|
|
||||||
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
|
||||||
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
|
||||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
|
||||||
|
|
||||||
verify(authenticationManager, never()).authenticate(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
|
|
||||||
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
|
||||||
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
|
||||||
.isInstanceOf(DomainException.class);
|
|
||||||
|
|
||||||
verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(),
|
|
||||||
argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void login_invalidates_rate_limit_on_success() {
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
|
||||||
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
|
||||||
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
|
||||||
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
|
||||||
|
|
||||||
authService.login("user@test.de", "pass123", IP, UA);
|
|
||||||
|
|
||||||
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void revokeOtherSessions_delegates_to_port() {
|
|
||||||
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
|
|
||||||
|
|
||||||
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
|
|
||||||
|
|
||||||
assertThat(count).isEqualTo(2);
|
|
||||||
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void revokeAllSessions_delegates_to_port() {
|
|
||||||
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
|
|
||||||
|
|
||||||
int count = authService.revokeAllSessions("user@test.de");
|
|
||||||
|
|
||||||
assertThat(count).isEqualTo(3);
|
|
||||||
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import java.util.UUID;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -49,7 +48,6 @@ class AuthSessionControllerTest {
|
|||||||
.thenReturn(new LoginResult(appUser, auth));
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -63,7 +61,6 @@ class AuthSessionControllerTest {
|
|||||||
.thenThrow(DomainException.invalidCredentials());
|
.thenThrow(DomainException.invalidCredentials());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -80,7 +77,6 @@ class AuthSessionControllerTest {
|
|||||||
|
|
||||||
// No WithMockUser — must be reachable without an active session
|
// No WithMockUser — must be reachable without an active session
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -95,7 +91,6 @@ class AuthSessionControllerTest {
|
|||||||
.thenReturn(new LoginResult(appUser, auth));
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -121,7 +116,6 @@ class AuthSessionControllerTest {
|
|||||||
.thenReturn(new LoginResult(appUser, auth));
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -137,24 +131,12 @@ class AuthSessionControllerTest {
|
|||||||
.thenThrow(DomainException.invalidCredentials());
|
.thenThrow(DomainException.invalidCredentials());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
.andExpect(header().doesNotExist("Set-Cookie"));
|
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CSRF protection ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void authenticated_post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
|
||||||
// Red test: CSRF disabled → returns 204; after re-enabling returns 403.
|
|
||||||
mockMvc.perform(post("/api/auth/logout")
|
|
||||||
.with(user("user@test.de"))) // authenticated but no CSRF token
|
|
||||||
.andExpect(status().isForbidden())
|
|
||||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -162,18 +144,15 @@ class AuthSessionControllerTest {
|
|||||||
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/logout")
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
.with(user("user@test.de"))
|
.with(user("user@test.de")))
|
||||||
.with(csrf()))
|
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void logout_without_session_returns_403() throws Exception {
|
void logout_returns_401_when_not_authenticated() throws Exception {
|
||||||
// CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null,
|
// No authentication at all — Spring Security must return 401
|
||||||
// ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403.
|
|
||||||
mockMvc.perform(post("/api/auth/logout"))
|
mockMvc.perform(post("/api/auth/logout"))
|
||||||
.andExpect(status().isForbidden())
|
.andExpect(status().isUnauthorized());
|
||||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -184,8 +163,7 @@ class AuthSessionControllerTest {
|
|||||||
.when(authService).logout(anyString(), anyString(), anyString());
|
.when(authService).logout(anyString(), anyString(), anyString());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/logout")
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
.with(user("ghost@test.de"))
|
.with(user("ghost@test.de")))
|
||||||
.with(csrf()))
|
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,7 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void login_sets_opaque_fa_session_cookie() {
|
void login_sets_opaque_fa_session_cookie() {
|
||||||
String xsrf = fetchXsrfToken();
|
ResponseEntity<String> response = doLogin();
|
||||||
ResponseEntity<String> response = doLogin(xsrf);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
String cookie = extractFaSessionCookie(response);
|
String cookie = extractFaSessionCookie(response);
|
||||||
@@ -74,8 +73,7 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void session_cookie_authenticates_subsequent_request() {
|
void session_cookie_authenticates_subsequent_request() {
|
||||||
String xsrf = fetchXsrfToken();
|
String cookie = extractFaSessionCookie(doLogin());
|
||||||
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
|
||||||
|
|
||||||
ResponseEntity<String> me = http.exchange(
|
ResponseEntity<String> me = http.exchange(
|
||||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
@@ -86,17 +84,16 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
||||||
String xsrf = fetchXsrfToken();
|
String cookie = extractFaSessionCookie(doLogin());
|
||||||
String sessionCookie = extractFaSessionCookie(doLogin(xsrf));
|
|
||||||
|
|
||||||
ResponseEntity<Void> logout = http.postForEntity(
|
ResponseEntity<Void> logout = http.postForEntity(
|
||||||
baseUrl + "/api/auth/logout",
|
baseUrl + "/api/auth/logout",
|
||||||
new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class);
|
new HttpEntity<>(cookieHeaders(cookie)), Void.class);
|
||||||
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||||
|
|
||||||
ResponseEntity<String> me = http.exchange(
|
ResponseEntity<String> me = http.exchange(
|
||||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
new HttpEntity<>(cookieHeaders(sessionCookie)), String.class);
|
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +101,7 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void session_expired_by_idle_timeout_returns_401() {
|
void session_expired_by_idle_timeout_returns_401() {
|
||||||
String xsrf = fetchXsrfToken();
|
String cookie = extractFaSessionCookie(doLogin());
|
||||||
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
|
||||||
|
|
||||||
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
||||||
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
||||||
@@ -119,37 +115,11 @@ class AuthSessionIntegrationTest {
|
|||||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Task: CSRF rejection at integration layer ────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
|
|
||||||
ResponseEntity<String> response = http.postForEntity(
|
|
||||||
baseUrl + "/api/auth/logout",
|
|
||||||
new HttpEntity<>("{}", headers), String.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
|
||||||
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
private ResponseEntity<String> doLogin() {
|
||||||
* Generates an XSRF token for use in integration tests.
|
|
||||||
* CookieCsrfTokenRepository validates that Cookie: XSRF-TOKEN=X matches X-XSRF-TOKEN: X.
|
|
||||||
* By supplying both with the same value we simulate exactly what a browser does.
|
|
||||||
*/
|
|
||||||
private String fetchXsrfToken() {
|
|
||||||
return java.util.UUID.randomUUID().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ResponseEntity<String> doLogin(String xsrfToken) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrfToken);
|
|
||||||
headers.set("X-XSRF-TOKEN", xsrfToken);
|
|
||||||
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
||||||
return http.postForEntity(baseUrl + "/api/auth/login",
|
return http.postForEntity(baseUrl + "/api/auth/login",
|
||||||
new HttpEntity<>(body, headers), String.class);
|
new HttpEntity<>(body, headers), String.class);
|
||||||
@@ -161,13 +131,6 @@ class AuthSessionIntegrationTest {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpHeaders csrfAndSessionHeaders(String sessionId, String xsrfToken) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrfToken);
|
|
||||||
headers.set("X-XSRF-TOKEN", xsrfToken);
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||||
if (setCookieHeader == null) return "";
|
if (setCookieHeader == null) return "";
|
||||||
@@ -178,7 +141,6 @@ class AuthSessionIntegrationTest {
|
|||||||
.orElse("");
|
.orElse("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private RestTemplate noThrowRestTemplate() {
|
private RestTemplate noThrowRestTemplate() {
|
||||||
RestTemplate template = new RestTemplate();
|
RestTemplate template = new RestTemplate();
|
||||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||||
|
|||||||
@@ -1,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.auth;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class JdbcSessionRevocationAdapterTest {
|
|
||||||
|
|
||||||
@Mock JdbcIndexedSessionRepository sessionRepository;
|
|
||||||
@InjectMocks JdbcSessionRevocationAdapter adapter;
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
@Test
|
|
||||||
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
|
||||||
var sessions = new HashMap<String, Object>();
|
|
||||||
sessions.put("session-keep", null);
|
|
||||||
sessions.put("session-del-1", null);
|
|
||||||
sessions.put("session-del-2", null);
|
|
||||||
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
|
||||||
|
|
||||||
int count = adapter.revokeOtherSessions("session-keep", "user@test.de");
|
|
||||||
|
|
||||||
assertThat(count).isEqualTo(2);
|
|
||||||
verify(sessionRepository, never()).deleteById("session-keep");
|
|
||||||
verify(sessionRepository).deleteById("session-del-1");
|
|
||||||
verify(sessionRepository).deleteById("session-del-2");
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
@Test
|
|
||||||
void revokeAllSessions_deletes_all_sessions_for_principal() {
|
|
||||||
var sessions = new HashMap<String, Object>();
|
|
||||||
sessions.put("session-1", null);
|
|
||||||
sessions.put("session-2", null);
|
|
||||||
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
|
||||||
|
|
||||||
int count = adapter.revokeAllSessions("user@test.de");
|
|
||||||
|
|
||||||
assertThat(count).isEqualTo(2);
|
|
||||||
verify(sessionRepository).deleteById("session-1");
|
|
||||||
verify(sessionRepository).deleteById("session-2");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(AnnotationController.class)
|
@WebMvcTest(AnnotationController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -68,7 +67,7 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -77,7 +76,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -93,7 +92,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -102,7 +101,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +115,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -134,7 +133,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -144,28 +143,28 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +174,7 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -184,7 +183,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -200,7 +199,7 @@ class AnnotationControllerTest {
|
|||||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -218,7 +217,7 @@ class AnnotationControllerTest {
|
|||||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -230,7 +229,7 @@ class AnnotationControllerTest {
|
|||||||
when(annotationService.updateAnnotation(any(), any(), any()))
|
when(annotationService.updateAnnotation(any(), any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -239,7 +238,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"x\":-0.1,\"y\":0.3}"))
|
.content("{\"x\":-0.1,\"y\":0.3}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -248,7 +247,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"width\":0.005}"))
|
.content("{\"width\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -257,7 +256,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"height\":0.005}"))
|
.content("{\"height\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -266,7 +265,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"x\":1.1}"))
|
.content("{\"x\":1.1}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -277,7 +276,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
||||||
// authentication == null → resolveUserId returns null
|
// authentication == null → resolveUserId returns null
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -295,7 +294,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -313,7 +312,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(CommentController.class)
|
@WebMvcTest(CommentController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -71,7 +70,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||||
@@ -80,7 +79,7 @@ class CommentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -89,7 +88,7 @@ class CommentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
@@ -102,7 +101,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -117,7 +116,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -128,7 +127,7 @@ class CommentControllerTest {
|
|||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
||||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
+ "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
@@ -137,7 +136,7 @@ class CommentControllerTest {
|
|||||||
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
+ "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -152,7 +151,7 @@ class CommentControllerTest {
|
|||||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
+ "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -167,7 +166,7 @@ class CommentControllerTest {
|
|||||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
+ "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -176,7 +175,7 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -188,7 +187,7 @@ class CommentControllerTest {
|
|||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -200,7 +199,7 @@ class CommentControllerTest {
|
|||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -209,14 +208,14 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(TranscriptionBlockController.class)
|
@WebMvcTest(TranscriptionBlockController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -144,7 +143,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
mockMvc.perform(post(URL_BASE)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -153,7 +152,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
mockMvc.perform(post(URL_BASE)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -165,7 +164,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
mockMvc.perform(post(URL_BASE)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -178,7 +177,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
mockMvc.perform(post(URL_BASE)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -193,7 +192,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
mockMvc.perform(post(URL_BASE)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -207,7 +206,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||||
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
mockMvc.perform(post(URL_BASE)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -218,7 +217,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -227,7 +226,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -244,7 +243,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
||||||
.thenReturn(updated);
|
.thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -260,7 +259,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||||
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -273,7 +272,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -287,7 +286,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -298,7 +297,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -308,28 +307,28 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +339,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
||||||
.when(transcriptionService).deleteBlock(any(), any());
|
.when(transcriptionService).deleteBlock(any(), any());
|
||||||
|
|
||||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +347,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
mockMvc.perform(put(URL_REORDER)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -357,7 +356,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
mockMvc.perform(put(URL_REORDER)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -368,7 +367,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||||
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
mockMvc.perform(put(URL_REORDER)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -435,7 +434,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
||||||
DOC_ID, BLOCK_ID).with(csrf()))
|
DOC_ID, BLOCK_ID))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
@@ -446,14 +445,14 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +469,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
.thenReturn(List.of(b1, b2));
|
.thenReturn(List.of(b1, b2));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||||
@@ -484,7 +483,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$").isEmpty());
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
@@ -495,7 +494,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(GeschichteController.class)
|
@WebMvcTest(GeschichteController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -131,7 +130,7 @@ class GeschichteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_returns401_whenUnauthenticated() throws Exception {
|
void create_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
mockMvc.perform(post("/api/geschichten")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -140,7 +139,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void create_returns403_whenLackingBlogWrite() throws Exception {
|
void create_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
mockMvc.perform(post("/api/geschichten")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -156,7 +155,7 @@ class GeschichteControllerTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setTitle("New");
|
dto.setTitle("New");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
mockMvc.perform(post("/api/geschichten")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -168,7 +167,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void update_returns403_whenLackingBlogWrite() throws Exception {
|
void update_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -181,7 +180,7 @@ class GeschichteControllerTest {
|
|||||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||||
.thenReturn(published(id, "Updated"));
|
.thenReturn(published(id, "Updated"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
mockMvc.perform(patch("/api/geschichten/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"status\":\"PUBLISHED\"}"))
|
.content("{\"status\":\"PUBLISHED\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -193,7 +192,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +201,7 @@ class GeschichteControllerTest {
|
|||||||
void delete_returns204_withBlogWrite() throws Exception {
|
void delete_returns204_withBlogWrite() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
|
mockMvc.perform(delete("/api/geschichten/{id}", id))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(geschichteService).delete(id);
|
verify(geschichteService).delete(id);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class MassImportServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, 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", "foo∕bar.pdf");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsFullwidthSlash() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo/bar.pdf");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeReverseSolidus() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo⧵bar.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()) {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(NotificationController.class)
|
@WebMvcTest(NotificationController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -142,7 +141,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
mockMvc.perform(post("/api/notifications/read-all"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +151,7 @@ class NotificationControllerTest {
|
|||||||
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
||||||
when(userService.findByEmail("testuser")).thenReturn(user);
|
when(userService.findByEmail("testuser")).thenReturn(user);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
mockMvc.perform(post("/api/notifications/read-all"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(notificationService).markAllRead(USER_ID);
|
verify(notificationService).markAllRead(USER_ID);
|
||||||
@@ -162,7 +161,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf()))
|
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +176,7 @@ class NotificationControllerTest {
|
|||||||
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||||
.when(notificationService).markRead(notifId, USER_ID);
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +256,7 @@ class NotificationControllerTest {
|
|||||||
.notifyOnReply(true).notifyOnMention(true).build();
|
.notifyOnReply(true).notifyOnMention(true).build();
|
||||||
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -276,7 +275,7 @@ class NotificationControllerTest {
|
|||||||
.notifyOnReply(true).notifyOnMention(false).build();
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -338,7 +337,7 @@ class NotificationControllerTest {
|
|||||||
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||||
.when(notificationService).markRead(notifId, USER_ID);
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(OcrController.class)
|
@WebMvcTest(OcrController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -67,7 +66,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -81,7 +80,7 @@ class OcrControllerTest {
|
|||||||
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
||||||
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -128,7 +127,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/batch").with(csrf())
|
mockMvc.perform(post("/api/ocr/batch")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -180,14 +179,14 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
mockMvc.perform(post("/api/ocr/train"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
mockMvc.perform(post("/api/ocr/train"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +196,7 @@ class OcrControllerTest {
|
|||||||
when(ocrTrainingService.triggerTraining(any()))
|
when(ocrTrainingService.triggerTraining(any()))
|
||||||
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
mockMvc.perform(post("/api/ocr/train"))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +209,7 @@ class OcrControllerTest {
|
|||||||
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
||||||
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
mockMvc.perform(post("/api/ocr/train"))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.status").value("DONE"))
|
.andExpect(jsonPath("$.status").value("DONE"))
|
||||||
.andExpect(jsonPath("$.blockCount").value(10));
|
.andExpect(jsonPath("$.blockCount").value(10));
|
||||||
@@ -366,7 +365,7 @@ class OcrControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
mockMvc.perform(post("/api/ocr/train-sender")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":null}"))
|
.content("{\"personId\":null}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -374,7 +373,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
mockMvc.perform(post("/api/ocr/train-sender")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -383,7 +382,7 @@ class OcrControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
mockMvc.perform(post("/api/ocr/train-sender")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -396,7 +395,7 @@ class OcrControllerTest {
|
|||||||
when(senderModelService.triggerManualSenderTraining(unknownId))
|
when(senderModelService.triggerManualSenderTraining(unknownId))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
mockMvc.perform(post("/api/ocr/train-sender")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + unknownId + "\"}"))
|
.content("{\"personId\":\"" + unknownId + "\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -411,7 +410,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
mockMvc.perform(post("/api/ocr/train-sender")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -427,7 +426,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
mockMvc.perform(post("/api/ocr/train-sender")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -443,7 +442,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
mockMvc.perform(post("/api/ocr/train-sender")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted());
|
.andExpect(status().isAccepted());
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(PersonController.class)
|
@WebMvcTest(PersonController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -218,7 +217,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -227,7 +226,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -236,7 +235,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -245,7 +244,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -254,7 +253,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -266,7 +265,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -279,7 +278,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -294,7 +293,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -308,7 +307,7 @@ class PersonControllerTest {
|
|||||||
when(personService.createPerson(any())).thenThrow(
|
when(personService.createPerson(any())).thenThrow(
|
||||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -319,7 +318,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -328,7 +327,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -337,7 +336,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -350,7 +349,7 @@ class PersonControllerTest {
|
|||||||
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -361,7 +360,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -370,7 +369,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -379,7 +378,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\" \"}"))
|
.content("{\"targetPersonId\":\" \"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -391,7 +390,7 @@ class PersonControllerTest {
|
|||||||
UUID sourceId = UUID.randomUUID();
|
UUID sourceId = UUID.randomUUID();
|
||||||
UUID targetId = UUID.randomUUID();
|
UUID targetId = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -403,7 +402,7 @@ class PersonControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -419,7 +418,7 @@ class PersonControllerTest {
|
|||||||
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
@@ -437,7 +436,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
||||||
String oversizedNotes = "x".repeat(5001);
|
String oversizedNotes = "x".repeat(5001);
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -448,7 +447,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
||||||
String oversizedFirstName = "x".repeat(101);
|
String oversizedFirstName = "x".repeat(101);
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -459,7 +458,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons").with(csrf())
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -468,7 +467,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -477,7 +476,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -508,7 +507,7 @@ class PersonControllerTest {
|
|||||||
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
||||||
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/aliases", personId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -518,7 +517,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void addAlias_returns403_withoutWritePermission() throws Exception {
|
void addAlias_returns403_withoutWritePermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -532,7 +531,7 @@ class PersonControllerTest {
|
|||||||
UUID personId = UUID.randomUUID();
|
UUID personId = UUID.randomUUID();
|
||||||
UUID aliasId = UUID.randomUUID();
|
UUID aliasId = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf()))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(personService).removeAlias(personId, aliasId);
|
verify(personService).removeAlias(personId, aliasId);
|
||||||
@@ -541,14 +540,14 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -557,7 +556,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import static org.mockito.Mockito.doNothing;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(RelationshipController.class)
|
@WebMvcTest(RelationshipController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -68,7 +67,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -77,14 +76,14 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf())
|
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"familyMember\":true}"))
|
.content("{\"familyMember\":true}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -126,7 +125,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -142,7 +141,7 @@ class RelationshipControllerTest {
|
|||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, null, null);
|
||||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -155,7 +154,7 @@ class RelationshipControllerTest {
|
|||||||
UUID relId = UUID.randomUUID();
|
UUID relId = UUID.randomUUID();
|
||||||
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import static org.mockito.Mockito.doThrow;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(TagController.class)
|
@WebMvcTest(TagController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -62,7 +61,7 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -71,7 +70,7 @@ class TagControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -83,7 +82,7 @@ class TagControllerTest {
|
|||||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
||||||
when(tagService.update(any(), any())).thenReturn(tag);
|
when(tagService.update(any(), any())).thenReturn(tag);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -117,7 +116,7 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mergeTag_returns401_whenUnauthenticated() throws Exception {
|
void mergeTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -126,7 +125,7 @@ class TagControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -135,7 +134,7 @@ class TagControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN_TAG")
|
@WithMockUser(authorities = "ADMIN_TAG")
|
||||||
void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
|
void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -147,7 +146,7 @@ class TagControllerTest {
|
|||||||
when(tagService.mergeTags(any(), any()))
|
when(tagService.mergeTags(any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -160,7 +159,7 @@ class TagControllerTest {
|
|||||||
Tag target = Tag.builder().id(targetId).name("Target").build();
|
Tag target = Tag.builder().id(targetId).name("Target").build();
|
||||||
when(tagService.mergeTags(any(), any())).thenReturn(target);
|
when(tagService.mergeTags(any(), any())).thenReturn(target);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + targetId + "\"}"))
|
.content("{\"targetId\": \"" + targetId + "\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -172,21 +171,21 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
|
void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
|
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN_TAG")
|
@WithMockUser(authorities = "ADMIN_TAG")
|
||||||
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
|
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,21 +193,21 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN_TAG")
|
@WithMockUser(authorities = "ADMIN_TAG")
|
||||||
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(AuthController.class)
|
@WebMvcTest(AuthController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -118,7 +117,7 @@ class AuthControllerTest {
|
|||||||
req.setFirstName("Max");
|
req.setFirstName("Max");
|
||||||
req.setLastName("Muster");
|
req.setLastName("Muster");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
mockMvc.perform(post("/api/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -135,7 +134,7 @@ class AuthControllerTest {
|
|||||||
req.setEmail("dupe@test.com");
|
req.setEmail("dupe@test.com");
|
||||||
req.setPassword("password123");
|
req.setPassword("password123");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
mockMvc.perform(post("/api/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
@@ -151,7 +150,7 @@ class AuthControllerTest {
|
|||||||
req.setEmail("new@test.com");
|
req.setEmail("new@test.com");
|
||||||
req.setPassword("abc");
|
req.setPassword("abc");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
mockMvc.perform(post("/api/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -167,7 +166,7 @@ class AuthControllerTest {
|
|||||||
req.setEmail("new@test.com");
|
req.setEmail("new@test.com");
|
||||||
req.setPassword("password123");
|
req.setPassword("password123");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
mockMvc.perform(post("/api/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -184,7 +183,7 @@ class AuthControllerTest {
|
|||||||
req.setPassword("password123");
|
req.setPassword("password123");
|
||||||
|
|
||||||
// No WithMockUser — must still succeed (no auth challenge)
|
// No WithMockUser — must still succeed (no auth challenge)
|
||||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
mockMvc.perform(post("/api/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(InviteController.class)
|
@WebMvcTest(InviteController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -104,7 +103,7 @@ class InviteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/invites").with(csrf())
|
mockMvc.perform(post("/api/invites")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -113,7 +112,7 @@ class InviteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "user@test.com")
|
@WithMockUser(username = "user@test.com")
|
||||||
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/invites").with(csrf())
|
mockMvc.perform(post("/api/invites")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -143,7 +142,7 @@ class InviteControllerTest {
|
|||||||
req.setLabel("Für Familie");
|
req.setLabel("Für Familie");
|
||||||
req.setMaxUses(1);
|
req.setMaxUses(1);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/invites").with(csrf())
|
mockMvc.perform(post("/api/invites")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -165,7 +164,7 @@ class InviteControllerTest {
|
|||||||
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
||||||
|
|
||||||
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
||||||
mockMvc.perform(post("/api/invites").with(csrf())
|
mockMvc.perform(post("/api/invites")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -179,14 +178,14 @@ class InviteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "user@test.com")
|
@WithMockUser(username = "user@test.com")
|
||||||
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +194,7 @@ class InviteControllerTest {
|
|||||||
void revokeInvite_returns204_whenSuccessful() throws Exception {
|
void revokeInvite_returns204_whenSuccessful() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/invites/" + id).with(csrf()))
|
mockMvc.perform(delete("/api/invites/" + id))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(inviteService).revokeInvite(id);
|
verify(inviteService).revokeInvite(id);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import org.springframework.mail.MailSendException;
|
|||||||
import org.springframework.mail.SimpleMailMessage;
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.raddatz.familienarchiv.auth.AuthService;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -37,10 +36,8 @@ class PasswordResetServiceTest {
|
|||||||
@Mock PasswordResetTokenRepository tokenRepository;
|
@Mock PasswordResetTokenRepository tokenRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
@Mock JavaMailSender mailSender;
|
@Mock JavaMailSender mailSender;
|
||||||
@Mock AuthService authService;
|
|
||||||
@InjectMocks PasswordResetService service;
|
@InjectMocks PasswordResetService service;
|
||||||
|
|
||||||
|
|
||||||
private AppUser makeUser(String email) {
|
private AppUser makeUser(String email) {
|
||||||
return AppUser.builder()
|
return AppUser.builder()
|
||||||
.id(UUID.randomUUID())
|
.id(UUID.randomUUID())
|
||||||
@@ -179,27 +176,6 @@ class PasswordResetServiceTest {
|
|||||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void resetPassword_revokes_all_sessions_after_password_reset() {
|
|
||||||
AppUser user = makeUser("user@example.com");
|
|
||||||
PasswordResetToken token = PasswordResetToken.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.token("validtoken123")
|
|
||||||
.user(user)
|
|
||||||
.expiresAt(LocalDateTime.now().plusHours(1))
|
|
||||||
.used(false)
|
|
||||||
.build();
|
|
||||||
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
|
||||||
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
|
||||||
|
|
||||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
|
||||||
req.setToken("validtoken123");
|
|
||||||
req.setNewPassword("newpass");
|
|
||||||
service.resetPassword(req);
|
|
||||||
|
|
||||||
verify(authService).revokeAllSessions("user@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.user;
|
package org.raddatz.familienarchiv.user;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
|
||||||
import org.raddatz.familienarchiv.auth.AuthService;
|
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
@@ -12,7 +10,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
@@ -20,8 +17,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -29,7 +24,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(UserController.class)
|
@WebMvcTest(UserController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -38,8 +32,6 @@ class UserControllerTest {
|
|||||||
@Autowired MockMvc mockMvc;
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
@MockitoBean UserService userService;
|
@MockitoBean UserService userService;
|
||||||
@MockitoBean AuthService authService;
|
|
||||||
@MockitoBean AuditService auditService;
|
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
// ─── GET /api/users/me ────────────────────────────────────────────────────────
|
// ─── GET /api/users/me ────────────────────────────────────────────────────────
|
||||||
@@ -91,7 +83,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||||
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
|
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
|
||||||
mockMvc.perform(post("/api/users").with(csrf())
|
mockMvc.perform(post("/api/users")
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -100,7 +92,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||||
void createUser_returns400_whenEmailContainsColon() throws Exception {
|
void createUser_returns400_whenEmailContainsColon() throws Exception {
|
||||||
mockMvc.perform(post("/api/users").with(csrf())
|
mockMvc.perform(post("/api/users")
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -109,7 +101,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||||
void createUser_returns400_whenEmailIsBlank() throws Exception {
|
void createUser_returns400_whenEmailIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/users").with(csrf())
|
mockMvc.perform(post("/api/users")
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -120,7 +112,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "reader@example.com")
|
@WithMockUser(username = "reader@example.com")
|
||||||
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/users").with(csrf())
|
mockMvc.perform(post("/api/users")
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -129,7 +121,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "reader@example.com")
|
@WithMockUser(username = "reader@example.com")
|
||||||
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -138,7 +130,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "reader@example.com")
|
@WithMockUser(username = "reader@example.com")
|
||||||
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +138,7 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/users").with(csrf())
|
mockMvc.perform(post("/api/users")
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -154,7 +146,7 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -162,92 +154,7 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── POST /api/users/me/password (changePassword + session revocation) ────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "user@example.com")
|
|
||||||
void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception {
|
|
||||||
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build();
|
|
||||||
when(userService.findByEmail("user@example.com")).thenReturn(user);
|
|
||||||
when(authService.revokeOtherSessions(any(), any())).thenReturn(1);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
|
||||||
.andExpect(status().isNoContent());
|
|
||||||
|
|
||||||
verify(authService).revokeOtherSessions(any(), eq("user@example.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void changePassword_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "user@example.com")
|
|
||||||
void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users/me/password")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
|
||||||
.andExpect(status().isForbidden())
|
|
||||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /api/users/{id}/force-logout ────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
|
||||||
void forceLogout_returns200_and_revokes_target_sessions() throws Exception {
|
|
||||||
UUID targetId = UUID.randomUUID();
|
|
||||||
AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build();
|
|
||||||
AppUser target = AppUser.builder().id(targetId).email("target@example.com").build();
|
|
||||||
when(userService.findByEmail("admin@example.com")).thenReturn(actor);
|
|
||||||
when(userService.getById(targetId)).thenReturn(target);
|
|
||||||
when(authService.revokeAllSessions("target@example.com")).thenReturn(2);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void forceLogout_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void forceLogout_returns403_whenMissingPermission() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "ADMIN_USER")
|
|
||||||
void forceLogout_returns404_whenUserNotFound() throws Exception {
|
|
||||||
UUID targetId = UUID.randomUUID();
|
|
||||||
when(userService.getById(targetId)).thenThrow(
|
|
||||||
org.raddatz.familienarchiv.exception.DomainException.notFound(
|
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found"));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
|
||||||
.andExpect(status().isNotFound());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
|
||||||
void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout"))
|
|
||||||
.andExpect(status().isForbidden())
|
|
||||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)._
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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 1–2 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.
|
|
||||||
@@ -9,23 +9,18 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
|||||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||||
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
|
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
|
||||||
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
|
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
|
||||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF enabled: double-submit cookie pattern (CookieCsrfTokenRepository.withHttpOnlyFalse + CsrfTokenRequestAttributeHandler). Custom AccessDeniedHandler returns JSON {\"code\":\"CSRF_TOKEN_MISSING\"}.")
|
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.")
|
||||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation.")
|
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.")
|
||||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
|
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
|
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
|
||||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||||
Component(rateLimiter, "LoginRateLimiter", "@Component org.raddatz.familienarchiv.auth", "Dual Bucket4j/Caffeine in-memory rate limiting: per ip:email bucket and per ip bucket. checkAndConsume() throws TOO_MANY_LOGIN_ATTEMPTS (429) when either bucket is exhausted. invalidateOnSuccess() resets both buckets on successful login. Buckets expire after idle windowMinutes.")
|
|
||||||
Component(rateLimitProps, "RateLimitProperties", "@ConfigurationProperties(\"rate-limit.login\") org.raddatz.familienarchiv.auth", "Externalized config for login rate limiting: maxAttemptsPerIpEmail (default 10), maxAttemptsPerIp (default 20), windowMinutes (default 15). Bound from application.yaml rate-limit.login block.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
|
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
|
||||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie + X-XSRF-TOKEN header")
|
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie")
|
||||||
Rel(authCtrl, authSvc, "Validate creds + audit")
|
Rel(authCtrl, authSvc, "Validate creds + audit")
|
||||||
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
|
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
|
||||||
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
|
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
|
||||||
Rel(authSvc, rateLimiter, "checkAndConsume() / invalidateOnSuccess()")
|
|
||||||
Rel(authSvc, sessionRepo, "revokeOtherSessions() / revokeAllSessions()")
|
|
||||||
Rel(rateLimiter, rateLimitProps, "Reads config")
|
|
||||||
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
|
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
|
||||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
@startuml
|
@startuml
|
||||||
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
|
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
|
||||||
note over Browser, DB
|
note over Browser, DB
|
||||||
Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524).
|
Phase 1 of the auth rewrite (ADR-020 / #523).
|
||||||
Adds CSRF double-submit cookies, login rate limiting, and
|
Replaces the Basic-credentials-in-cookie model
|
||||||
session revocation on password change/reset.
|
with an opaque server-side session id (fa_session).
|
||||||
end note
|
end note
|
||||||
|
|
||||||
actor User
|
actor User
|
||||||
@@ -11,10 +11,9 @@ participant Browser
|
|||||||
participant "Caddy (TLS termination)" as Caddy
|
participant "Caddy (TLS termination)" as Caddy
|
||||||
participant "Frontend (SvelteKit)" as Frontend
|
participant "Frontend (SvelteKit)" as Frontend
|
||||||
participant "Backend (Spring Boot)" as Backend
|
participant "Backend (Spring Boot)" as Backend
|
||||||
participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter
|
|
||||||
participant "spring_session\n(PostgreSQL)" as DB
|
participant "spring_session\n(PostgreSQL)" as DB
|
||||||
|
|
||||||
== Login (with rate limiting + CSRF bootstrap) ==
|
== Login ==
|
||||||
User -> Browser: Enter email + password
|
User -> Browser: Enter email + password
|
||||||
Browser -> Caddy: HTTPS POST /?/login (form action)
|
Browser -> Caddy: HTTPS POST /?/login (form action)
|
||||||
note right of Caddy
|
note right of Caddy
|
||||||
@@ -31,46 +30,19 @@ note right of Backend
|
|||||||
→ request.getScheme() = "https"
|
→ request.getScheme() = "https"
|
||||||
→ Secure cookie flag set automatically.
|
→ Secure cookie flag set automatically.
|
||||||
end note
|
end note
|
||||||
Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip]
|
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||||
alt Rate limit exceeded
|
Backend -> DB: SELECT user WHERE email=?
|
||||||
RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS)
|
DB --> Backend: AppUser + groups + permissions
|
||||||
Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email})
|
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||||
Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"}
|
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||||
Frontend --> Browser: Show rate-limit error
|
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||||
else Under limit
|
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure
|
||||||
Backend -> DB: SELECT user WHERE email=?
|
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||||
DB --> Backend: AppUser + groups + permissions
|
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
|
||||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
|
||||||
Backend -> RateLimiter: invalidateOnSuccess(ip, email)
|
|
||||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
|
||||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=<token>;\n Path=/; SameSite=Strict; Secure
|
|
||||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
|
||||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
|
||||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
|
||||||
end
|
|
||||||
|
|
||||||
== Authenticated mutating request (CSRF double-submit) ==
|
== Authenticated request ==
|
||||||
note over Browser, Backend
|
|
||||||
handleFetch in hooks.client.ts reads the XSRF-TOKEN cookie
|
|
||||||
and injects X-XSRF-TOKEN header on every POST/PUT/PATCH/DELETE.
|
|
||||||
end note
|
|
||||||
Browser -> Caddy: HTTPS POST /api/...\nCookie: fa_session=<opaque>; XSRF-TOKEN=<token>\nX-XSRF-TOKEN: <token>
|
|
||||||
Caddy -> Backend: HTTP POST /api/...\n+ Cookie + X-XSRF-TOKEN
|
|
||||||
alt X-XSRF-TOKEN missing or mismatched
|
|
||||||
Backend --> Caddy: 403 Forbidden\n{"code":"CSRF_TOKEN_MISSING"}
|
|
||||||
Caddy --> Browser: HTTPS 403
|
|
||||||
else CSRF valid
|
|
||||||
Backend -> DB: SELECT * FROM spring_session WHERE SESSION_ID = ?
|
|
||||||
DB --> Backend: session row
|
|
||||||
Backend -> Backend: Process request
|
|
||||||
Backend --> Caddy: 2xx response + refreshed XSRF-TOKEN cookie
|
|
||||||
Caddy --> Browser: HTTPS 2xx
|
|
||||||
end
|
|
||||||
|
|
||||||
== Authenticated read request ==
|
|
||||||
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
||||||
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
|
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
|
||||||
Frontend -> Frontend: hooks.server.ts reads fa_session
|
Frontend -> Frontend: hooks.server.ts reads fa_session
|
||||||
@@ -89,28 +61,6 @@ else Session expired (idle > 8h) or unknown
|
|||||||
Caddy --> Browser: HTTPS 302
|
Caddy --> Browser: HTTPS 302
|
||||||
end
|
end
|
||||||
|
|
||||||
== Password change (revoke other sessions) ==
|
|
||||||
Browser -> Backend: POST /api/users/me/password\n{currentPassword, newPassword}\n+ X-XSRF-TOKEN
|
|
||||||
Backend -> Backend: Verify currentPassword
|
|
||||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
|
||||||
Backend -> DB: DELETE spring_session WHERE principal = ?\n AND session_id != <current>
|
|
||||||
note right of Backend
|
|
||||||
revokeOtherSessions: caller stays logged in,
|
|
||||||
all other devices are signed out.
|
|
||||||
end note
|
|
||||||
Backend --> Browser: 204 No Content
|
|
||||||
|
|
||||||
== Password reset (revoke all sessions) ==
|
|
||||||
Browser -> Backend: POST /api/auth/reset-password\n{token, newPassword}
|
|
||||||
Backend -> Backend: Verify reset token
|
|
||||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
|
||||||
Backend -> DB: DELETE spring_session WHERE principal = ?
|
|
||||||
note right of Backend
|
|
||||||
revokeAllSessions: unauthenticated caller has
|
|
||||||
no session to preserve — all sessions wiped.
|
|
||||||
end note
|
|
||||||
Backend --> Browser: 204 No Content
|
|
||||||
|
|
||||||
== Logout ==
|
== Logout ==
|
||||||
Browser -> Caddy: HTTPS POST /logout
|
Browser -> Caddy: HTTPS POST /logout
|
||||||
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>
|
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>
|
||||||
|
|||||||
@@ -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 . .
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
2878
frontend/package-lock.json
generated
2878
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user