Compare commits
87 Commits
fdb9ae31ae
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5239f515f | ||
|
|
f2bb58e294 | ||
|
|
2adb98895d | ||
|
|
6049dcadd3 | ||
|
|
7fe8842b57 | ||
|
|
f9340366d1 | ||
|
|
af84ffc379 | ||
|
|
23439e581a | ||
|
|
2c6b59d0c7 | ||
|
|
c0a7408ef4 | ||
|
|
9d283c4500 | ||
|
|
6832300a4b | ||
|
|
9c5267e1f0 | ||
|
|
4979ae1867 | ||
|
|
29ef82f7b4 | ||
|
|
f458c11a0d | ||
|
|
e615ba1bbf | ||
|
|
1bec7dd17e | ||
|
|
a0339a5526 | ||
|
|
65cae4a5e8 | ||
|
|
c8cc0646cb | ||
|
|
e8057fe517 | ||
|
|
378023c53d | ||
|
|
ff3e863032 | ||
|
|
8fc32f18ce | ||
|
|
0cd9ea915e | ||
|
|
f0e7f73ec1 | ||
|
|
567f9267e8 | ||
|
|
1dc5bf4377 | ||
|
|
31d3ec8367 | ||
|
|
d739f58bb5 | ||
|
|
18e675a5b2 | ||
|
|
a3fc838855 | ||
|
|
d5043053e0 | ||
|
|
c932dd19d9 | ||
|
|
c532ad21bf | ||
|
|
0e95bd9160 | ||
|
|
e312cce4e1 | ||
|
|
5587722800 | ||
|
|
0451b6630c | ||
|
|
f77fb79cd2 | ||
|
|
1247b51d9e | ||
|
|
7342c60952 | ||
|
|
328bd2c3b4 | ||
|
|
db87a214fd | ||
|
|
ad95b09046 | ||
|
|
1e95ca979b | ||
|
|
1cae9ac311 | ||
|
|
72bd2e11b4 | ||
|
|
69b3c663c0 | ||
|
|
f470a39ad2 | ||
|
|
e2f287d3d8 | ||
|
|
914e438793 | ||
|
|
6266c5f721 | ||
|
|
f564c30ae2 | ||
|
|
a5ce46359a | ||
|
|
b45953e567 | ||
|
|
36d1b9c038 | ||
|
|
56bcbcdd5c | ||
|
|
9b9bfde843 | ||
|
|
164a917d95 | ||
|
|
96c0aa592c | ||
|
|
d8520d9714 | ||
|
|
873d668653 | ||
|
|
4e257a7ca4 | ||
|
|
d0bb6729cd | ||
|
|
32ede3e3ce | ||
|
|
5da78e5e30 | ||
|
|
cb108faaf8 | ||
|
|
611b82ccde | ||
|
|
64d8f9d904 | ||
|
|
6f452a9a8b | ||
|
|
20fe5637c1 | ||
|
|
9bf8cf831d | ||
|
|
9f4a1141ef | ||
|
|
cb818f4bfa | ||
|
|
9c195ff5cb | ||
|
|
54d32c9163 | ||
|
|
0b5ab73963 | ||
|
|
956387471d | ||
|
|
78fd9e026e | ||
|
|
4d6fb06e02 | ||
|
|
8944f8bb44 | ||
|
|
1b178767ab | ||
|
|
7d10653c41 | ||
|
|
b7a03614bc | ||
|
|
49c5324352 |
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Unit & Component Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -29,6 +29,10 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Security audit (no dev deps)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
working-directory: frontend
|
||||
|
||||
- name: Compile Paraglide i18n
|
||||
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||
working-directory: frontend
|
||||
|
||||
@@ -252,20 +252,20 @@ jobs:
|
||||
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)
|
||||
[ -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)"
|
||||
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:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# 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'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# 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=\(\)'
|
||||
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; }
|
||||
echo "All smoke checks passed"
|
||||
|
||||
|
||||
@@ -181,28 +181,31 @@ jobs:
|
||||
|
||||
- name: Smoke test deployed environment
|
||||
# See nightly.yml — same three checks, against the prod vhost.
|
||||
# --resolve pins to the bridge gateway IP (the host), not 127.0.0.1
|
||||
# — see nightly.yml for the full network topology explanation.
|
||||
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||
# separate arguments; a quoted string would pass the flag and its value
|
||||
# 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: |
|
||||
set -e
|
||||
HOST="archiv.raddatz.cloud"
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; }
|
||||
RESOLVE="--resolve $HOST:443:$HOST_IP"
|
||||
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; }
|
||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||
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:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# 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'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# 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=\(\)'
|
||||
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; }
|
||||
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/
|
||||
├── audit/ Audit logging
|
||||
├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC)
|
||||
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
|
||||
├── config/ Infrastructure config (Minio, Async, Web)
|
||||
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
||||
├── 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)
|
||||
|
||||
**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`.
|
||||
**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).
|
||||
|
||||
### 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)
|
||||
|
||||
**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`.
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ if (!result.response.ok) {
|
||||
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 raw `fetch` — the client cannot handle it.
|
||||
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.
|
||||
|
||||
### Date handling
|
||||
|
||||
|
||||
@@ -97,7 +97,10 @@ public class MyEntity {
|
||||
|
||||
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
|
||||
- Write methods: `@Transactional`.
|
||||
- Read methods: no annotation (default non-transactional).
|
||||
- Read methods: no annotation (default non-transactional) — **except** when the method returns
|
||||
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.
|
||||
|
||||
## Error Handling
|
||||
|
||||
@@ -7,12 +7,10 @@ import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -26,28 +24,17 @@ public class AuthService {
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final UserService userService;
|
||||
private final AuditService auditService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
private final SessionRevocationPort sessionRevocationPort;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JdbcIndexedSessionRepository sessionRepository;
|
||||
|
||||
@Autowired(required = false)
|
||||
private LoginRateLimiter loginRateLimiter;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (loginRateLimiter != null) {
|
||||
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 {
|
||||
loginRateLimiter.checkAndConsume(ip, email);
|
||||
} catch (DomainException ex) {
|
||||
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
|
||||
"ip", ip,
|
||||
"email", email));
|
||||
throw ex;
|
||||
}
|
||||
try {
|
||||
Authentication auth = authenticationManager.authenticate(
|
||||
@@ -58,9 +45,7 @@ public class AuthService {
|
||||
"userId", user.getId().toString(),
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
if (loginRateLimiter != null) {
|
||||
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||
}
|
||||
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||
return new LoginResult(user, auth);
|
||||
} catch (AuthenticationException ex) {
|
||||
// Audit login failure — intentionally does NOT log the attempted password.
|
||||
@@ -75,20 +60,11 @@ public class AuthService {
|
||||
}
|
||||
|
||||
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;
|
||||
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
|
||||
}
|
||||
|
||||
public int revokeAllSessions(String principalName) {
|
||||
var sessions = sessionRepository.findByPrincipalName(principalName);
|
||||
sessions.keySet().forEach(sessionRepository::deleteById);
|
||||
return sessions.size();
|
||||
return sessionRevocationPort.revokeAllSessions(principalName);
|
||||
}
|
||||
|
||||
public void logout(String email, String ip, String ua) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
|
||||
|
||||
private final JdbcIndexedSessionRepository sessionRepository;
|
||||
|
||||
@Override
|
||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||
int count = 0;
|
||||
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
|
||||
if (!id.equals(currentSessionId)) {
|
||||
sessionRepository.deleteById(id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int revokeAllSessions(String principalName) {
|
||||
var sessions = sessionRepository.findByPrincipalName(principalName);
|
||||
sessions.keySet().forEach(sessionRepository::deleteById);
|
||||
return sessions.size();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -36,17 +37,27 @@ public class LoginRateLimiter {
|
||||
.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) {
|
||||
boolean ipEmailOk = byIpEmail.get(ip + ":" + email).tryConsume(1);
|
||||
boolean ipOk = byIp.get(ip).tryConsume(1);
|
||||
if (!ipEmailOk || !ipOk) {
|
||||
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);
|
||||
"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);
|
||||
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
|
||||
byIp.invalidate(ip);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
|
||||
|
||||
@Override
|
||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int revokeAllSessions(String principalName) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
|
||||
@Configuration
|
||||
class SessionRevocationConfig {
|
||||
|
||||
@Bean
|
||||
SessionRevocationPort sessionRevocationPort(
|
||||
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
|
||||
if (sessionRepository != null) {
|
||||
return new JdbcSessionRevocationAdapter(sessionRepository);
|
||||
}
|
||||
return new NoOpSessionRevocationAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
public interface SessionRevocationPort {
|
||||
int revokeOtherSessions(String currentSessionId, String principalName);
|
||||
int revokeAllSessions(String principalName);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
|
||||
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.setHeader("Retry-After", "60");
|
||||
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.document;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.BatchSize;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
@@ -21,6 +22,15 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@NamedEntityGraph(name = "Document.full", attributeNodes = {
|
||||
@NamedAttributeNode("sender"),
|
||||
@NamedAttributeNode("receivers"),
|
||||
@NamedAttributeNode("tags")
|
||||
})
|
||||
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
||||
@NamedAttributeNode("sender"),
|
||||
@NamedAttributeNode("tags")
|
||||
})
|
||||
@Entity
|
||||
@Table(name = "documents")
|
||||
@Data // Lombok: Generiert Getter, Setter, ToString, etc.
|
||||
@@ -118,24 +128,27 @@ public class Document {
|
||||
@Builder.Default
|
||||
private ScriptType scriptType = ScriptType.UNKNOWN;
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||
@BatchSize(size = 50)
|
||||
@Builder.Default
|
||||
private Set<Person> receivers = new HashSet<>();
|
||||
|
||||
@ManyToOne
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "sender_id")
|
||||
private Person sender;
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
@BatchSize(size = 50)
|
||||
@Builder.Default
|
||||
private Set<Tag> tags = new HashSet<>();
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@ElementCollection(fetch = FetchType.LAZY)
|
||||
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
|
||||
@Column(name = "label")
|
||||
@Enumerated(EnumType.STRING)
|
||||
@BatchSize(size = 50)
|
||||
@Builder.Default
|
||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
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.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
@@ -23,6 +25,18 @@ import java.util.UUID;
|
||||
@Repository
|
||||
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
|
||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||
@@ -30,17 +44,21 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
||||
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
||||
|
||||
// Findet alle Dokumente mit einem bestimmten Status
|
||||
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
||||
// Callers access only status/id scalar fields — no graph needed.
|
||||
List<Document> findByStatus(DocumentStatus status);
|
||||
|
||||
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
||||
boolean existsByOriginalFilename(String originalFilename);
|
||||
|
||||
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||
@EntityGraph("Document.full")
|
||||
List<Document> findBySenderId(UUID senderId);
|
||||
|
||||
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||
@EntityGraph("Document.full")
|
||||
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);
|
||||
|
||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||
@@ -55,12 +73,15 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
long countByMetadataCompleteFalse();
|
||||
|
||||
// No production callers — only used if a future export path iterates the full list; no graph needed.
|
||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||
|
||||
// Callers map to IncompleteDocumentDTO using only scalar fields (id, title, createdAt) — no graph needed.
|
||||
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
|
||||
|
||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||
|
||||
@EntityGraph("Document.full")
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
@@ -75,6 +96,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@Param("to") LocalDate to,
|
||||
Sort sort);
|
||||
|
||||
@EntityGraph("Document.full")
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"LEFT JOIN d.receivers r " +
|
||||
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
||||
|
||||
@@ -447,6 +447,7 @@ public class DocumentService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||
@@ -635,7 +636,7 @@ public class DocumentService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
|
||||
@Transactional(readOnly = true)
|
||||
public List<Document> getRecentActivity(int size) {
|
||||
return documentRepository.findAll(
|
||||
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
|
||||
@@ -843,6 +844,7 @@ public class DocumentService {
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Document getDocumentById(UUID id) {
|
||||
Document doc = documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
|
||||
@@ -10,11 +10,21 @@ public class DomainException extends RuntimeException {
|
||||
|
||||
private final ErrorCode code;
|
||||
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) {
|
||||
super(developerMessage);
|
||||
this.code = code;
|
||||
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() {
|
||||
@@ -25,6 +35,11 @@ public class DomainException extends RuntimeException {
|
||||
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 ---
|
||||
|
||||
public static DomainException notFound(ErrorCode code, String message) {
|
||||
@@ -59,4 +74,8 @@ public class DomainException extends RuntimeException {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DomainException.class)
|
||||
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
|
||||
return ResponseEntity
|
||||
.status(ex.getStatus())
|
||||
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
||||
var builder = ResponseEntity.status(ex.getStatus());
|
||||
if (ex.getRetryAfterSeconds() != null) {
|
||||
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()));
|
||||
}
|
||||
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
@@ -31,6 +33,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -53,9 +56,33 @@ public class MassImportService {
|
||||
|
||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||
|
||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
||||
public record SkippedFile(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||
) {}
|
||||
|
||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
public record ImportStatus(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
|
||||
@JsonIgnore String message,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
|
||||
LocalDateTime startedAt
|
||||
) {
|
||||
// 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. */
|
||||
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() {
|
||||
return currentStatus;
|
||||
@@ -117,22 +144,22 @@ public class MassImportService {
|
||||
if (currentStatus.state() == State.RUNNING) {
|
||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||
}
|
||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
|
||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||
try {
|
||||
File spreadsheet = findSpreadsheetFile();
|
||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
||||
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
||||
processed, currentStatus.startedAt());
|
||||
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||
} catch (NoSpreadsheetException e) {
|
||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||
} catch (Exception e) {
|
||||
log.error("Massenimport fehlgeschlagen", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,8 +281,10 @@ public class MassImportService {
|
||||
|
||||
// --- Import logic (works on neutral List<String> rows) ---
|
||||
|
||||
private int processRows(List<List<String>> rows) {
|
||||
int count = 0;
|
||||
private ProcessResult processRows(List<List<String>> rows) {
|
||||
int processed = 0;
|
||||
List<SkippedFile> skippedFiles = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i < rows.size(); i++) { // skip header row
|
||||
List<String> cells = rows.get(i);
|
||||
String index = getCell(cells, colIndex);
|
||||
@@ -266,18 +295,58 @@ public class MassImportService {
|
||||
if (fileOnDisk.isEmpty()) {
|
||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
||||
}
|
||||
importSingleDocument(cells, fileOnDisk, filename, index);
|
||||
count++;
|
||||
|
||||
if (fileOnDisk.isPresent()) {
|
||||
try {
|
||||
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
||||
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
|
||||
continue;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
||||
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||
if (skipReason.isPresent()) {
|
||||
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
|
||||
} else {
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
return new ProcessResult(processed, skippedFiles);
|
||||
}
|
||||
|
||||
// package-private: Mockito spy in tests can override to inject IOException
|
||||
InputStream openFileStream(File file) throws IOException {
|
||||
return new FileInputStream(file);
|
||||
}
|
||||
|
||||
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||
try (InputStream is = openFileStream(file)) {
|
||||
byte[] header = is.readNBytes(4);
|
||||
return header.length == 4
|
||||
&& header[0] == 0x25 // %
|
||||
&& header[1] == 0x50 // P
|
||||
&& header[2] == 0x44 // D
|
||||
&& header[3] == 0x46; // F
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a single document row.
|
||||
*
|
||||
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
|
||||
*/
|
||||
@Transactional
|
||||
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
||||
return;
|
||||
return Optional.of("ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
String archiveBox = getCell(cells, colBox);
|
||||
@@ -313,7 +382,7 @@ public class MassImportService {
|
||||
status = DocumentStatus.UPLOADED;
|
||||
} catch (Exception e) {
|
||||
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
||||
return;
|
||||
return Optional.of("S3_UPLOAD_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +424,7 @@ public class MassImportService {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
@@ -9,6 +10,9 @@ import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||
@Entity
|
||||
@Table(name = "persons")
|
||||
@Data
|
||||
|
||||
@@ -32,6 +32,11 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
|
||||
// cannot be injected here. A static instance is safe because the response
|
||||
// only serializes fixed String keys — no custom naming strategy or module needed.
|
||||
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final Environment environment;
|
||||
|
||||
@@ -88,7 +93,7 @@ public class SecurityConfig {
|
||||
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
||||
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
||||
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
||||
// See ADR-020 and issue #524 for the full security rationale.
|
||||
// See ADR-022 and issue #524 for the full security rationale.
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||
@@ -127,7 +132,7 @@ public class SecurityConfig {
|
||||
ErrorCode code = (e instanceof CsrfException)
|
||||
? ErrorCode.CSRF_TOKEN_MISSING
|
||||
: ErrorCode.FORBIDDEN;
|
||||
res.getWriter().write(new ObjectMapper().writeValueAsString(Map.of("code", code.name())));
|
||||
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||
}));
|
||||
|
||||
return http.build();
|
||||
|
||||
@@ -2,10 +2,13 @@ package org.raddatz.familienarchiv.tag;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||
@Entity
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
|
||||
@@ -31,5 +31,6 @@ public class InviteListItemDTO {
|
||||
private String status;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String shareableUrl;
|
||||
}
|
||||
|
||||
@@ -30,15 +30,15 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/")
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private UserService userService;
|
||||
private AuthService authService;
|
||||
private AuditService auditService;
|
||||
private final UserService userService;
|
||||
private final AuthService authService;
|
||||
private final AuditService auditService;
|
||||
|
||||
@GetMapping("users/me")
|
||||
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
||||
|
||||
@@ -15,17 +15,10 @@ import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
@@ -37,19 +30,13 @@ class AuthServiceTest {
|
||||
@Mock AuthenticationManager authenticationManager;
|
||||
@Mock UserService userService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock JdbcIndexedSessionRepository sessionRepository;
|
||||
@Mock LoginRateLimiter loginRateLimiter;
|
||||
@Mock SessionRevocationPort sessionRevocationPort;
|
||||
@InjectMocks AuthService authService;
|
||||
|
||||
private static final String IP = "127.0.0.1";
|
||||
private static final String UA = "Mozilla/5.0 (Test)";
|
||||
|
||||
@BeforeEach
|
||||
void injectOptionalFields() {
|
||||
ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository);
|
||||
ReflectionTestUtils.setField(authService, "loginRateLimiter", loginRateLimiter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_returns_user_on_valid_credentials() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
@@ -159,7 +146,6 @@ class AuthServiceTest {
|
||||
|
||||
@Test
|
||||
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||
|
||||
@@ -183,35 +169,23 @@ class AuthServiceTest {
|
||||
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
|
||||
}
|
||||
|
||||
@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");
|
||||
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(sessionRepository, never()).deleteById("session-keep");
|
||||
verify(sessionRepository).deleteById("session-del-1");
|
||||
verify(sessionRepository).deleteById("session-del-2");
|
||||
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
|
||||
}
|
||||
|
||||
@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");
|
||||
void revokeAllSessions_delegates_to_port() {
|
||||
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
|
||||
|
||||
int count = authService.revokeAllSessions("user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
verify(sessionRepository).deleteById("session-1");
|
||||
verify(sessionRepository).deleteById("session-2");
|
||||
assertThat(count).isEqualTo(3);
|
||||
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,21 @@ class AuthSessionIntegrationTest {
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// ─── Task: CSRF rejection at integration layer ────────────────────────────
|
||||
|
||||
@Test
|
||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
|
||||
ResponseEntity<String> response = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>("{}", headers), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JdbcSessionRevocationAdapterTest {
|
||||
|
||||
@Mock JdbcIndexedSessionRepository sessionRepository;
|
||||
@InjectMocks JdbcSessionRevocationAdapter adapter;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
||||
var sessions = new HashMap<String, Object>();
|
||||
sessions.put("session-keep", null);
|
||||
sessions.put("session-del-1", null);
|
||||
sessions.put("session-del-2", null);
|
||||
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||
|
||||
int count = adapter.revokeOtherSessions("session-keep", "user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
verify(sessionRepository, never()).deleteById("session-keep");
|
||||
verify(sessionRepository).deleteById("session-del-1");
|
||||
verify(sessionRepository).deleteById("session-del-2");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
void revokeAllSessions_deletes_all_sessions_for_principal() {
|
||||
var sessions = new HashMap<String, Object>();
|
||||
sessions.put("session-1", null);
|
||||
sessions.put("session-2", null);
|
||||
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||
|
||||
int count = adapter.revokeAllSessions("user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
verify(sessionRepository).deleteById("session-1");
|
||||
verify(sessionRepository).deleteById("session-2");
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ 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.assertThatThrownBy;
|
||||
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 {
|
||||
|
||||
@@ -37,10 +38,22 @@ class LoginRateLimiterTest {
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
|
||||
.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++) {
|
||||
@@ -61,7 +74,75 @@ class LoginRateLimiterTest {
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
|
||||
.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,6 +45,15 @@ class RateLimitInterceptorTest {
|
||||
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
|
||||
void different_ips_have_independent_limits() throws Exception {
|
||||
HttpServletRequest other = mock(HttpServletRequest.class);
|
||||
|
||||
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
@@ -1338,4 +1339,16 @@ class DocumentControllerTest {
|
||||
DocumentStatus.REVIEWED,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||
}
|
||||
|
||||
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/documents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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,5 +1,9 @@
|
||||
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.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
@@ -21,6 +25,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -55,6 +60,12 @@ class DocumentRepositoryTest {
|
||||
@Autowired
|
||||
private TranscriptionBlockRepository transcriptionBlockRepository;
|
||||
|
||||
@Autowired
|
||||
private EntityManagerFactory entityManagerFactory;
|
||||
|
||||
@Autowired
|
||||
private EntityManager entityManager;
|
||||
|
||||
// ─── save and findById ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -490,6 +501,117 @@ class DocumentRepositoryTest {
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
private Document uploaded(String title) {
|
||||
|
||||
@@ -135,7 +135,7 @@ class MassImportServiceTest {
|
||||
@Test
|
||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||
|
||||
assertThatThrownBy(() -> service.runImportAsync())
|
||||
@@ -154,9 +154,76 @@ class MassImportServiceTest {
|
||||
.build();
|
||||
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
||||
|
||||
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||
|
||||
verify(documentService, never()).save(any());
|
||||
assertThat(result).isPresent().contains("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<String> result = service.importSingleDocument(
|
||||
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
|
||||
|
||||
assertThat(result).isPresent().contains("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", "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("ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — create new document (metadata only) ───────────
|
||||
@@ -208,7 +275,7 @@ class MassImportServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
||||
void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
||||
Path tempFile = tempDir.resolve("fail.pdf");
|
||||
Files.write(tempFile, "data".getBytes());
|
||||
|
||||
@@ -216,10 +283,11 @@ class MassImportServiceTest {
|
||||
doThrow(new RuntimeException("S3 error"))
|
||||
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
|
||||
service.importSingleDocument(
|
||||
Optional<String> result = service.importSingleDocument(
|
||||
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
||||
|
||||
verify(documentService, never()).save(any());
|
||||
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — sender handling ───────────────────────────────
|
||||
@@ -325,8 +393,8 @@ class MassImportServiceTest {
|
||||
@Test
|
||||
void processRows_returnsZero_whenOnlyHeaderRow() {
|
||||
List<List<String>> rows = List.of(List.of("header", "col1"));
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result).isEqualTo(0);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result.processed()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -335,8 +403,8 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("") // blank index
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result).isEqualTo(0);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result.processed()).isEqualTo(0);
|
||||
verify(documentService, never()).findByOriginalFilename(any());
|
||||
}
|
||||
|
||||
@@ -349,9 +417,9 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("doc001") // no dot → appends ".pdf"
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
|
||||
assertThat(result).isEqualTo(1);
|
||||
assertThat(result.processed()).isEqualTo(1);
|
||||
verify(documentService).findByOriginalFilename("doc001.pdf");
|
||||
}
|
||||
|
||||
@@ -364,9 +432,9 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("doc002.pdf") // has dot → used as-is
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
|
||||
assertThat(result).isEqualTo(1);
|
||||
assertThat(result.processed()).isEqualTo(1);
|
||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
||||
}
|
||||
|
||||
@@ -525,6 +593,67 @@ class MassImportServiceTest {
|
||||
assertThat(result).isEqualTo("hello");
|
||||
}
|
||||
|
||||
// ─── PDF magic byte validation regression ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
|
||||
setupOneValidOneFakeImport(tempDir);
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
|
||||
setupOneValidOneFakeImport(tempDir);
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
|
||||
setupOneValidOneFakeImport(tempDir);
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::filename)
|
||||
.contains("fake.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
|
||||
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
|
||||
buildMinimalImportXlsx(tempDir, "tiny.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
|
||||
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
|
||||
MassImportService spyService = spy(service);
|
||||
doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
|
||||
|
||||
spyService.runImportAsync();
|
||||
|
||||
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
|
||||
assertThat(spyService.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::reason)
|
||||
.containsExactly("FILE_READ_ERROR");
|
||||
}
|
||||
|
||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||
|
||||
// Security regression — do not remove.
|
||||
@@ -621,4 +750,28 @@ class MassImportServiceTest {
|
||||
}
|
||||
return destination.toFile();
|
||||
}
|
||||
|
||||
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
|
||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
||||
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
||||
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
||||
Path xlsx = dir.resolve("import.xlsx");
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
|
||||
sheet.createRow(0).createCell(0).setCellValue("Index");
|
||||
for (int i = 0; i < filenames.length; i++) {
|
||||
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class AdminControllerTest {
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
@@ -61,7 +61,7 @@ class AdminControllerTest {
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
|
||||
@@ -20,6 +20,8 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -178,7 +180,7 @@ class UserControllerTest {
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
org.mockito.Mockito.verify(authService).revokeOtherSessions(any(), org.mockito.ArgumentMatchers.eq("user@example.com"));
|
||||
verify(authService).revokeOtherSessions(any(), eq("user@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -189,6 +191,16 @@ class UserControllerTest {
|
||||
.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
|
||||
@@ -230,4 +242,12 @@ class UserControllerTest {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,9 @@ services:
|
||||
# SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN}
|
||||
API_INTERNAL_URL: http://backend:8080
|
||||
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:
|
||||
- archiv-net
|
||||
healthcheck:
|
||||
|
||||
@@ -228,6 +228,9 @@ services:
|
||||
API_INTERNAL_URL: http://backend:8080
|
||||
# Vite dev proxy forwards /api from browser to the backend container
|
||||
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:
|
||||
- "${PORT_FRONTEND}:5173"
|
||||
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 |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
@@ -117,7 +117,7 @@ Controllers never call repositories directly. Services never reach into another
|
||||
### Permission system
|
||||
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
|
||||
|
||||
Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
|
||||
Sessions use a Spring Session JDBC-backed cookie (`fa_session`, `httpOnly`, `SameSite=strict`, maxAge=86400 s). CSRF protection uses the double-submit cookie pattern: Spring Security sets an `XSRF-TOKEN` cookie (readable by JS); SvelteKit's `handleFetch` injects the value as `X-XSRF-TOKEN` on every mutating request; a missing or mismatched token returns `403 CSRF_TOKEN_MISSING`. See [ADR-022](adr/022-csrf-session-revocation-rate-limiting.md) and [docs/security-guide.md](security-guide.md) for the full security reference.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ _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).
|
||||
|
||||
**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.
|
||||
_See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
|
||||
|
||||
|
||||
115
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
115
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 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.
|
||||
110
docs/adr/022-eager-to-lazy-fetch-strategy.md
Normal file
110
docs/adr/022-eager-to-lazy-fetch-strategy.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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,18 +9,23 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
|
||||
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.")
|
||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.")
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF enabled: double-submit cookie pattern (CookieCsrfTokenRepository.withHttpOnlyFalse + CsrfTokenRequestAttributeHandler). Custom AccessDeniedHandler returns JSON {\"code\":\"CSRF_TOKEN_MISSING\"}.")
|
||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||
Component(rateLimiter, "LoginRateLimiter", "@Component org.raddatz.familienarchiv.auth", "Dual Bucket4j/Caffeine in-memory rate limiting: per ip:email bucket and per ip bucket. checkAndConsume() throws TOO_MANY_LOGIN_ATTEMPTS (429) when either bucket is exhausted. invalidateOnSuccess() resets both buckets on successful login. Buckets expire after idle windowMinutes.")
|
||||
Component(rateLimitProps, "RateLimitProperties", "@ConfigurationProperties(\"rate-limit.login\") org.raddatz.familienarchiv.auth", "Externalized config for login rate limiting: maxAttemptsPerIpEmail (default 10), maxAttemptsPerIp (default 20), windowMinutes (default 15). Bound from application.yaml rate-limit.login block.")
|
||||
}
|
||||
|
||||
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
|
||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie")
|
||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie + X-XSRF-TOKEN header")
|
||||
Rel(authCtrl, authSvc, "Validate creds + audit")
|
||||
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
|
||||
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
|
||||
Rel(authSvc, rateLimiter, "checkAndConsume() / invalidateOnSuccess()")
|
||||
Rel(authSvc, sessionRepo, "revokeOtherSessions() / revokeAllSessions()")
|
||||
Rel(rateLimiter, rateLimitProps, "Reads config")
|
||||
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@startuml
|
||||
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
|
||||
note over Browser, DB
|
||||
Phase 1 of the auth rewrite (ADR-020 / #523).
|
||||
Replaces the Basic-credentials-in-cookie model
|
||||
with an opaque server-side session id (fa_session).
|
||||
Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524).
|
||||
Adds CSRF double-submit cookies, login rate limiting, and
|
||||
session revocation on password change/reset.
|
||||
end note
|
||||
|
||||
actor User
|
||||
@@ -11,9 +11,10 @@ participant Browser
|
||||
participant "Caddy (TLS termination)" as Caddy
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter
|
||||
participant "spring_session\n(PostgreSQL)" as DB
|
||||
|
||||
== Login ==
|
||||
== Login (with rate limiting + CSRF bootstrap) ==
|
||||
User -> Browser: Enter email + password
|
||||
Browser -> Caddy: HTTPS POST /?/login (form action)
|
||||
note right of Caddy
|
||||
@@ -30,19 +31,46 @@ note right of Backend
|
||||
→ request.getScheme() = "https"
|
||||
→ Secure cookie flag set automatically.
|
||||
end note
|
||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure
|
||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip]
|
||||
alt Rate limit exceeded
|
||||
RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS)
|
||||
Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email})
|
||||
Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"}
|
||||
Frontend --> Browser: Show rate-limit error
|
||||
else Under limit
|
||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||
Backend -> RateLimiter: invalidateOnSuccess(ip, email)
|
||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=<token>;\n Path=/; SameSite=Strict; Secure
|
||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
end
|
||||
|
||||
== Authenticated request ==
|
||||
== Authenticated mutating request (CSRF double-submit) ==
|
||||
note over Browser, Backend
|
||||
handleFetch in hooks.client.ts reads the XSRF-TOKEN cookie
|
||||
and injects X-XSRF-TOKEN header on every POST/PUT/PATCH/DELETE.
|
||||
end note
|
||||
Browser -> Caddy: HTTPS POST /api/...\nCookie: fa_session=<opaque>; XSRF-TOKEN=<token>\nX-XSRF-TOKEN: <token>
|
||||
Caddy -> Backend: HTTP POST /api/...\n+ Cookie + X-XSRF-TOKEN
|
||||
alt X-XSRF-TOKEN missing or mismatched
|
||||
Backend --> Caddy: 403 Forbidden\n{"code":"CSRF_TOKEN_MISSING"}
|
||||
Caddy --> Browser: HTTPS 403
|
||||
else CSRF valid
|
||||
Backend -> DB: SELECT * FROM spring_session WHERE SESSION_ID = ?
|
||||
DB --> Backend: session row
|
||||
Backend -> Backend: Process request
|
||||
Backend --> Caddy: 2xx response + refreshed XSRF-TOKEN cookie
|
||||
Caddy --> Browser: HTTPS 2xx
|
||||
end
|
||||
|
||||
== Authenticated read request ==
|
||||
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
||||
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
|
||||
Frontend -> Frontend: hooks.server.ts reads fa_session
|
||||
@@ -61,6 +89,28 @@ else Session expired (idle > 8h) or unknown
|
||||
Caddy --> Browser: HTTPS 302
|
||||
end
|
||||
|
||||
== Password change (revoke other sessions) ==
|
||||
Browser -> Backend: POST /api/users/me/password\n{currentPassword, newPassword}\n+ X-XSRF-TOKEN
|
||||
Backend -> Backend: Verify currentPassword
|
||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||
Backend -> DB: DELETE spring_session WHERE principal = ?\n AND session_id != <current>
|
||||
note right of Backend
|
||||
revokeOtherSessions: caller stays logged in,
|
||||
all other devices are signed out.
|
||||
end note
|
||||
Backend --> Browser: 204 No Content
|
||||
|
||||
== Password reset (revoke all sessions) ==
|
||||
Browser -> Backend: POST /api/auth/reset-password\n{token, newPassword}
|
||||
Backend -> Backend: Verify reset token
|
||||
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||
Backend -> DB: DELETE spring_session WHERE principal = ?
|
||||
note right of Backend
|
||||
revokeAllSessions: unauthenticated caller has
|
||||
no session to preserve — all sessions wiped.
|
||||
end note
|
||||
Backend --> Browser: 204 No Content
|
||||
|
||||
== Logout ==
|
||||
Browser -> Caddy: HTTPS POST /logout
|
||||
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>
|
||||
|
||||
@@ -58,3 +58,20 @@ test.describe('Language selector', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"nav_conversations": "Briefwechsel",
|
||||
"nav_admin": "Admin",
|
||||
"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_dark": "Zu dunklem Design wechseln",
|
||||
"btn_save": "Speichern",
|
||||
@@ -352,6 +354,11 @@
|
||||
"admin_system_import_status_running": "Import läuft…",
|
||||
"admin_system_import_status_done": "Import abgeschlossen",
|
||||
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
||||
"admin_system_import_skipped_label": "übersprungen",
|
||||
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
|
||||
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
|
||||
"import_reason_s3_upload_failed": "Upload-Fehler (S3)",
|
||||
"import_reason_already_exists": "Bereits importiert",
|
||||
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
||||
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||
@@ -389,6 +396,10 @@
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
"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_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
@@ -511,6 +522,7 @@
|
||||
"notification_filter_unread": "Ungelesen",
|
||||
"notification_filter_mention": "Erwähnung",
|
||||
"notification_filter_reply": "Antwort",
|
||||
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
||||
"notification_load_more": "Ältere laden",
|
||||
"notification_empty_history": "Keine Benachrichtigungen",
|
||||
@@ -650,6 +662,7 @@
|
||||
"transcription_block_segmentation_only": "Nur Segmentierung",
|
||||
"training_chip_kurrent": "Kurrent-Erkennung",
|
||||
"training_chip_segmentation": "Segmentierung",
|
||||
"transcribe_mark_for_training": "Für Training vormerken",
|
||||
"training_col_type": "Typ",
|
||||
"training_type_base": "Basis",
|
||||
"training_type_personalized": "Personalisiert",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"nav_conversations": "Letters",
|
||||
"nav_admin": "Admin",
|
||||
"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_dark": "Switch to dark mode",
|
||||
"btn_save": "Save",
|
||||
@@ -352,6 +354,11 @@
|
||||
"admin_system_import_status_running": "Import running…",
|
||||
"admin_system_import_status_done": "Import complete",
|
||||
"admin_system_import_status_done_label": "Documents processed",
|
||||
"admin_system_import_skipped_label": "skipped",
|
||||
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
|
||||
"import_reason_file_read_error": "File read error",
|
||||
"import_reason_s3_upload_failed": "Upload error (S3)",
|
||||
"import_reason_already_exists": "Already imported",
|
||||
"admin_system_import_status_failed": "Import failed",
|
||||
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||
@@ -389,6 +396,10 @@
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show 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_drop_hint": "Drop one or multiple files at once",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
@@ -511,6 +522,7 @@
|
||||
"notification_filter_unread": "Unread",
|
||||
"notification_filter_mention": "Mention",
|
||||
"notification_filter_reply": "Reply",
|
||||
"notification_error_generic": "Action failed. Please try again.",
|
||||
"notification_mark_all_read_aria": "Mark all notifications as read",
|
||||
"notification_load_more": "Load older",
|
||||
"notification_empty_history": "No notifications",
|
||||
@@ -650,6 +662,7 @@
|
||||
"transcription_block_segmentation_only": "Segmentation only",
|
||||
"training_chip_kurrent": "Kurrent recognition",
|
||||
"training_chip_segmentation": "Segmentation",
|
||||
"transcribe_mark_for_training": "Mark for OCR training",
|
||||
"training_col_type": "Type",
|
||||
"training_type_base": "Base",
|
||||
"training_type_personalized": "Personalized",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"nav_conversations": "Cartas",
|
||||
"nav_admin": "Admin",
|
||||
"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_dark": "Cambiar a modo oscuro",
|
||||
"btn_save": "Guardar",
|
||||
@@ -352,6 +354,11 @@
|
||||
"admin_system_import_status_running": "Importación en curso…",
|
||||
"admin_system_import_status_done": "Importación completada",
|
||||
"admin_system_import_status_done_label": "Documentos procesados",
|
||||
"admin_system_import_skipped_label": "omitidos",
|
||||
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
|
||||
"import_reason_file_read_error": "Error al leer el archivo",
|
||||
"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_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||
@@ -389,6 +396,10 @@
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar 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_drop_hint": "Uno o varios archivos a la vez",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
@@ -511,6 +522,7 @@
|
||||
"notification_filter_unread": "No leídas",
|
||||
"notification_filter_mention": "Mención",
|
||||
"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_load_more": "Cargar anteriores",
|
||||
"notification_empty_history": "Sin notificaciones",
|
||||
@@ -650,6 +662,7 @@
|
||||
"transcription_block_segmentation_only": "Solo segmentación",
|
||||
"training_chip_kurrent": "Reconocimiento Kurrent",
|
||||
"training_chip_segmentation": "Segmentación",
|
||||
"transcribe_mark_for_training": "Marcar para entrenamiento de OCR",
|
||||
"training_col_type": "Tipo",
|
||||
"training_type_base": "Base",
|
||||
"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__/",
|
||||
"test:unit": "vitest",
|
||||
"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:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
@@ -24,9 +24,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/sveltekit": "^10.53.1",
|
||||
"@tiptap/core": "3.22.5",
|
||||
"@tiptap/extension-mention": "3.22.5",
|
||||
"@tiptap/starter-kit": "3.22.5",
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/extension-mention": "3.23.4",
|
||||
"@tiptap/starter-kit": "3.23.4",
|
||||
"diff": "^8.0.3",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
@@ -37,9 +37,9 @@
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@inlang/paraglide-js": "^2.5.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.60.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -57,7 +57,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"playwright": "^1.56.1",
|
||||
"playwright": "^1.60.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
@@ -66,7 +66,7 @@
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.2.2",
|
||||
"vite": "^7.3.3",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vitest": "^4.0.10",
|
||||
"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
|
||||
index 5d0d37b..821d7b4 100644
|
||||
index c01e754..f1bb7be 100644
|
||||
--- a/node_modules/@vitest/browser-playwright/dist/index.js
|
||||
+++ b/node_modules/@vitest/browser-playwright/dist/index.js
|
||||
@@ -935,7 +935,7 @@ class PlaywrightBrowserProvider {
|
||||
@@ -936,7 +936,7 @@ class PlaywrightBrowserProvider {
|
||||
createMocker() {
|
||||
const idPreficates = new Map();
|
||||
const idPredicates = new Map();
|
||||
const sessionIds = new Map();
|
||||
- function createPredicate(sessionId, url) {
|
||||
+ function createPredicate(url) {
|
||||
const moduleUrl = new URL(url, "http://localhost");
|
||||
const predicate = (url) => {
|
||||
if (url.searchParams.has("_vitest_original")) {
|
||||
@@ -960,11 +960,7 @@ class PlaywrightBrowserProvider {
|
||||
@@ -961,11 +961,7 @@ class PlaywrightBrowserProvider {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
- const ids = sessionIds.get(sessionId) || [];
|
||||
- ids.push(moduleUrl.href);
|
||||
- sessionIds.set(sessionId, ids);
|
||||
- idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate);
|
||||
- idPredicates.set(predicateKey(sessionId, moduleUrl.href), predicate);
|
||||
- return predicate;
|
||||
+ return { url: moduleUrl.href, predicate };
|
||||
}
|
||||
function predicateKey(sessionId, url) {
|
||||
return `${sessionId}:${url}`;
|
||||
@@ -972,7 +968,23 @@ class PlaywrightBrowserProvider {
|
||||
@@ -973,7 +969,23 @@ class PlaywrightBrowserProvider {
|
||||
return {
|
||||
register: async (sessionId, module) => {
|
||||
const page = this.getPage(sessionId);
|
||||
@@ -37,19 +37,19 @@ index 5d0d37b..821d7b4 100644
|
||||
+ // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js')
|
||||
+ // leak an orphan route whose handler crashes after the next
|
||||
+ // session's birpc channel closes.
|
||||
+ const existingPredicate = idPreficates.get(key);
|
||||
+ const existingPredicate = idPredicates.get(key);
|
||||
+ if (existingPredicate) {
|
||||
+ await page.context().unroute(existingPredicate);
|
||||
+ }
|
||||
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
||||
+ ids.add(moduleUrl);
|
||||
+ sessionIds.set(sessionId, ids);
|
||||
+ idPreficates.set(key, predicate);
|
||||
+ idPredicates.set(key, predicate);
|
||||
+ await page.context().route(predicate, async (route) => {
|
||||
if (module.type === "manual") {
|
||||
const exports$1 = Object.keys(await module.resolve());
|
||||
const body = createManualModuleSource(module.url, exports$1);
|
||||
@@ -1033,8 +1045,8 @@ class PlaywrightBrowserProvider {
|
||||
@@ -1034,8 +1046,8 @@ class PlaywrightBrowserProvider {
|
||||
},
|
||||
clear: async (sessionId) => {
|
||||
const page = this.getPage(sessionId);
|
||||
@@ -58,5 +58,5 @@ index 5d0d37b..821d7b4 100644
|
||||
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
||||
+ const promises = [...ids].map((id) => {
|
||||
const key = predicateKey(sessionId, id);
|
||||
const predicate = idPreficates.get(key);
|
||||
const predicate = idPredicates.get(key);
|
||||
if (predicate) {
|
||||
@@ -111,7 +111,7 @@ const PUBLIC_API_PATHS = [
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
const isApi = request.url.startsWith(apiUrl) || new URL(request.url).pathname.startsWith('/api/');
|
||||
|
||||
if (!isApi) return fetch(request);
|
||||
|
||||
@@ -131,14 +131,13 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
if (sessionId) cookieParts.push(`fa_session=${sessionId}`);
|
||||
if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`);
|
||||
|
||||
if (cookieParts.length === 0 && !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> = {};
|
||||
if (cookieParts.length > 0) extraHeaders['Cookie'] = cookieParts.join('; ');
|
||||
const extraHeaders: Record<string, string> = { Cookie: cookieParts.join('; ') };
|
||||
if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken;
|
||||
|
||||
const modified = new Request(cloned, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/shared/utils/time';
|
||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
@@ -6,11 +7,13 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||
|
||||
interface Props {
|
||||
unread: NotificationItem[];
|
||||
onMarkRead: (n: NotificationItem) => void;
|
||||
onMarkAllRead: () => void;
|
||||
optimisticMarkRead: (id: string) => void;
|
||||
optimisticMarkAllRead: () => void;
|
||||
}
|
||||
|
||||
const { unread, onMarkRead, onMarkAllRead }: Props = $props();
|
||||
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
|
||||
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
function verb(type: NotificationItem['type'], actor: string): string {
|
||||
return type === 'REPLY'
|
||||
@@ -24,6 +27,9 @@ function href(n: NotificationItem): string {
|
||||
</script>
|
||||
|
||||
<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}
|
||||
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<svg
|
||||
@@ -66,14 +72,28 @@ function href(n: NotificationItem): string {
|
||||
{m.chronik_for_you_count({ count: unread.length })}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chronik-mark-all-read"
|
||||
onclick={onMarkAllRead}
|
||||
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
<form
|
||||
action="/aktivitaeten?/mark-all-read"
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
errorMessage = null;
|
||||
optimisticMarkAllRead();
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' || result.type === 'error') {
|
||||
errorMessage = m.notification_error_generic();
|
||||
await update({ reset: false, invalidateAll: false });
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
{m.chronik_mark_all_read()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
|
||||
<ul role="list" class="flex flex-col gap-2">
|
||||
@@ -89,7 +109,7 @@ function href(n: NotificationItem): string {
|
||||
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"
|
||||
>
|
||||
{n.type === 'MENTION' ? '@' : '\u21A9'}
|
||||
{n.type === 'MENTION' ? '@' : '↩'}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm leading-snug text-ink">
|
||||
@@ -100,25 +120,40 @@ function href(n: NotificationItem): string {
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chronik-fuerdich-dismiss"
|
||||
aria-label={m.chronik_mark_read_aria()}
|
||||
onclick={() => onMarkRead(n)}
|
||||
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"
|
||||
<form
|
||||
action="/aktivitaeten?/dismiss-notification"
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
errorMessage = null;
|
||||
optimisticMarkRead(n.id);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' || result.type === 'error') {
|
||||
errorMessage = m.notification_error_generic();
|
||||
await update({ reset: false, invalidateAll: false });
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
<input type="hidden" name="notificationId" value={n.id} />
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="chronik-fuerdich-dismiss"
|
||||
aria-label={m.chronik_mark_read_aria()}
|
||||
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"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
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>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -5,7 +5,36 @@ import { page, userEvent } from 'vitest/browser';
|
||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||
|
||||
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 {
|
||||
return {
|
||||
@@ -26,8 +55,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('renders inbox-zero state when there are no unread items', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
||||
expect(zero).not.toBeNull();
|
||||
@@ -37,8 +66,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('links to the archived mentions in the inbox-zero state', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
||||
expect(link).not.toBeNull();
|
||||
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('renders the count badge with correct total when unread exists', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
|
||||
});
|
||||
@@ -56,8 +85,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('count badge has aria-live=polite when unread exists', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' })],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
// Wait for render
|
||||
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
|
||||
@@ -69,8 +98,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
||||
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
|
||||
@@ -80,38 +109,38 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('renders the "Alle gelesen" button when unread exists', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' })],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
|
||||
const onMarkAllRead = vi.fn();
|
||||
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
|
||||
const optimisticMarkAllRead = vi.fn();
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' })],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead
|
||||
});
|
||||
await userEvent.click(page.getByText('Alle gelesen'));
|
||||
expect(onMarkAllRead).toHaveBeenCalledTimes(1);
|
||||
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
|
||||
const onMarkRead = vi.fn();
|
||||
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => {
|
||||
const optimisticMarkRead = vi.fn();
|
||||
const n = notif({ id: 'xyz' });
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [n],
|
||||
onMarkRead,
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
const dismiss = document.querySelector(
|
||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(dismiss).not.toBeNull();
|
||||
dismiss?.click();
|
||||
expect(onMarkRead).toHaveBeenCalledTimes(1);
|
||||
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
|
||||
expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
|
||||
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
|
||||
});
|
||||
|
||||
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
||||
@@ -124,8 +153,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
annotationId: 'annot-9'
|
||||
})
|
||||
],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
const link = document.querySelector(
|
||||
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
||||
@@ -136,8 +165,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'x' })],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
});
|
||||
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
||||
expect(dismiss).not.toBeNull();
|
||||
@@ -145,4 +174,22 @@ describe('ChronikFuerDichBox', () => {
|
||||
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
||||
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,7 +4,36 @@ import { page } from 'vitest/browser';
|
||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||
import type { NotificationItem } from '$lib/notification/notifications';
|
||||
|
||||
afterEach(cleanup);
|
||||
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||
|
||||
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 => ({
|
||||
id: 'n-1',
|
||||
@@ -22,7 +51,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
|
||||
describe('ChronikFuerDichBox', () => {
|
||||
it('renders the inbox-zero state when there are no unread', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
|
||||
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
||||
@@ -34,8 +63,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,8 +91,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ actorName: 'Bertha' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,8 +105,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,11 +115,11 @@ describe('ChronikFuerDichBox', () => {
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
|
||||
const onMarkRead = vi.fn();
|
||||
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
|
||||
const optimisticMarkRead = vi.fn();
|
||||
const item = mention({ id: 'n-7' });
|
||||
render(ChronikFuerDichBox, {
|
||||
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
|
||||
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
|
||||
});
|
||||
|
||||
const dismiss = document.querySelector(
|
||||
@@ -98,35 +127,55 @@ describe('ChronikFuerDichBox', () => {
|
||||
) as HTMLElement;
|
||||
dismiss.click();
|
||||
|
||||
expect(onMarkRead).toHaveBeenCalledWith(item);
|
||||
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
|
||||
});
|
||||
|
||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||
const onMarkAllRead = vi.fn();
|
||||
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||
const optimisticMarkAllRead = vi.fn();
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention()],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead
|
||||
}
|
||||
});
|
||||
|
||||
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
||||
btn.click();
|
||||
|
||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
||||
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('builds a deep-link href to the comment for each notification', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,7 +303,9 @@ async function handleLabelToggle(label: string) {
|
||||
|
||||
{#if canWrite && hasBlocks}
|
||||
<div class="border-t border-line px-4 py-3">
|
||||
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
|
||||
<p class="mb-2 font-sans text-xs font-medium text-ink-2">
|
||||
{m.transcribe_mark_for_training()}
|
||||
</p>
|
||||
<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)}
|
||||
<button
|
||||
|
||||
@@ -34,7 +34,7 @@ let {
|
||||
<button
|
||||
onclick={onPrev}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="Zurück"
|
||||
aria-label={m.viewer_previous_page()}
|
||||
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">
|
||||
@@ -51,7 +51,7 @@ let {
|
||||
<button
|
||||
onclick={onNext}
|
||||
disabled={!isLoaded || currentPage >= totalPages}
|
||||
aria-label="Weiter"
|
||||
aria-label={m.viewer_next_page()}
|
||||
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">
|
||||
@@ -64,7 +64,7 @@ let {
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={onZoomOut}
|
||||
aria-label="Verkleinern"
|
||||
aria-label={m.viewer_zoom_out()}
|
||||
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">
|
||||
@@ -74,7 +74,7 @@ let {
|
||||
</button>
|
||||
<button
|
||||
onclick={onZoomIn}
|
||||
aria-label="Vergrößern"
|
||||
aria-label={m.viewer_zoom_in()}
|
||||
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">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PdfControls from './PdfControls.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -23,28 +24,28 @@ describe('PdfControls — annotation toggle visibility', () => {
|
||||
it('renders annotation toggle when annotationCount is greater than zero', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 3 });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
|
||||
.element(page.getByRole('button', { name: m.pdf_annotations_show() }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render annotation toggle when annotationCount is zero', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 0 });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotierungen/i }))
|
||||
.element(page.getByRole('button', { name: m.pdf_annotations_show() }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PdfControls — annotation toggle label', () => {
|
||||
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
|
||||
it('shows show-annotations label when annotations are hidden', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
|
||||
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
|
||||
const btn = page.getByRole('button', { name: m.pdf_annotations_show() });
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
|
||||
it('shows hide-annotations label when annotations are visible', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
|
||||
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
|
||||
const btn = page.getByRole('button', { name: m.pdf_annotations_hide() });
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -58,7 +59,9 @@ describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
|
||||
});
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
|
||||
b.getAttribute('aria-label') ?? ''
|
||||
)
|
||||
);
|
||||
expect(annotationBtn).not.toBeNull();
|
||||
expect(annotationBtn!.className).toContain('text-primary');
|
||||
@@ -75,7 +78,9 @@ describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
|
||||
});
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
|
||||
b.getAttribute('aria-label') ?? ''
|
||||
)
|
||||
);
|
||||
expect(annotationBtn).not.toBeNull();
|
||||
expect(annotationBtn!.className).toContain('focus-visible:ring-2');
|
||||
@@ -86,7 +91,12 @@ describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
|
||||
const label = b.getAttribute('aria-label') ?? '';
|
||||
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
|
||||
return [
|
||||
m.viewer_previous_page(),
|
||||
m.viewer_next_page(),
|
||||
m.viewer_zoom_out(),
|
||||
m.viewer_zoom_in()
|
||||
].includes(label);
|
||||
});
|
||||
expect(iconOnlyButtons).toHaveLength(4);
|
||||
for (const btn of iconOnlyButtons) {
|
||||
@@ -104,7 +114,9 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
|
||||
});
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
|
||||
b.getAttribute('aria-label') ?? ''
|
||||
)
|
||||
);
|
||||
expect(annotationBtn).not.toBeNull();
|
||||
expect(annotationBtn!.className).toContain('min-h-[44px]');
|
||||
@@ -118,7 +130,9 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
|
||||
});
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
|
||||
b.getAttribute('aria-label') ?? ''
|
||||
)
|
||||
);
|
||||
expect(annotationBtn).not.toBeNull();
|
||||
expect(annotationBtn!.className).toContain('min-w-[44px]');
|
||||
@@ -131,7 +145,9 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
|
||||
showAnnotations: false
|
||||
});
|
||||
const btn1 = Array.from(c1.querySelectorAll('button')).find((b) =>
|
||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
|
||||
b.getAttribute('aria-label') ?? ''
|
||||
)
|
||||
);
|
||||
expect(btn1!.getAttribute('aria-pressed')).toBe('false');
|
||||
cleanup();
|
||||
@@ -142,7 +158,9 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
|
||||
showAnnotations: true
|
||||
});
|
||||
const btn2 = Array.from(c2.querySelectorAll('button')).find((b) =>
|
||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
|
||||
b.getAttribute('aria-label') ?? ''
|
||||
)
|
||||
);
|
||||
expect(btn2!.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
@@ -152,7 +170,12 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
|
||||
const label = b.getAttribute('aria-label') ?? '';
|
||||
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
|
||||
return [
|
||||
m.viewer_previous_page(),
|
||||
m.viewer_next_page(),
|
||||
m.viewer_zoom_out(),
|
||||
m.viewer_zoom_in()
|
||||
].includes(label);
|
||||
});
|
||||
expect(iconOnlyButtons).toHaveLength(4);
|
||||
for (const btn of iconOnlyButtons) {
|
||||
@@ -165,7 +188,12 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
|
||||
const label = b.getAttribute('aria-label') ?? '';
|
||||
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
|
||||
return [
|
||||
m.viewer_previous_page(),
|
||||
m.viewer_next_page(),
|
||||
m.viewer_zoom_out(),
|
||||
m.viewer_zoom_in()
|
||||
].includes(label);
|
||||
});
|
||||
expect(iconOnlyButtons).toHaveLength(4);
|
||||
for (const btn of iconOnlyButtons) {
|
||||
|
||||
@@ -180,6 +180,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -580,6 +596,38 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1849,7 +1897,7 @@ export interface components {
|
||||
status: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
shareableUrl?: string;
|
||||
shareableUrl: string;
|
||||
};
|
||||
GroupDTO: {
|
||||
name?: string;
|
||||
@@ -2011,13 +2059,17 @@ export interface components {
|
||||
lastName?: string;
|
||||
notifyOnMention?: boolean;
|
||||
};
|
||||
LoginRequest: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
ForgotPasswordRequest: {
|
||||
email?: string;
|
||||
};
|
||||
ImportStatus: {
|
||||
/** @enum {string} */
|
||||
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||
message?: string;
|
||||
statusCode?: string;
|
||||
/** Format: int32 */
|
||||
processed?: number;
|
||||
/** Format: date-time */
|
||||
@@ -2255,14 +2307,14 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
/** Format: int32 */
|
||||
number?: number;
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
empty?: boolean;
|
||||
@@ -2410,7 +2462,7 @@ export interface components {
|
||||
};
|
||||
ActivityFeedItemDTO: {
|
||||
/** @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";
|
||||
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";
|
||||
actor?: components["schemas"]["ActivityActorDTO"];
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
@@ -2954,6 +3006,30 @@ 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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3547,6 +3623,7 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
blockId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
@@ -3597,6 +3674,7 @@ export interface operations {
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
blockId: string;
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
@@ -3791,6 +3869,48 @@ 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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4985,7 +5105,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentDensityResult"];
|
||||
"application/json": components["schemas"]["DocumentDensityResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -5061,7 +5181,7 @@ export interface operations {
|
||||
query?: {
|
||||
limit?: number;
|
||||
/** @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")[];
|
||||
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")[];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
43
frontend/src/lib/messages.spec.ts
Normal file
43
frontend/src/lib/messages.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import { notificationStore } from '$lib/notification/notifications.svelte';
|
||||
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
@@ -30,17 +28,6 @@ function closeDropdown() {
|
||||
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) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.stopPropagation();
|
||||
@@ -113,8 +100,8 @@ onDestroy(() => {
|
||||
{#if open}
|
||||
<NotificationDropdown
|
||||
notifications={stream.notifications}
|
||||
onMarkRead={handleMarkRead}
|
||||
onMarkAllRead={stream.markAllRead}
|
||||
optimisticMarkRead={stream.optimisticMarkRead}
|
||||
optimisticMarkAllRead={stream.optimisticMarkAllRead}
|
||||
onClose={closeDropdown}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -3,10 +3,18 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import type { NotificationItem } from '$lib/notification/notifications';
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
|
||||
const gotoMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
||||
vi.mock('$app/forms', () => ({
|
||||
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: [] }));
|
||||
|
||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||
@@ -17,18 +25,17 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||
get unreadCount() {
|
||||
return mockNotificationList.value.length;
|
||||
},
|
||||
markRead: mockMarkRead,
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn(),
|
||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
markAllRead: vi.fn()
|
||||
destroy: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
gotoMock.mockClear();
|
||||
mockMarkRead.mockClear();
|
||||
vi.clearAllMocks();
|
||||
mockNotificationList.value = [];
|
||||
});
|
||||
|
||||
@@ -45,16 +52,6 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
|
||||
...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', () => {
|
||||
it('bell button has cursor-pointer class', async () => {
|
||||
render(NotificationBell);
|
||||
@@ -82,29 +79,3 @@ describe('NotificationBell — cursor and tooltip', () => {
|
||||
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,17 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/shared/utils/time';
|
||||
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
|
||||
type Props = {
|
||||
notifications: NotificationItem[];
|
||||
onMarkRead: (notification: NotificationItem) => void;
|
||||
onMarkAllRead: () => void;
|
||||
optimisticMarkRead: (id: string) => void;
|
||||
optimisticMarkAllRead: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
||||
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
|
||||
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
function handleViewAll() {
|
||||
onClose(); // close first — avoids stale dropdown during navigation transition
|
||||
@@ -31,16 +35,35 @@ function handleViewAll() {
|
||||
{m.notification_bell_label()}
|
||||
</span>
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMarkAllRead}
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
<form
|
||||
action="/aktivitaeten?/mark-all-read"
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</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 -->
|
||||
{#if notifications.length === 0}
|
||||
<!-- Empty state -->
|
||||
@@ -66,67 +89,93 @@ function handleViewAll() {
|
||||
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onMarkRead(notification)}
|
||||
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
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
<form
|
||||
action="/aktivitaeten?/dismiss-notification"
|
||||
method="POST"
|
||||
class="contents"
|
||||
use:enhance={() => {
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
<input type="hidden" name="notificationId" value={notification.id} />
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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}
|
||||
</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}
|
||||
</button>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -6,9 +6,38 @@ import NotificationDropdown from './NotificationDropdown.svelte';
|
||||
|
||||
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(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockFormResult.type = 'success'; // reset to default after each test
|
||||
});
|
||||
|
||||
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -29,8 +58,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -42,8 +71,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -55,8 +84,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -70,8 +99,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification()],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -83,8 +112,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -98,8 +127,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -116,8 +145,8 @@ describe('NotificationDropdown', () => {
|
||||
makeNotification({ id: 'n1', read: false }),
|
||||
makeNotification({ id: 'n2', read: true })
|
||||
],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -126,37 +155,100 @@ describe('NotificationDropdown', () => {
|
||||
expect(unreadDots.length).toBe(1);
|
||||
});
|
||||
|
||||
it('calls onMarkRead with the notification when an item is clicked', async () => {
|
||||
const onMarkRead = vi.fn();
|
||||
it('each notification row is wrapped in a form posting to the dismiss action', async () => {
|
||||
render(NotificationDropdown, {
|
||||
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' });
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [n],
|
||||
onMarkRead,
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
||||
|
||||
expect(onMarkRead).toHaveBeenCalledWith(n);
|
||||
expect(optimisticMarkRead).toHaveBeenCalledWith('n42');
|
||||
});
|
||||
|
||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||
const onMarkAllRead = vi.fn();
|
||||
it('the mark-all-read control is a form posting to the mark-all-read action', async () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification()],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead,
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
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: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
||||
|
||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
||||
expect(optimisticMarkAllRead).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 () => {
|
||||
@@ -164,8 +256,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose
|
||||
}
|
||||
});
|
||||
@@ -179,8 +271,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -193,12 +285,15 @@ describe('NotificationDropdown', () => {
|
||||
it('calls onClose before navigating to /aktivitaeten', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const onClose = vi.fn(() => callOrder.push('close'));
|
||||
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
|
||||
vi.mocked(goto).mockImplementation(() => {
|
||||
callOrder.push('goto');
|
||||
return Promise.resolve();
|
||||
});
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose
|
||||
}
|
||||
});
|
||||
@@ -212,8 +307,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -225,8 +320,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -242,14 +337,78 @@ describe('NotificationDropdown', () => {
|
||||
makeNotification({ id: 'n1', actorName: 'First' }),
|
||||
makeNotification({ id: 'n2', actorName: 'Second' })
|
||||
],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const items = document.querySelectorAll('button[type="button"]');
|
||||
// At least 2 items + mark-all button
|
||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
|
||||
expect(forms.length).toBe(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,12 +108,46 @@ describe('notificationStore (singleton)', () => {
|
||||
expect(notificationStore.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('markAllRead resets unreadCount', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
await notificationStore.markAllRead();
|
||||
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
||||
notificationStore.init();
|
||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
||||
notificationStore.optimisticMarkRead('sse-1');
|
||||
|
||||
expect(notificationStore.notifications[0].read).toBe(true);
|
||||
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,28 +35,19 @@ async function fetchUnreadCount(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(notification: NotificationItem): Promise<void> {
|
||||
if (!notification.read) {
|
||||
try {
|
||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||
notification.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
function optimisticMarkRead(id: string): void {
|
||||
const notification = notifications.find((n) => n.id === id);
|
||||
if (notification && !notification.read) {
|
||||
notification.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead(): Promise<void> {
|
||||
try {
|
||||
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);
|
||||
function optimisticMarkAllRead(): void {
|
||||
for (const n of notifications) {
|
||||
n.read = true;
|
||||
}
|
||||
unreadCount = 0;
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
@@ -123,8 +114,8 @@ export const notificationStore = {
|
||||
},
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
markRead,
|
||||
markAllRead,
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead,
|
||||
init,
|
||||
destroy
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
<!-- Hamburger toggle (mobile only) -->
|
||||
<button
|
||||
class="ml-auto flex h-11 w-11 items-center justify-center self-center rounded text-white/70 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring lg:hidden"
|
||||
aria-label={mobileNavOpen ? 'Menü schließen' : 'Menü öffnen'}
|
||||
aria-label={mobileNavOpen ? m.layout_menu_close() : m.layout_menu_open()}
|
||||
aria-expanded={mobileNavOpen}
|
||||
aria-controls="mobile-nav"
|
||||
onclick={() => (mobileNavOpen = !mobileNavOpen)}
|
||||
|
||||
@@ -19,14 +19,22 @@ describe('admin/groups layout load', () => {
|
||||
{ id: 'g1', name: 'Admins', permissions: ['ADMIN'] },
|
||||
{ id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }
|
||||
]);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/groups'),
|
||||
url: new URL('http://localhost/admin/groups')
|
||||
});
|
||||
expect(result.groups).toHaveLength(2);
|
||||
expect(result.groups[0].name).toBe('Admins');
|
||||
});
|
||||
|
||||
it('returns an empty array when the API returns nothing', async () => {
|
||||
mockApi([]);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/groups'),
|
||||
url: new URL('http://localhost/admin/groups')
|
||||
});
|
||||
expect(result.groups).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -35,7 +43,11 @@ describe('admin/groups layout load', () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/groups'),
|
||||
url: new URL('http://localhost/admin/groups')
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/groups');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,50 +1,43 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { parseBackendError } from '$lib/shared/errors';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
export interface InviteListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
displayCode: string;
|
||||
label?: string;
|
||||
useCount: number;
|
||||
maxUses?: number;
|
||||
expiresAt?: string;
|
||||
revoked: boolean;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
shareableUrl: string;
|
||||
}
|
||||
|
||||
export type InviteListItem = components['schemas']['InviteListItemDTO'];
|
||||
export type UserGroup = components['schemas']['UserGroup'];
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const status = url.searchParams.get('status') ?? 'active';
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const;
|
||||
type InviteStatus = (typeof VALID_STATUSES)[number];
|
||||
|
||||
const [invitesRes, groupsRes] = await Promise.all([
|
||||
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
|
||||
fetch(`${apiUrl}/api/groups`)
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const rawStatus = url.searchParams.get('status');
|
||||
const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus)
|
||||
? (rawStatus as InviteStatus)
|
||||
: 'ACTIVE';
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const [invitesResult, groupsResult] = await Promise.all([
|
||||
api.GET('/api/invites', { params: { query: { status } } }),
|
||||
api.GET('/api/groups')
|
||||
]);
|
||||
|
||||
let invites: InviteListItem[] = [];
|
||||
let loadError: string | null = null;
|
||||
if (!invitesRes.ok) {
|
||||
const backendError = await parseBackendError(invitesRes);
|
||||
loadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||
if (!invitesResult.response.ok) {
|
||||
const code = (invitesResult.error as unknown as { code?: string })?.code;
|
||||
loadError = code ?? 'INTERNAL_ERROR';
|
||||
} else {
|
||||
invites = await invitesRes.json();
|
||||
invites = (invitesResult.data ?? []) as InviteListItem[];
|
||||
}
|
||||
|
||||
let groups: UserGroup[] = [];
|
||||
let groupsLoadError: string | null = null;
|
||||
if (!groupsRes.ok) {
|
||||
const backendError = await parseBackendError(groupsRes);
|
||||
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||
if (!groupsResult.response.ok) {
|
||||
const code = (groupsResult.error as unknown as { code?: string })?.code;
|
||||
groupsLoadError = code ?? 'INTERNAL_ERROR';
|
||||
} else {
|
||||
const raw: UserGroup[] = await groupsRes.json();
|
||||
const raw = groupsResult.data ?? [];
|
||||
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
@@ -63,42 +56,30 @@ export const actions = {
|
||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||
const groupIds = formData.getAll('groupIds') as string[];
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/invites`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
label,
|
||||
maxUses,
|
||||
prefillFirstName,
|
||||
prefillLastName,
|
||||
prefillEmail,
|
||||
expiresAt,
|
||||
groupIds
|
||||
})
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/invites', {
|
||||
body: { label, maxUses, prefillFirstName, prefillLastName, prefillEmail, expiresAt, groupIds }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
const created: InviteListItem = await res.json();
|
||||
return { created };
|
||||
return { created: result.data! as InviteListItem };
|
||||
},
|
||||
|
||||
revoke: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
const id = formData.get('id') as string | null;
|
||||
if (!id) return fail(400, { revokeError: getErrorMessage('VALIDATION_ERROR') });
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
return { revoked: id };
|
||||
|
||||
284
frontend/src/routes/admin/invites/page.server.spec.ts
Normal file
284
frontend/src/routes/admin/invites/page.server.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
import type { UserGroup } from './+page.server';
|
||||
|
||||
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
|
||||
// the void and the Record<string, any> from the generic constraint.
|
||||
type LoadData = {
|
||||
invites: unknown[];
|
||||
status: string;
|
||||
loadError: string | null;
|
||||
groups: UserGroup[];
|
||||
groupsLoadError: string | null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyFetch = (...args: any[]) => any;
|
||||
|
||||
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
headers: new Headers({ 'content-type': 'application/json' })
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('admin/invites load()', () => {
|
||||
const mockFetch = vi.fn<AnyFetch>();
|
||||
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
function event(status = 'active') {
|
||||
const url = new URL(`http://localhost/admin/invites?status=${status}`);
|
||||
return {
|
||||
url,
|
||||
request: new Request(url),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('returns groups array alongside invites when both succeed', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||
mockResponse(true, [
|
||||
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||
])
|
||||
);
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groups).toHaveLength(2);
|
||||
expect(result.groupsLoadError).toBeNull();
|
||||
});
|
||||
|
||||
it('returns groups sorted alphabetically by name', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||
mockResponse(true, [
|
||||
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||
])
|
||||
);
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||
});
|
||||
|
||||
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockResponse(true, []))
|
||||
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groups).toEqual([]);
|
||||
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||
});
|
||||
|
||||
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockResponse(true, []))
|
||||
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||
});
|
||||
|
||||
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockResponse(true, []))
|
||||
.mockResolvedValueOnce(mockResponse(true, []));
|
||||
|
||||
await load(event());
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
// createApiClient calls fetch(Request, {}), not fetch(string, init)
|
||||
const urls = mockFetch.mock.calls.map((call) => (call[0] as Request).url);
|
||||
expect(urls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('/api/invites'),
|
||||
expect.stringContaining('/api/groups')
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin/invites create action', () => {
|
||||
const mockFetch = vi.fn<AnyFetch>();
|
||||
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
const successBody = {
|
||||
id: 'inv-1',
|
||||
code: 'ABCDE12345',
|
||||
displayCode: 'ABCDE-12345',
|
||||
status: 'active',
|
||||
revoked: false,
|
||||
useCount: 0,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
||||
};
|
||||
|
||||
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||
const fd = new FormData();
|
||||
fd.append('groupIds', 'g-1');
|
||||
fd.append('groupIds', 'g-2');
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||
|
||||
await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
// createApiClient calls fetch(Request, {}), not fetch(string, init)
|
||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||
expect(req).toBeInstanceOf(Request);
|
||||
expect(req.url).toContain('/api/invites');
|
||||
const sent = await req.json();
|
||||
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
||||
});
|
||||
|
||||
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||
const fd = new FormData();
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||
|
||||
await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||
expect(req).toBeInstanceOf(Request);
|
||||
const sent = await req.json();
|
||||
expect(sent.groupIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns created invite on success', async () => {
|
||||
const fd = new FormData();
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||
|
||||
const result = await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
expect(result).toMatchObject({ created: expect.objectContaining({ id: 'inv-1' }) });
|
||||
});
|
||||
|
||||
it('returns fail with backend error code when create returns non-OK', async () => {
|
||||
const fd = new FormData();
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||
|
||||
const result = await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
expect(result).toMatchObject({ status: 403, data: { createError: 'FORBIDDEN' } });
|
||||
});
|
||||
|
||||
it('falls back to INTERNAL_ERROR when create error body has no code', async () => {
|
||||
const fd = new FormData();
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||
|
||||
const result = await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
expect(result).toMatchObject({ status: 500, data: { createError: 'INTERNAL_ERROR' } });
|
||||
});
|
||||
|
||||
it('includes expiresAt in POST body when provided', async () => {
|
||||
const fd = new FormData();
|
||||
fd.append('expiresAt', '2026-12-31');
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||
|
||||
await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||
const sent = await req.json();
|
||||
expect(sent.expiresAt).toBe('2026-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin/invites revoke action', () => {
|
||||
const mockFetch = vi.fn<AnyFetch>();
|
||||
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
it('calls DELETE /api/invites/{id} via createApiClient', async () => {
|
||||
const fd = new FormData();
|
||||
fd.append('id', 'inv-abc');
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
|
||||
|
||||
await actions.revoke({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||
expect(req).toBeInstanceOf(Request);
|
||||
expect(req.url).toContain('/api/invites/inv-abc');
|
||||
expect(req.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('returns revoked id on success', async () => {
|
||||
const fd = new FormData();
|
||||
fd.append('id', 'inv-abc');
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
|
||||
|
||||
const result = await actions.revoke({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({ revoked: 'inv-abc' });
|
||||
});
|
||||
|
||||
it('returns fail with backend error code when revoke returns non-OK', async () => {
|
||||
const fd = new FormData();
|
||||
fd.append('id', 'inv-abc');
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'NOT_FOUND' }, 404));
|
||||
|
||||
const result = await actions.revoke({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
expect(result).toMatchObject({ status: 404, data: { revokeError: 'NOT_FOUND' } });
|
||||
});
|
||||
|
||||
it('returns fail(400) when revoke id is missing', async () => {
|
||||
const result = await actions.revoke({
|
||||
request: new Request('http://localhost', { method: 'POST', body: new FormData() }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
import type { UserGroup } from './+page.server';
|
||||
|
||||
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
|
||||
// the void and the Record<string, any> from the generic constraint.
|
||||
type LoadData = {
|
||||
invites: unknown[];
|
||||
status: string;
|
||||
loadError: string | null;
|
||||
groups: UserGroup[];
|
||||
groupsLoadError: string | null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyFetch = (...args: any[]) => any;
|
||||
|
||||
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
headers: new Headers({ 'content-type': 'application/json' })
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('admin/invites load()', () => {
|
||||
const mockFetch = vi.fn<AnyFetch>();
|
||||
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
function event(status = 'active') {
|
||||
return {
|
||||
url: new URL(`http://localhost/admin/invites?status=${status}`),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('returns groups array alongside invites when both succeed', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||
mockResponse(true, [
|
||||
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||
])
|
||||
);
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groups).toHaveLength(2);
|
||||
expect(result.groupsLoadError).toBeNull();
|
||||
});
|
||||
|
||||
it('returns groups sorted alphabetically by name', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||
mockResponse(true, [
|
||||
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||
])
|
||||
);
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||
});
|
||||
|
||||
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockResponse(true, []))
|
||||
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groups).toEqual([]);
|
||||
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||
});
|
||||
|
||||
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockResponse(true, []))
|
||||
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||
|
||||
const result = (await load(event())) as LoadData;
|
||||
|
||||
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||
});
|
||||
|
||||
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockResponse(true, []))
|
||||
.mockResolvedValueOnce(mockResponse(true, []));
|
||||
|
||||
await load(event());
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin/invites create action', () => {
|
||||
const mockFetch = vi.fn<AnyFetch>();
|
||||
|
||||
beforeEach(() => mockFetch.mockReset());
|
||||
|
||||
const successBody = {
|
||||
id: 'inv-1',
|
||||
code: 'ABCDE12345',
|
||||
displayCode: 'ABCDE-12345',
|
||||
status: 'active',
|
||||
revoked: false,
|
||||
useCount: 0,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
||||
};
|
||||
|
||||
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||
const fd = new FormData();
|
||||
fd.append('groupIds', 'g-1');
|
||||
fd.append('groupIds', 'g-2');
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||
|
||||
await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const sent = JSON.parse(init.body as string);
|
||||
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
||||
});
|
||||
|
||||
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||
const fd = new FormData();
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||
|
||||
await actions.create({
|
||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const sent = JSON.parse(init.body as string);
|
||||
expect(sent.groupIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -26,26 +26,46 @@ beforeEach(() => vi.clearAllMocks());
|
||||
describe('admin layout load — permission check', () => {
|
||||
it('throws 403 when user has no admin permission', async () => {
|
||||
await expect(
|
||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: noPermUser } })
|
||||
load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin'),
|
||||
url: new URL('http://localhost/admin'),
|
||||
locals: { user: noPermUser }
|
||||
})
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 when user is undefined', async () => {
|
||||
await expect(
|
||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } })
|
||||
load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin'),
|
||||
url: new URL('http://localhost/admin'),
|
||||
locals: { user: undefined }
|
||||
})
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 when user has no groups', async () => {
|
||||
await expect(
|
||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } })
|
||||
load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin'),
|
||||
url: new URL('http://localhost/admin'),
|
||||
locals: { user: { groups: [] } }
|
||||
})
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('allows access for a user with ADMIN_TAG only', async () => {
|
||||
mockApi([], [], []);
|
||||
await expect(
|
||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: tagAdminUser } })
|
||||
load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin'),
|
||||
url: new URL('http://localhost/admin'),
|
||||
locals: { user: tagAdminUser }
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -63,6 +83,8 @@ describe('admin layout load — permission check', () => {
|
||||
|
||||
const result = await load({
|
||||
fetch: mockFetch as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin'),
|
||||
url: new URL('http://localhost/admin'),
|
||||
locals: { user: adminUser }
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ describe('admin/ocr/[personId] — load', () => {
|
||||
data: { runs: [], personNames: { [personId]: 'Anna Müller' } }
|
||||
});
|
||||
|
||||
const result = (await load({ params: { personId }, fetch } as never))!;
|
||||
const result = (await load({
|
||||
params: { personId },
|
||||
fetch,
|
||||
request: new Request('http://localhost/admin/ocr/123'),
|
||||
url: new URL('http://localhost/admin/ocr/123')
|
||||
} as never))!;
|
||||
|
||||
expect(result.history.personNames?.[personId]).toBe('Anna Müller');
|
||||
});
|
||||
@@ -27,7 +32,12 @@ describe('admin/ocr/[personId] — load', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
load({ params: { personId: 'unknown-id' }, fetch } as never)
|
||||
load({
|
||||
params: { personId: 'unknown-id' },
|
||||
fetch,
|
||||
request: new Request('http://localhost/admin/ocr/unknown-id'),
|
||||
url: new URL('http://localhost/admin/ocr/unknown-id')
|
||||
} as never)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,11 @@ describe('admin/ocr/global — load', () => {
|
||||
data: { runs: [{ id: 'run1' }], personNames: {} }
|
||||
});
|
||||
|
||||
const result = (await load({ fetch } as never))!;
|
||||
const result = (await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/admin/ocr/global'),
|
||||
url: new URL('http://localhost/admin/ocr/global')
|
||||
} as never))!;
|
||||
|
||||
expect(result.history.runs).toHaveLength(1);
|
||||
});
|
||||
@@ -22,6 +26,12 @@ describe('admin/ocr/global — load', () => {
|
||||
it('throws error when API call fails', async () => {
|
||||
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} });
|
||||
|
||||
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 500 });
|
||||
await expect(
|
||||
load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/admin/ocr/global'),
|
||||
url: new URL('http://localhost/admin/ocr/global')
|
||||
} as never)
|
||||
).rejects.toMatchObject({ status: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,11 @@ describe('admin/ocr — load', () => {
|
||||
data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] }
|
||||
});
|
||||
|
||||
const result = (await load({ fetch } as never))!;
|
||||
const result = (await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/admin/ocr'),
|
||||
url: new URL('http://localhost/admin/ocr')
|
||||
} as never))!;
|
||||
|
||||
expect(result.trainingInfo.availableBlocks).toBe(10);
|
||||
expect(result.trainingInfo.ocrServiceAvailable).toBe(true);
|
||||
@@ -23,6 +27,12 @@ describe('admin/ocr — load', () => {
|
||||
it('throws 503 when OCR API call fails', async () => {
|
||||
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} });
|
||||
|
||||
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 503 });
|
||||
await expect(
|
||||
load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/admin/ocr'),
|
||||
url: new URL('http://localhost/admin/ocr')
|
||||
} as never)
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,14 @@ const failureMessage = $derived(
|
||||
? m.admin_system_import_failed_no_spreadsheet()
|
||||
: m.admin_system_import_failed_internal()
|
||||
);
|
||||
|
||||
function reasonLabel(code: string): string {
|
||||
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
|
||||
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
|
||||
if (code === 'S3_UPLOAD_FAILED') return m.import_reason_s3_upload_failed();
|
||||
if (code === 'ALREADY_EXISTS') return m.import_reason_already_exists();
|
||||
return code;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -48,6 +56,41 @@ const failureMessage = $derived(
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
||||
</div>
|
||||
<div aria-live="polite">
|
||||
{#if importStatus.skipped > 0}
|
||||
<details class="mb-4 rounded-sm border border-warning/40 bg-warning/10 p-4 text-amber-900">
|
||||
<summary class="flex min-h-[44px] cursor-pointer list-none items-center gap-2 py-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="details-chevron h-4 w-4 shrink-0 motion-safe:transition-transform"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<div>
|
||||
<span data-testid="skipped-count" class="block text-base font-bold"
|
||||
>{importStatus.skipped}</span
|
||||
>
|
||||
<span class="block font-sans text-xs font-bold tracking-widest uppercase">
|
||||
{m.admin_system_import_skipped_label()}
|
||||
</span>
|
||||
</div>
|
||||
</summary>
|
||||
<ul class="mt-3 max-h-64 space-y-1 overflow-y-auto">
|
||||
{#each importStatus.skippedFiles as skipped (skipped.filename)}
|
||||
<li class="font-mono text-sm text-ink-2">
|
||||
{skipped.filename} — {reasonLabel(skipped.reason)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
data-import-trigger
|
||||
onclick={ontrigger}
|
||||
@@ -79,3 +122,9 @@ const failureMessage = $derived(
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
details[open] .details-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,8 @@ const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
|
||||
state: 'IDLE',
|
||||
statusCode: 'IMPORT_IDLE',
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
skippedFiles: [],
|
||||
startedAt: null,
|
||||
...overrides
|
||||
});
|
||||
@@ -128,4 +130,106 @@ describe('ImportStatusCard', () => {
|
||||
await getByRole('button').click();
|
||||
expect(ontrigger).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows skipped count when DONE and skipped > 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'DONE',
|
||||
statusCode: 'IMPORT_DONE',
|
||||
processed: 10,
|
||||
skipped: 3,
|
||||
skippedFiles: [
|
||||
{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||
{ filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||
{ filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||
]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('shows skipped filenames in collapsible list when DONE and skipped > 0', async () => {
|
||||
const { getByText } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'DONE',
|
||||
statusCode: 'IMPORT_DONE',
|
||||
processed: 5,
|
||||
skipped: 1,
|
||||
skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByText('fake.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skipped section when DONE and skipped is 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skipped section when RUNNING even with skipped > 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'RUNNING',
|
||||
statusCode: 'IMPORT_RUNNING',
|
||||
processed: 5,
|
||||
skipped: 2,
|
||||
skippedFiles: [
|
||||
{ filename: 'a.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||
{ filename: 'b.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||
]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skipped section when FAILED even with skipped > 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'FAILED',
|
||||
statusCode: 'IMPORT_FAILED_INTERNAL',
|
||||
skipped: 1,
|
||||
skippedFiles: [{ filename: 'bad.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows raw reason code for unknown skip reasons', async () => {
|
||||
const { getByText } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'DONE',
|
||||
statusCode: 'IMPORT_DONE',
|
||||
processed: 1,
|
||||
skipped: 1,
|
||||
skippedFiles: [{ filename: 'odd.pdf', reason: 'SOME_FUTURE_CODE' }]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByText('SOME_FUTURE_CODE', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export type SkippedFile = {
|
||||
filename: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ImportStatus = {
|
||||
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||
statusCode: string;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
skippedFiles: SkippedFile[];
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('tags/[id] — load function', () => {
|
||||
const result = await load({
|
||||
params: { id: 't1' },
|
||||
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
||||
request: new Request('http://localhost/admin/tags/t1'),
|
||||
url
|
||||
} as never);
|
||||
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true);
|
||||
@@ -31,6 +32,7 @@ describe('tags/[id] — load function', () => {
|
||||
const result = await load({
|
||||
params: { id: 't1' },
|
||||
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
||||
request: new Request('http://localhost/admin/tags/t1'),
|
||||
url
|
||||
} as never);
|
||||
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false);
|
||||
|
||||
@@ -44,14 +44,22 @@ const sampleTree = [
|
||||
describe('admin/tags layout load', () => {
|
||||
it('returns the tree list', async () => {
|
||||
mockTreeApi(sampleTree);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/tags'),
|
||||
url: new URL('http://localhost/admin/tags')
|
||||
});
|
||||
expect(result.tree).toHaveLength(2);
|
||||
expect(result.tree[0].name).toBe('Familie');
|
||||
});
|
||||
|
||||
it('returns an empty tree when the API returns nothing', async () => {
|
||||
mockTreeApi([]);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/tags'),
|
||||
url: new URL('http://localhost/admin/tags')
|
||||
});
|
||||
expect(result.tree).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -60,13 +68,21 @@ describe('admin/tags layout load', () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/tags'),
|
||||
url: new URL('http://localhost/admin/tags')
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/tags/tree');
|
||||
});
|
||||
|
||||
it('flattens the tree into a flat tags array', async () => {
|
||||
mockTreeApi(sampleTree);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/tags'),
|
||||
url: new URL('http://localhost/admin/tags')
|
||||
});
|
||||
// Both parent and child should be in the flat array
|
||||
expect(result.tags).toHaveLength(3);
|
||||
expect(result.tags.map((t) => t.name)).toContain('Eltern');
|
||||
@@ -74,14 +90,22 @@ describe('admin/tags layout load', () => {
|
||||
|
||||
it('preserves parentId on child tags in the flat array', async () => {
|
||||
mockTreeApi(sampleTree);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/tags'),
|
||||
url: new URL('http://localhost/admin/tags')
|
||||
});
|
||||
const child = result.tags.find((t) => t.name === 'Eltern');
|
||||
expect(child?.parentId).toBe('parent1');
|
||||
});
|
||||
|
||||
it('sets parentId to undefined on root tags in the flat array', async () => {
|
||||
mockTreeApi(sampleTree);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/tags'),
|
||||
url: new URL('http://localhost/admin/tags')
|
||||
});
|
||||
const root = result.tags.find((t) => t.name === 'Familie');
|
||||
expect(root?.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
|
||||
const user = locals.user;
|
||||
@@ -45,21 +46,17 @@ export const actions: Actions = {
|
||||
groupIds: data.getAll('groupIds') as string[]
|
||||
};
|
||||
|
||||
const res = await fetch(`/api/users/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PUT('/api/users/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
// Body may contain null for fields the user cleared; the backend treats
|
||||
// null as "clear this field". Cast to satisfy the optional-only spec type.
|
||||
body: body as components['schemas']['AdminUpdateUserRequest']
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
const json = await res.json();
|
||||
code = json?.code;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return fail(res.status, { error: getErrorMessage(code) });
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
199
frontend/src/routes/admin/users/[id]/page.server.spec.ts
Normal file
199
frontend/src/routes/admin/users/[id]/page.server.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
function mockApi(methods: Partial<Record<'GET' | 'PUT' | 'DELETE', ReturnType<typeof vi.fn>>>) {
|
||||
vi.mocked(createApiClient).mockReturnValue(methods as ReturnType<typeof createApiClient>);
|
||||
}
|
||||
|
||||
// ─── load() ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin/users/[id] load()', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeEvent(permissions: string[] = ['ADMIN']) {
|
||||
return {
|
||||
params: { id: 'user-123' },
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: { groups: [{ permissions }] } },
|
||||
request: new Request('http://localhost/admin/users/user-123'),
|
||||
url: new URL('http://localhost/admin/users/user-123')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('throws 403 when the user lacks the ADMIN permission', async () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await load(makeEvent(['READ_ALL']));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
|
||||
expect((thrown as { status: number }).status).toBe(403);
|
||||
});
|
||||
|
||||
it('throws 404 when the backend returns non-ok for the user lookup', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: false, status: 404 }, data: undefined })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] });
|
||||
mockApi({ GET: mockGet });
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await load(makeEvent());
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
|
||||
expect((thrown as { status: number }).status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns editUser and groups on success', async () => {
|
||||
const editUser = { id: 'user-123', email: 'max@example.com', firstName: 'Max' };
|
||||
const groups = [
|
||||
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||
];
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: editUser })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: groups });
|
||||
mockApi({ GET: mockGet });
|
||||
|
||||
const result = await load(makeEvent());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
editUser: expect.objectContaining({ id: 'user-123' }),
|
||||
groups: expect.arrayContaining([expect.objectContaining({ id: 'g-1' })])
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update action ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin/users/[id] update action', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeUpdateRequest(fields: Record<string, string | string[]> = {}) {
|
||||
const fd = new FormData();
|
||||
const defaults: Record<string, string> = {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
email: 'max@example.com'
|
||||
};
|
||||
for (const [k, v] of Object.entries({ ...defaults, ...fields })) {
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((item) => fd.append(k, item));
|
||||
} else {
|
||||
fd.append(k, v);
|
||||
}
|
||||
}
|
||||
return new Request('http://localhost', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
function makeEvent(request: Request) {
|
||||
return {
|
||||
params: { id: 'user-123' },
|
||||
request,
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('calls PUT /api/users/{id} and returns success: true on 200', async () => {
|
||||
const mockPut = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
||||
mockApi({ PUT: mockPut });
|
||||
|
||||
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
'/api/users/{id}',
|
||||
expect.objectContaining({ params: { path: { id: 'user-123' } } })
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('returns fail with backend error code when PUT returns non-OK', async () => {
|
||||
const mockPut = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
|
||||
mockApi({ PUT: mockPut });
|
||||
|
||||
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
||||
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('returns fail with generic message when error body has no code field', async () => {
|
||||
const mockPut = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: false, status: 500 }, error: null });
|
||||
mockApi({ PUT: mockPut });
|
||||
|
||||
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
||||
|
||||
expect(result).toMatchObject({ status: 500 });
|
||||
});
|
||||
|
||||
it('returns fail without calling backend when passwords do not match', async () => {
|
||||
const mockPut = vi.fn();
|
||||
mockApi({ PUT: mockPut });
|
||||
|
||||
const result = await actions.update(
|
||||
makeEvent(makeUpdateRequest({ newPassword: 'abc', confirmPassword: 'xyz' }))
|
||||
);
|
||||
|
||||
expect(mockPut).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete action ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin/users/[id] delete action', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeEvent() {
|
||||
return {
|
||||
params: { id: 'user-123' },
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/users/user-123')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('redirects to /admin/users on successful delete', async () => {
|
||||
const mockDelete = vi.fn().mockResolvedValue({ response: { ok: true } });
|
||||
mockApi({ DELETE: mockDelete });
|
||||
|
||||
let redirectLocation: string | null = null;
|
||||
try {
|
||||
await actions.delete(makeEvent());
|
||||
} catch (e: unknown) {
|
||||
const r = e as { location?: string };
|
||||
redirectLocation = r.location ?? null;
|
||||
}
|
||||
|
||||
expect(redirectLocation).toBe('/admin/users');
|
||||
});
|
||||
|
||||
it('returns fail when delete returns non-OK', async () => {
|
||||
const mockDelete = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
|
||||
mockApi({ DELETE: mockDelete });
|
||||
|
||||
const result = await actions.delete(makeEvent());
|
||||
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
@@ -19,14 +19,24 @@ describe('admin/users layout load', () => {
|
||||
{ id: 'u1', email: 'alice@example.com' },
|
||||
{ id: 'u2', email: 'bob@example.com' }
|
||||
]);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/users'),
|
||||
url: new URL('http://localhost/admin/users')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
expect(result.users).toHaveLength(2);
|
||||
expect(result.users[0].email).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('returns an empty array when the API returns nothing', async () => {
|
||||
mockApi([]);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/users'),
|
||||
url: new URL('http://localhost/admin/users')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
expect(result.users).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -35,7 +45,12 @@ describe('admin/users layout load', () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/admin/users'),
|
||||
url: new URL('http://localhost/admin/users')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/api/users');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import type { components, operations } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
@@ -65,3 +67,31 @@ export async function load({ fetch, url }) {
|
||||
loadError
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
'dismiss-notification': async ({ request, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const raw = data.get('notificationId');
|
||||
const notificationId = typeof raw === 'string' ? raw : null;
|
||||
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PATCH('/api/notifications/{id}/read', {
|
||||
params: { path: { id: notificationId } }
|
||||
});
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
'mark-all-read': async ({ fetch }) => {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/notifications/read-all');
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,14 +76,6 @@ async function onFilterChange(v: FilterValue) {
|
||||
});
|
||||
}
|
||||
|
||||
async function onMarkRead(n: NotificationItem) {
|
||||
await notificationStore.markRead(n);
|
||||
}
|
||||
|
||||
async function onMarkAllRead() {
|
||||
await notificationStore.markAllRead();
|
||||
}
|
||||
|
||||
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
||||
|
||||
const isEmpty = $derived(displayFeed.length === 0);
|
||||
@@ -108,7 +100,11 @@ function retry() {
|
||||
{#if data.loadError === 'activity'}
|
||||
<ChronikErrorCard onRetry={retry} />
|
||||
{:else}
|
||||
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
||||
<ChronikFuerDichBox
|
||||
unread={unread}
|
||||
optimisticMarkRead={notificationStore.optimisticMarkRead}
|
||||
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { load } from './+page.server';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const mockApi = {
|
||||
GET: vi.fn()
|
||||
GET: vi.fn(),
|
||||
PATCH: vi.fn(),
|
||||
POST: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
@@ -29,7 +31,11 @@ beforeEach(() => {
|
||||
describe('aktivitaeten/load — core', () => {
|
||||
it('requests only unread notifications for Für-dich', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl() } as never);
|
||||
await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl()
|
||||
} as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
|
||||
params: { query: { read: false, page: 0, size: 20 } }
|
||||
});
|
||||
@@ -45,7 +51,11 @@ describe('aktivitaeten/load — core', () => {
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: unread } });
|
||||
});
|
||||
|
||||
const result = await load({ fetch, url: buildUrl() } as never);
|
||||
const result = await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl()
|
||||
} as never);
|
||||
|
||||
expect(result.activityFeed).toEqual(feed);
|
||||
expect(result.unreadNotifications).toEqual(unread);
|
||||
@@ -61,7 +71,11 @@ describe('aktivitaeten/load — core', () => {
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||
});
|
||||
|
||||
const result = await load({ fetch, url: buildUrl() } as never);
|
||||
const result = await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl()
|
||||
} as never);
|
||||
|
||||
expect(result.loadError).toBe('activity');
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
@@ -69,11 +83,19 @@ describe('aktivitaeten/load — core', () => {
|
||||
|
||||
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
|
||||
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||
const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||
const validResult = await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl('?filter=fuer-dich')
|
||||
} as never);
|
||||
expect(validResult.filter).toBe('fuer-dich');
|
||||
|
||||
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||
const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never);
|
||||
const invalidResult = await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl('?filter=bogus')
|
||||
} as never);
|
||||
expect(invalidResult.filter).toBe('alle');
|
||||
});
|
||||
});
|
||||
@@ -81,7 +103,11 @@ describe('aktivitaeten/load — core', () => {
|
||||
describe('aktivitaeten/load — kinds param per filter', () => {
|
||||
it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl() } as never);
|
||||
await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl()
|
||||
} as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40 } }
|
||||
});
|
||||
@@ -89,7 +115,11 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
||||
|
||||
it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||
await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl('?filter=fuer-dich')
|
||||
} as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40 } }
|
||||
});
|
||||
@@ -97,7 +127,11 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
||||
|
||||
it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=hochgeladen') } as never);
|
||||
await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl('?filter=hochgeladen')
|
||||
} as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } }
|
||||
});
|
||||
@@ -105,7 +139,11 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
||||
|
||||
it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=transkription') } as never);
|
||||
await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl('?filter=transkription')
|
||||
} as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -120,7 +158,11 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
||||
|
||||
it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => {
|
||||
mockSuccess();
|
||||
await load({ fetch, url: buildUrl('?filter=kommentare') } as never);
|
||||
await load({
|
||||
fetch,
|
||||
request: new Request('http://localhost/aktivitaeten'),
|
||||
url: buildUrl('?filter=kommentare')
|
||||
} as never);
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -133,3 +175,84 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
||||
expect(call[1].params.query.kinds).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function makeActionEvent(formData: FormData): any {
|
||||
return {
|
||||
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
|
||||
fetch
|
||||
};
|
||||
}
|
||||
|
||||
describe('aktivitaeten/actions — dismiss-notification', () => {
|
||||
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
|
||||
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mockApi.PATCH).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
|
||||
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
||||
const fd = new FormData();
|
||||
fd.set('notificationId', 'n-abc');
|
||||
|
||||
await actions['dismiss-notification'](makeActionEvent(fd));
|
||||
|
||||
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
|
||||
params: { path: { id: 'n-abc' } }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns { success: true } when the API responds ok', async () => {
|
||||
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
||||
const fd = new FormData();
|
||||
fd.set('notificationId', 'n-abc');
|
||||
|
||||
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
||||
mockApi.PATCH.mockResolvedValue({
|
||||
response: { ok: false, status: 403 },
|
||||
error: { code: 'NOTIFICATION_NOT_FOUND' }
|
||||
});
|
||||
const fd = new FormData();
|
||||
fd.set('notificationId', 'n-abc');
|
||||
|
||||
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
||||
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('aktivitaeten/actions — mark-all-read', () => {
|
||||
it('calls POST /api/notifications/read-all', async () => {
|
||||
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
||||
|
||||
await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||
|
||||
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
|
||||
});
|
||||
|
||||
it('returns { success: true } when the API responds ok', async () => {
|
||||
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
||||
|
||||
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
||||
mockApi.POST.mockResolvedValue({
|
||||
response: { ok: false, status: 500 },
|
||||
error: { code: 'INTERNAL_ERROR' }
|
||||
});
|
||||
|
||||
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
||||
|
||||
expect(result).toMatchObject({ status: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('korrespondenz load — no senderId', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/briefwechsel'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: readUser }
|
||||
});
|
||||
@@ -69,6 +70,7 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: 'p1' }),
|
||||
request: new Request('http://localhost/briefwechsel'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: readUser }
|
||||
});
|
||||
@@ -108,6 +110,7 @@ describe('korrespondenz load — senderId and receiverId set', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: 'p1', receiverId: 'p2' }),
|
||||
request: new Request('http://localhost/briefwechsel'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: readUser }
|
||||
});
|
||||
@@ -137,6 +140,7 @@ describe('korrespondenz load — canWrite', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: 'p1' }),
|
||||
request: new Request('http://localhost/briefwechsel'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: writeUser }
|
||||
});
|
||||
@@ -160,6 +164,7 @@ describe('korrespondenz load — canWrite', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: 'p1' }),
|
||||
request: new Request('http://localhost/briefwechsel'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: readUser }
|
||||
});
|
||||
@@ -188,6 +193,7 @@ describe('korrespondenz load — backend error', () => {
|
||||
await expect(
|
||||
load({
|
||||
url: makeUrl({ senderId: 'p1' }),
|
||||
request: new Request('http://localhost/briefwechsel'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
locals: { user: readUser }
|
||||
})
|
||||
|
||||
@@ -23,7 +23,9 @@ describe('document detail load — happy path', () => {
|
||||
|
||||
const result = await load({
|
||||
params: { id: '123' },
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
fetch: mockFetch as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/documents/123'),
|
||||
url: new URL('http://localhost/documents/123')
|
||||
});
|
||||
|
||||
expect(result.document.title).toBe('Testbrief');
|
||||
@@ -44,7 +46,12 @@ describe('document detail load — error paths', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
|
||||
load({
|
||||
params: { id: 'missing' },
|
||||
fetch: mockFetch as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/documents/123'),
|
||||
url: new URL('http://localhost/documents/123')
|
||||
})
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
@@ -59,7 +66,12 @@ describe('document detail load — error paths', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
|
||||
load({
|
||||
params: { id: 'secret' },
|
||||
fetch: mockFetch as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/documents/123'),
|
||||
url: new URL('http://localhost/documents/123')
|
||||
})
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
@@ -74,7 +86,12 @@ describe('document detail load — error paths', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
|
||||
load({
|
||||
params: { id: 'any' },
|
||||
fetch: mockFetch as unknown as typeof fetch,
|
||||
request: new Request('http://localhost/documents/123'),
|
||||
url: new URL('http://localhost/documents/123')
|
||||
})
|
||||
).rejects.toMatchObject({ location: '/login' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
||||
const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } };
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
await load({
|
||||
locals,
|
||||
request: new Request('http://localhost/documents/bulk-edit'),
|
||||
url: new URL('http://localhost/documents/bulk-edit')
|
||||
});
|
||||
throw new Error('expected redirect to be thrown');
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; location?: string };
|
||||
@@ -19,7 +23,11 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
||||
const locals = { user: { groups: [] } };
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
await load({
|
||||
locals,
|
||||
request: new Request('http://localhost/documents/bulk-edit'),
|
||||
url: new URL('http://localhost/documents/bulk-edit')
|
||||
});
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
@@ -30,7 +38,11 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
||||
const locals = {};
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
await load({
|
||||
locals,
|
||||
request: new Request('http://localhost/documents/bulk-edit'),
|
||||
url: new URL('http://localhost/documents/bulk-edit')
|
||||
});
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
@@ -40,7 +52,11 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
||||
it('returns canWrite=true for a WRITE_ALL user', async () => {
|
||||
const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } };
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
const result = await load({ locals });
|
||||
const result = await load({
|
||||
locals,
|
||||
request: new Request('http://localhost/documents/bulk-edit'),
|
||||
url: new URL('http://localhost/documents/bulk-edit')
|
||||
});
|
||||
expect(result).toEqual({ canWrite: true });
|
||||
});
|
||||
|
||||
@@ -52,7 +68,11 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
||||
};
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
await load({
|
||||
locals,
|
||||
request: new Request('http://localhost/documents/bulk-edit'),
|
||||
url: new URL('http://localhost/documents/bulk-edit')
|
||||
});
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('documents page load — search params', () => {
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -57,6 +58,7 @@ describe('documents page load — search params', () => {
|
||||
|
||||
await load({
|
||||
url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -81,6 +83,7 @@ describe('documents page load — search params', () => {
|
||||
|
||||
await load({
|
||||
url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -111,6 +114,7 @@ describe('documents page load — search params', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'test' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -129,6 +133,7 @@ describe('documents page load — search params', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -148,7 +153,11 @@ describe('documents page load — auth redirect', () => {
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
|
||||
load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
).rejects.toMatchObject({ location: '/login' });
|
||||
});
|
||||
});
|
||||
@@ -161,7 +170,11 @@ describe('documents page load — network error fallback', () => {
|
||||
GET: vi.fn().mockRejectedValue(new Error('Network failure'))
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.items).toEqual([]);
|
||||
@@ -199,6 +212,7 @@ describe('documents page load — person name resolution', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -210,6 +224,7 @@ describe('documents page load — person name resolution', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -221,6 +236,7 @@ describe('documents page load — person name resolution', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: 'not-a-uuid' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
@@ -234,6 +250,7 @@ describe('documents page load — person name resolution', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ let {
|
||||
{#if form?.error}
|
||||
{#if form?.rateLimited}
|
||||
<div
|
||||
aria-invalid="true"
|
||||
role="alert"
|
||||
class="flex items-center gap-2 font-sans text-xs font-medium text-red-600"
|
||||
>
|
||||
@@ -118,7 +117,7 @@ let {
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="h-4 w-4 shrink-0 text-ink-3"
|
||||
class="h-4 w-4 shrink-0 text-red-600"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -129,7 +128,9 @@ let {
|
||||
<span>{form.error}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||
<div role="alert" class="text-center font-sans text-xs font-medium text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -70,4 +70,16 @@ describe('login page', () => {
|
||||
.element(page.getByRole('link', { name: /passwort vergessen/i }))
|
||||
.toHaveAttribute('href', '/forgot-password');
|
||||
});
|
||||
|
||||
it('shows rate-limit alert with clock icon when rateLimited is true', async () => {
|
||||
render(LoginPage, {
|
||||
props: {
|
||||
data: { registered: false },
|
||||
form: { error: 'Zu viele Anmeldeversuche.', rateLimited: true }
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
await expect.element(page.getByText('Zu viele Anmeldeversuche.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ it('never calls /api/documents/search regardless of URL params', async () => {
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -49,6 +50,7 @@ it('always fetches dashboard data regardless of URL params', async () => {
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'Urlaub' }),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -110,6 +112,7 @@ describe('home page load — dashboard', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -147,6 +150,7 @@ describe('home page load — dashboard', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -168,6 +172,7 @@ describe('home page load — dashboard', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -189,6 +194,7 @@ describe('home page load — dashboard', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -213,6 +219,7 @@ describe('home page load — dashboard', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -232,6 +239,7 @@ describe('home page load — auth redirect', () => {
|
||||
await expect(
|
||||
load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0])
|
||||
@@ -249,6 +257,7 @@ describe('home page load — network error fallback', () => {
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: contributorParent()
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -268,6 +277,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
|
||||
await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: vi
|
||||
.fn()
|
||||
@@ -289,6 +299,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
|
||||
await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: vi
|
||||
.fn()
|
||||
@@ -310,6 +321,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
|
||||
await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: vi
|
||||
.fn()
|
||||
@@ -332,6 +344,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
|
||||
await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true })
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -352,6 +365,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: vi
|
||||
.fn()
|
||||
@@ -369,6 +383,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false })
|
||||
} as Parameters<typeof load>[0]);
|
||||
@@ -398,6 +413,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/'),
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
parent: vi
|
||||
.fn()
|
||||
|
||||
@@ -32,7 +32,13 @@ describe('person detail load — happy path', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||
const result = await load({
|
||||
params: { id: 'p1' },
|
||||
fetch: mockFetch,
|
||||
request: new Request('http://localhost/persons/p1'),
|
||||
url: new URL('http://localhost/persons/p1'),
|
||||
locals: mockLocals
|
||||
});
|
||||
|
||||
expect(result.person.firstName).toBe('Hans');
|
||||
expect(result.sentDocuments).toHaveLength(1);
|
||||
@@ -55,7 +61,13 @@ describe('person detail load — happy path', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
||||
const result = await load({
|
||||
params: { id: 'p1' },
|
||||
fetch: mockFetch,
|
||||
request: new Request('http://localhost/persons/p1'),
|
||||
url: new URL('http://localhost/persons/p1'),
|
||||
locals: mockLocalsWriter
|
||||
});
|
||||
|
||||
expect(result.canWrite).toBe(true);
|
||||
});
|
||||
@@ -76,7 +88,13 @@ describe('person detail load — happy path', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||
const result = await load({
|
||||
params: { id: 'p1' },
|
||||
fetch: mockFetch,
|
||||
request: new Request('http://localhost/persons/p1'),
|
||||
url: new URL('http://localhost/persons/p1'),
|
||||
locals: mockLocals
|
||||
});
|
||||
|
||||
expect(result.sentDocuments).toEqual([]);
|
||||
expect(result.receivedDocuments).toEqual([]);
|
||||
@@ -100,7 +118,13 @@ describe('person detail load — error paths', () => {
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
load({ params: { id: 'missing' }, fetch: mockFetch, locals: mockLocals })
|
||||
load({
|
||||
params: { id: 'missing' },
|
||||
fetch: mockFetch,
|
||||
request: new Request('http://localhost/persons/p1'),
|
||||
url: new URL('http://localhost/persons/p1'),
|
||||
locals: mockLocals
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
status: 404
|
||||
});
|
||||
@@ -120,7 +144,13 @@ describe('person detail load — error paths', () => {
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals })
|
||||
load({
|
||||
params: { id: 'forbidden' },
|
||||
fetch: mockFetch,
|
||||
request: new Request('http://localhost/persons/p1'),
|
||||
url: new URL('http://localhost/persons/p1'),
|
||||
locals: mockLocals
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
status: 403
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.",
|
||||
"matchPackageNames": ["com.bucket4j:bucket4j-core"],
|
||||
"groupName": "bucket4j",
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@tiptap/"],
|
||||
"groupName": "tiptap",
|
||||
|
||||
Reference in New Issue
Block a user