Compare commits
246 Commits
82e81e159a
...
docs/impor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5efe3b8a7c | ||
|
|
0f1f9055c3 | ||
|
|
8cac63e938 | ||
|
|
97db718f81 | ||
|
|
06127724de | ||
|
|
7c017eca2a | ||
|
|
97ab9e38df | ||
|
|
f10b80a03f | ||
|
|
6478cc58ae | ||
|
|
a7c45b3a0e | ||
|
|
5ff0c25e10 | ||
|
|
7ba3a29592 | ||
|
|
d314fd9338 | ||
|
|
18d5a1e2da | ||
|
|
df00ea4238 | ||
|
|
ff1a7c07f1 | ||
|
|
366b484815 | ||
|
|
88c8063227 | ||
|
|
3066d3d3ff | ||
|
|
3e7ddea90a | ||
|
|
75b3ca8b9e | ||
|
|
74c4c390fc | ||
|
|
29087319e6 | ||
|
|
53457d9319 | ||
|
|
2d97595e9c | ||
|
|
a177077b40 | ||
|
|
b7a2332861 | ||
|
|
1da1a8d223 | ||
|
|
59715bdccd | ||
|
|
53a661adb6 | ||
|
|
4942c0ea07 | ||
|
|
7edc002ebb | ||
|
|
b43dd6cdd4 | ||
|
|
cff486dda7 | ||
|
|
df14e6b1ee | ||
|
|
1908dde859 | ||
|
|
4845e7a3c1 | ||
|
|
c6cceec6e9 | ||
|
|
8f6f4f2d62 | ||
|
|
6f7aa643c9 | ||
|
|
adfff420a5 | ||
|
|
8e9e3bba06 | ||
|
|
627fc44d99 | ||
|
|
6583226d79 | ||
|
|
41b205becc | ||
|
|
f22dcaecb7 | ||
|
|
1109ab917b | ||
|
|
769984608b | ||
|
|
c282f38170 | ||
|
|
3ea7f0b5b2 | ||
|
|
bcba4dab80 | ||
|
|
a4a3e3b105 | ||
|
|
cac00ed711 | ||
|
|
637829cebc | ||
|
|
4e636b3253 | ||
|
|
ab2708e63b | ||
|
|
ed8e9576e4 | ||
|
|
0958df7768 | ||
|
|
f4ffd8acee | ||
|
|
0801da8df0 | ||
|
|
e0e1578bdd | ||
|
|
2df71beb7e | ||
|
|
2dbb3c37b4 | ||
|
|
67368b4413 | ||
|
|
ddf6cf4cbc | ||
|
|
df952861c4 | ||
|
|
22a5ee816a | ||
|
|
0179e93a4b | ||
|
|
0fc0cbcffd | ||
|
|
549cb15845 | ||
|
|
74ddf16b01 | ||
|
|
ebaedb1af0 | ||
|
|
e75ac8ec45 | ||
|
|
525f091b3a | ||
|
|
d6abf990c7 | ||
|
|
77d59c5d83 | ||
|
|
6c2b9af10b | ||
|
|
2e3744d9ef | ||
|
|
131ed336bc | ||
|
|
3fa3460dbf | ||
|
|
79edb94558 | ||
|
|
52d8dc2b20 | ||
|
|
696b71da5a | ||
|
|
f3e3545d06 | ||
|
|
4bb6685edb | ||
|
|
18c93d4eaa | ||
|
|
eca4f1f0e8 | ||
|
|
4e33f52add | ||
|
|
890f014bb3 | ||
|
|
429ff32eda | ||
|
|
38a4ca2e34 | ||
|
|
b63a2040e3 | ||
|
|
0c4b22291f | ||
|
|
f1a61278f9 | ||
|
|
2914010b68 | ||
|
|
1a7e4ce536 | ||
|
|
3fa0f59529 | ||
|
|
36d50222ec | ||
|
|
d47326d01c | ||
|
|
0af43043ba | ||
|
|
51f7efe333 | ||
|
|
8f0fb89e22 | ||
|
|
9d812572c8 | ||
|
|
4ee36b2047 | ||
|
|
1253e89887 | ||
|
|
197a3e71d5 | ||
|
|
4f469db02e | ||
|
|
9886f2bcac | ||
|
|
006d02a137 | ||
|
|
c89441278f | ||
|
|
5301820a88 | ||
|
|
feb5275a94 | ||
|
|
4037564e65 | ||
|
|
0ef50d0ae1 | ||
|
|
9579391e27 | ||
|
|
720615bb1a | ||
|
|
6fbec80414 | ||
|
|
12416e7704 | ||
|
|
d56e6eadab | ||
|
|
510e406a5e | ||
|
|
711d170607 | ||
|
|
55617722f6 | ||
|
|
47afb9e181 | ||
|
|
db951d80cf | ||
|
|
a47027d67a | ||
|
|
1c94a43cb5 | ||
|
|
a1fc7b13d9 | ||
|
|
033d430688 | ||
|
|
640bdc12db | ||
|
|
93e58be141 | ||
|
|
96e8a07a8c | ||
|
|
f46ae2658f | ||
|
|
6125f50d6d | ||
|
|
197c948a35 | ||
|
|
4a4248e726 | ||
|
|
8210984fe3 | ||
|
|
e1e6d2d4b2 | ||
|
|
5ad5f82864 | ||
|
|
19e2f65a21 | ||
|
|
909f960b2e | ||
|
|
7b282f699d | ||
|
|
392097287c | ||
|
|
728f9cd1b0 | ||
|
|
35fbaf8154 | ||
|
|
978a2b3cdb | ||
|
|
30efb54aac | ||
|
|
dbf74cb91a | ||
|
|
261cbbd867 | ||
|
|
6f862243fd | ||
|
|
3d3c111c2b | ||
|
|
cdd5bfa318 | ||
|
|
85c13b3d46 | ||
|
|
9a460b3c90 | ||
|
|
cdc3e2e4c8 | ||
|
|
e89a90ff66 | ||
|
|
0c0a4830cd | ||
|
|
dd843d76c2 | ||
|
|
9601974db0 | ||
|
|
1782526c99 | ||
|
|
76ef54e064 | ||
|
|
f1d1ac3f1a | ||
|
|
0f48ffede5 | ||
|
|
3e72157ee1 | ||
|
|
e2d3975524 | ||
|
|
59e99f862a | ||
|
|
bb39ca59ec | ||
|
|
6b53cbfc5b | ||
|
|
e3e8373526 | ||
|
|
907a6a6b53 | ||
|
|
f27e2d33a5 | ||
|
|
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 |
@@ -39,6 +39,12 @@ PORT_PROMETHEUS=9090
|
||||
# Grafana admin password — change this before exposing Grafana beyond localhost
|
||||
GRAFANA_ADMIN_PASSWORD=changeme
|
||||
|
||||
# Password for the read-only grafana_reader PostgreSQL role used by the PO
|
||||
# Overview dashboard. Consumed by Flyway V68 (to set the role's password) and
|
||||
# by Grafana's PostgreSQL datasource (to connect). REQUIRED in production —
|
||||
# generate with: openssl rand -hex 32
|
||||
GRAFANA_DB_PASSWORD=changeme-generate-with-openssl-rand-hex-32
|
||||
|
||||
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
|
||||
GLITCHTIP_DOMAIN=http://localhost:3002
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@ name: nightly
|
||||
# STAGING_APP_ADMIN_USERNAME
|
||||
# STAGING_APP_ADMIN_PASSWORD
|
||||
# GRAFANA_ADMIN_PASSWORD
|
||||
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
|
||||
# GLITCHTIP_SECRET_KEY
|
||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||
|
||||
@@ -79,6 +80,8 @@ jobs:
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
||||
POSTGRES_USER=archiv
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Verify backend /import:ro mount is wired
|
||||
@@ -142,6 +145,7 @@ jobs:
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-staging-db-1
|
||||
@@ -252,20 +256,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"
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ name: release
|
||||
# MAIL_USERNAME
|
||||
# MAIL_PASSWORD
|
||||
# GRAFANA_ADMIN_PASSWORD
|
||||
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
|
||||
# GLITCHTIP_SECRET_KEY
|
||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||
|
||||
@@ -77,6 +78,7 @@ jobs:
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
|
||||
POSTGRES_USER=archiv
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Build images
|
||||
@@ -110,6 +112,7 @@ jobs:
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-production-db-1
|
||||
@@ -181,28 +184,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"
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,3 +26,7 @@ node_modules/
|
||||
|
||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||
frontend/yarn.lock
|
||||
|
||||
**/.venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -180,11 +180,16 @@
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine cache for in-memory rate limiting -->
|
||||
<!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||
<dependency>
|
||||
|
||||
@@ -43,8 +43,14 @@ public enum AuditKind {
|
||||
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
||||
LOGIN_FAILED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
|
||||
LOGOUT;
|
||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */
|
||||
LOGOUT,
|
||||
|
||||
/** Payload: {@code {"actorId": "uuid", "targetUserId": "uuid", "revokedCount": 3}} */
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
|
||||
@@ -24,13 +24,18 @@ public class AuthService {
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final UserService userService;
|
||||
private final AuditService auditService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
private final SessionRevocationPort sessionRevocationPort;
|
||||
|
||||
/**
|
||||
* Validates credentials and returns the authenticated user plus the Spring Security
|
||||
* Authentication object. The caller is responsible for persisting the Authentication
|
||||
* to the session via SecurityContextRepository.
|
||||
*/
|
||||
public LoginResult login(String email, String password, String ip, String ua) {
|
||||
try {
|
||||
loginRateLimiter.checkAndConsume(ip, email);
|
||||
} catch (DomainException ex) {
|
||||
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
|
||||
"ip", ip,
|
||||
"email", email));
|
||||
throw ex;
|
||||
}
|
||||
try {
|
||||
Authentication auth = authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(email, password));
|
||||
@@ -40,6 +45,7 @@ public class AuthService {
|
||||
"userId", user.getId().toString(),
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||
return new LoginResult(user, auth);
|
||||
} catch (AuthenticationException ex) {
|
||||
// Audit login failure — intentionally does NOT log the attempted password.
|
||||
@@ -53,6 +59,14 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
|
||||
}
|
||||
|
||||
public int revokeAllSessions(String principalName) {
|
||||
return sessionRevocationPort.revokeAllSessions(principalName);
|
||||
}
|
||||
|
||||
public void logout(String email, String ip, String ua) {
|
||||
AppUser user = userService.findByEmail(email);
|
||||
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginRateLimiter {
|
||||
|
||||
private final LoadingCache<String, Bucket> byIpEmail;
|
||||
private final LoadingCache<String, Bucket> byIp;
|
||||
private final int maxPerIpEmail;
|
||||
private final int maxPerIp;
|
||||
private final int windowMinutes;
|
||||
|
||||
public LoginRateLimiter(RateLimitProperties props) {
|
||||
this.maxPerIpEmail = props.getMaxAttemptsPerIpEmail();
|
||||
this.maxPerIp = props.getMaxAttemptsPerIp();
|
||||
this.windowMinutes = props.getWindowMinutes();
|
||||
|
||||
this.byIpEmail = Caffeine.newBuilder()
|
||||
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||
.build(key -> newBucket(maxPerIpEmail, windowMinutes));
|
||||
|
||||
this.byIp = Caffeine.newBuilder()
|
||||
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||
.build(key -> newBucket(maxPerIp, windowMinutes));
|
||||
}
|
||||
|
||||
// NOTE: This cache is node-local (in-memory). In a multi-replica deployment,
|
||||
// effective limits would be multiplied by replica count.
|
||||
// For the current single-VPS setup this is the correct, simplest implementation.
|
||||
|
||||
public void checkAndConsume(String ip, String email) {
|
||||
long retryAfterSeconds = windowMinutes * 60L;
|
||||
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
|
||||
if (!byIpEmail.get(key).tryConsume(1)) {
|
||||
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||
}
|
||||
if (!byIp.get(ip).tryConsume(1)) {
|
||||
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
|
||||
byIpEmail.get(key).addTokens(1);
|
||||
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public void invalidateOnSuccess(String ip, String email) {
|
||||
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
|
||||
byIp.invalidate(ip);
|
||||
}
|
||||
|
||||
private static Bucket newBucket(int limit, int minutes) {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(limit)
|
||||
.refillGreedy(limit, Duration.ofMinutes(minutes))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties("rate-limit.login")
|
||||
@Data
|
||||
public class RateLimitProperties {
|
||||
private int maxAttemptsPerIpEmail = 10;
|
||||
private int maxAttemptsPerIp = 20;
|
||||
private int windowMinutes = 15;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@@ -14,6 +16,7 @@ import javax.sql.DataSource;
|
||||
public class FlywayConfig {
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final Environment environment;
|
||||
|
||||
@Bean(name = "flyway")
|
||||
public Flyway flyway() {
|
||||
@@ -21,6 +24,7 @@ public class FlywayConfig {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("4")
|
||||
.load();
|
||||
@@ -28,4 +32,22 @@ public class FlywayConfig {
|
||||
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
||||
return flyway;
|
||||
}
|
||||
|
||||
// Fail-closed: refuse to boot when GRAFANA_DB_PASSWORD is unset. The
|
||||
// grafana_reader role's password is (re)set on every boot by
|
||||
// R__grafana_reader_password.sql, so a missing env var means we'd either
|
||||
// skip the rotation silently or — with a hardcoded fallback — publish a
|
||||
// well-known credential for a role with SELECT on audit_log, documents,
|
||||
// and transcription_blocks. Same shape as UserDataInitializer's refusal
|
||||
// to seed default admin credentials outside dev/test/e2e.
|
||||
String resolveGrafanaDbPassword() {
|
||||
String value = environment.getProperty("GRAFANA_DB_PASSWORD");
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"GRAFANA_DB_PASSWORD is required: it is consumed by "
|
||||
+ "R__grafana_reader_password.sql to (re)set the grafana_reader "
|
||||
+ "role's password on every boot. Generate with: openssl rand -hex 32");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,12 @@ import java.util.UUID;
|
||||
@NamedEntityGraph(name = "Document.full", attributeNodes = {
|
||||
@NamedAttributeNode("sender"),
|
||||
@NamedAttributeNode("receivers"),
|
||||
@NamedAttributeNode("tags")
|
||||
@NamedAttributeNode("tags"),
|
||||
@NamedAttributeNode("trainingLabels")
|
||||
})
|
||||
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
||||
@NamedAttributeNode("sender"),
|
||||
@NamedAttributeNode("receivers"),
|
||||
@NamedAttributeNode("tags")
|
||||
})
|
||||
@Entity
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DocumentListItem(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String originalFilename,
|
||||
String thumbnailUrl,
|
||||
LocalDate documentDate,
|
||||
Person sender,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<Person> receivers,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<Tag> tags,
|
||||
String archiveBox,
|
||||
String archiveFolder,
|
||||
String location,
|
||||
String summary,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int completionPercentage,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<ActivityActorDTO> contributors,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
SearchMatchData matchData
|
||||
) {}
|
||||
@@ -44,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)")
|
||||
@@ -69,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 " +
|
||||
@@ -89,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) " +
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DocumentSearchItem(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
Document document,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
SearchMatchData matchData,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int completionPercentage,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<ActivityActorDTO> contributors
|
||||
) {}
|
||||
@@ -7,7 +7,7 @@ import java.util.List;
|
||||
|
||||
public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<DocumentSearchItem> items,
|
||||
List<DocumentListItem> items,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long totalElements,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@@ -21,16 +21,16 @@ public record DocumentSearchResult(
|
||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||
*/
|
||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||
public static DocumentSearchResult of(List<DocumentListItem> items) {
|
||||
int size = items.size();
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged factory used by the service when it has a real Pageable + full match count
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||
*/
|
||||
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
||||
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
|
||||
int pageSize = pageable.getPageSize();
|
||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
|
||||
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
||||
@@ -636,8 +635,6 @@ public class DocumentService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
// @Transactional(readOnly=true) keeps the Hibernate session open so the
|
||||
// lazy-loaded sender and tags on returned documents remain accessible to callers.
|
||||
@Transactional(readOnly = true)
|
||||
public List<Document> getRecentActivity(int size) {
|
||||
return documentRepository.findAll(
|
||||
@@ -738,7 +735,7 @@ public class DocumentService {
|
||||
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||
}
|
||||
|
||||
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
|
||||
private List<DocumentListItem> enrichItems(List<Document> documents, String text) {
|
||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||
|
||||
@@ -746,7 +743,7 @@ public class DocumentService {
|
||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||
|
||||
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
return colorResolved.stream().map(doc -> toListItem(
|
||||
doc,
|
||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||
@@ -754,6 +751,26 @@ public class DocumentService {
|
||||
)).toList();
|
||||
}
|
||||
|
||||
private DocumentListItem toListItem(Document doc, SearchMatchData match, int completionPct, List<ActivityActorDTO> contributors) {
|
||||
return new DocumentListItem(
|
||||
doc.getId(),
|
||||
doc.getTitle(),
|
||||
doc.getOriginalFilename(),
|
||||
doc.getThumbnailUrl(),
|
||||
doc.getDocumentDate(),
|
||||
doc.getSender(),
|
||||
List.copyOf(doc.getReceivers()),
|
||||
List.copyOf(doc.getTags()),
|
||||
doc.getArchiveBox(),
|
||||
doc.getArchiveFolder(),
|
||||
doc.getLocation(),
|
||||
doc.getSummary(),
|
||||
completionPct,
|
||||
contributors,
|
||||
match
|
||||
);
|
||||
}
|
||||
|
||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
||||
}
|
||||
@@ -846,8 +863,6 @@ public class DocumentService {
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
|
||||
// @Transactional(readOnly=true) keeps the Hibernate session open so the
|
||||
// lazy-loaded tags and receivers on the returned document remain accessible to callers.
|
||||
@Transactional(readOnly = true)
|
||||
public Document getDocumentById(UUID id) {
|
||||
Document doc = documentRepository.findById(id)
|
||||
|
||||
@@ -43,7 +43,7 @@ public class TranscriptionBlockController {
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public TranscriptionBlock createBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||
@@ -53,7 +53,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/{blockId}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public TranscriptionBlock updateBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@@ -65,7 +65,7 @@ public class TranscriptionBlockController {
|
||||
|
||||
@DeleteMapping("/{blockId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public void deleteBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId) {
|
||||
@@ -73,7 +73,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/reorder")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public List<TranscriptionBlock> reorderBlocks(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
||||
@@ -82,7 +82,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/{blockId}/review")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public TranscriptionBlock reviewBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@@ -92,7 +92,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/review-all")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||
@PathVariable UUID documentId,
|
||||
Authentication authentication) {
|
||||
|
||||
@@ -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) {
|
||||
@@ -55,4 +70,12 @@ public class DomainException extends RuntimeException {
|
||||
public static DomainException internal(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
||||
}
|
||||
|
||||
public static DomainException tooManyRequests(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message);
|
||||
}
|
||||
|
||||
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ public enum ErrorCode {
|
||||
SESSION_EXPIRED,
|
||||
/** The password-reset token is missing, expired, or already used. 400 */
|
||||
INVALID_RESET_TOKEN,
|
||||
/** CSRF token is missing or does not match the expected value. 403 */
|
||||
CSRF_TOKEN_MISSING,
|
||||
/** The login rate limit has been exceeded for this IP/email combination. 429 */
|
||||
TOO_MANY_LOGIN_ATTEMPTS,
|
||||
|
||||
// --- Annotations ---
|
||||
/** The annotation with the given ID does not exist. 404 */
|
||||
|
||||
@@ -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,41 @@ 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 enum SkipReason {
|
||||
INVALID_FILENAME_PATH_TRAVERSAL,
|
||||
INVALID_PDF_SIGNATURE,
|
||||
FILE_READ_ERROR,
|
||||
ALREADY_EXISTS,
|
||||
S3_UPLOAD_FAILED
|
||||
}
|
||||
|
||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
public record SkippedFile(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) SkipReason reason
|
||||
) {}
|
||||
|
||||
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 +152,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,30 +289,94 @@ 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);
|
||||
if (index.isBlank()) continue;
|
||||
|
||||
String filename = index.contains(".") ? index : index + ".pdf";
|
||||
if (!isValidImportFilename(filename)) {
|
||||
log.warn("Skipping import row {}: filename rejected — {}", i, filename);
|
||||
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_FILENAME_PATH_TRAVERSAL));
|
||||
continue;
|
||||
}
|
||||
Optional<File> fileOnDisk = findFileRecursive(filename);
|
||||
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, SkipReason.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, SkipReason.FILE_READ_ERROR));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<SkipReason> 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);
|
||||
}
|
||||
|
||||
private boolean isValidImportFilename(String filename) {
|
||||
if (filename == null || filename.isBlank()) return false;
|
||||
if (filename.contains("/")) return false;
|
||||
if (filename.contains("\\")) return false;
|
||||
if (filename.contains("∕")) return false; // U+2215 DIVISION SLASH
|
||||
if (filename.contains("/")) return false; // U+FF0F FULLWIDTH SOLIDUS
|
||||
if (filename.contains("⧵")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
|
||||
if (filename.contains("..")) return false;
|
||||
if (filename.equals(".")) return false;
|
||||
if (filename.contains("\0")) return false;
|
||||
// Paths.get() is safe here on Linux for all inputs that passed the checks above;
|
||||
// it may throw InvalidPathException for OS-specific illegal chars on Windows,
|
||||
// but those are not reachable in production.
|
||||
if (Paths.get(filename).isAbsolute()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// package-private: Mockito spy in tests can override to inject IOException
|
||||
InputStream openFileStream(File file) throws IOException {
|
||||
return new FileInputStream(file);
|
||||
}
|
||||
|
||||
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||
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<SkipReason> 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(SkipReason.ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
String archiveBox = getCell(cells, colBox);
|
||||
@@ -313,7 +412,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(SkipReason.S3_UPLOAD_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +454,7 @@ public class MassImportService {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -390,11 +490,18 @@ public class MassImportService {
|
||||
}
|
||||
|
||||
private Optional<File> findFileRecursive(String filename) {
|
||||
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
|
||||
return walk.filter(p -> !Files.isDirectory(p))
|
||||
File baseDir = new File(importDir);
|
||||
try (Stream<Path> walk = Files.walk(baseDir.toPath())) {
|
||||
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
|
||||
.filter(p -> p.getFileName().toString().equals(filename))
|
||||
.map(Path::toFile)
|
||||
.findFirst();
|
||||
if (match.isEmpty()) return Optional.empty();
|
||||
File candidate = match.get().toFile();
|
||||
String baseDirCanonical = baseDir.getCanonicalPath();
|
||||
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
|
||||
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
|
||||
}
|
||||
return Optional.of(candidate);
|
||||
} catch (IOException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -19,12 +21,22 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
|
||||
// cannot be injected here. A static instance is safe because the response
|
||||
// only serializes fixed String keys — no custom naming strategy or module needed.
|
||||
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final Environment environment;
|
||||
|
||||
@@ -78,15 +90,13 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF is intentionally disabled. The session model relies on:
|
||||
// 1. SameSite=Strict on the fa_session cookie — a cross-site POST from
|
||||
// evil.com cannot include the cookie.
|
||||
// 2. CORS — Spring's default rejects cross-origin requests with credentials
|
||||
// unless explicitly allowed (no allowedOrigins config).
|
||||
//
|
||||
// If either of those is ever weakened, CSRF protection MUST be re-enabled.
|
||||
// Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524).
|
||||
.csrf(csrf -> csrf.disable())
|
||||
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
||||
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
||||
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
||||
// See ADR-022 and issue #524 for the full security rationale.
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||
@@ -112,10 +122,18 @@ public class SecurityConfig {
|
||||
// erlaubt pdf im Iframe
|
||||
.headers(headers -> headers
|
||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||
// Return 401 (not 302 redirect to /login) for unauthenticated API requests.
|
||||
// httpBasic and formLogin are removed — authentication is via Spring Session only.
|
||||
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)));
|
||||
// Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures.
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(
|
||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
|
||||
.accessDeniedHandler((req, res, e) -> {
|
||||
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
res.setContentType("application/json;charset=UTF-8");
|
||||
ErrorCode code = (e instanceof CsrfException)
|
||||
? ErrorCode.CSRF_TOKEN_MISSING
|
||||
: ErrorCode.FORBIDDEN;
|
||||
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||
}));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -32,6 +33,7 @@ public class PasswordResetService {
|
||||
private final UserService userService;
|
||||
private final PasswordResetTokenRepository tokenRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AuthService authService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JavaMailSender mailSender;
|
||||
@@ -85,6 +87,8 @@ public class PasswordResetService {
|
||||
|
||||
resetToken.setUsed(true);
|
||||
tokenRepository.save(resetToken);
|
||||
|
||||
authService.revokeAllSessions(user.getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,11 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
||||
@@ -26,13 +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 final UserService userService;
|
||||
private final AuthService authService;
|
||||
private final AuditService auditService;
|
||||
|
||||
@GetMapping("users/me")
|
||||
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
||||
@@ -56,9 +62,14 @@ public class UserController {
|
||||
@PostMapping("users/me/password")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void changePassword(Authentication authentication,
|
||||
HttpSession session,
|
||||
@RequestBody ChangePasswordDTO dto) {
|
||||
AppUser current = userService.findByEmail(authentication.getName());
|
||||
userService.changePassword(current.getId(), dto);
|
||||
int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName());
|
||||
auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of(
|
||||
"reason", "password_change",
|
||||
"revokedCount", revoked));
|
||||
}
|
||||
|
||||
@GetMapping("users/{id}")
|
||||
@@ -101,6 +112,18 @@ public class UserController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/force-logout")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<Map<String, Object>> forceLogout(Authentication authentication,
|
||||
@PathVariable UUID id) {
|
||||
AppUser target = userService.getById(id);
|
||||
int revoked = authService.revokeAllSessions(target.getEmail());
|
||||
auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of(
|
||||
"targetUserId", target.getId().toString(),
|
||||
"revokedCount", revoked));
|
||||
return ResponseEntity.ok(Map.of("revokedCount", revoked));
|
||||
}
|
||||
|
||||
private UUID actorId(Authentication auth) {
|
||||
return userService.findByEmail(auth.getName()).getId();
|
||||
}
|
||||
|
||||
@@ -150,3 +150,9 @@ sentry:
|
||||
enable-tracing: true
|
||||
ignored-exceptions-for-type:
|
||||
- org.raddatz.familienarchiv.exception.DomainException
|
||||
|
||||
rate-limit:
|
||||
login:
|
||||
max-attempts-per-ip-email: 10
|
||||
max-attempts-per-ip: 20
|
||||
window-minutes: 15
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Repeatable migration: sets the grafana_reader role's password from the
|
||||
-- ${grafanaDbPassword} placeholder (resolved by FlywayConfig from the
|
||||
-- GRAFANA_DB_PASSWORD environment variable). Flyway computes the checksum on
|
||||
-- the resolved migration content, so any change to GRAFANA_DB_PASSWORD changes
|
||||
-- the checksum and re-applies this migration on the next boot. That makes
|
||||
-- password rotation a "change env var + restart" operation — no manual psql.
|
||||
--
|
||||
-- V68 created the role itself (without a usable password). This file owns the
|
||||
-- password lifecycle; nothing else writes it.
|
||||
DO $$
|
||||
BEGIN
|
||||
EXECUTE format('ALTER ROLE grafana_reader WITH PASSWORD %L', '${grafanaDbPassword}');
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview
|
||||
-- dashboard (issue #651). The role is created here without a usable password
|
||||
-- (LOGIN-capable but no password set); R__grafana_reader_password.sql sets the
|
||||
-- password from GRAFANA_DB_PASSWORD on every boot, so rotation is just "bump
|
||||
-- the env var and restart the backend" — see docs/adr/024-* and the rotation
|
||||
-- runbook in docs/DEPLOYMENT.md.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN
|
||||
CREATE ROLE grafana_reader WITH LOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
GRANT CONNECT ON DATABASE ${flyway:database} TO grafana_reader;
|
||||
GRANT USAGE ON SCHEMA public TO grafana_reader;
|
||||
GRANT SELECT ON audit_log, documents, transcription_blocks TO grafana_reader;
|
||||
@@ -16,7 +16,6 @@ import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -31,6 +30,8 @@ class AuthServiceTest {
|
||||
@Mock AuthenticationManager authenticationManager;
|
||||
@Mock UserService userService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock LoginRateLimiter loginRateLimiter;
|
||||
@Mock SessionRevocationPort sessionRevocationPort;
|
||||
@InjectMocks AuthService authService;
|
||||
|
||||
private static final String IP = "127.0.0.1";
|
||||
@@ -129,4 +130,62 @@ class AuthServiceTest {
|
||||
&& !payload.containsKey("password"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_checks_rate_limit_before_authenticating() {
|
||||
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||
|
||||
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
|
||||
verify(authenticationManager, never()).authenticate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
|
||||
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||
|
||||
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(),
|
||||
argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_invalidates_rate_limit_on_success() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||
|
||||
authService.login("user@test.de", "pass123", IP, UA);
|
||||
|
||||
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeOtherSessions_delegates_to_port() {
|
||||
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
|
||||
|
||||
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeAllSessions_delegates_to_port() {
|
||||
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
|
||||
|
||||
int count = authService.revokeAllSessions("user@test.de");
|
||||
|
||||
assertThat(count).isEqualTo(3);
|
||||
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.UUID;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
@@ -48,6 +49,7 @@ class AuthSessionControllerTest {
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -61,6 +63,7 @@ class AuthSessionControllerTest {
|
||||
.thenThrow(DomainException.invalidCredentials());
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -77,6 +80,7 @@ class AuthSessionControllerTest {
|
||||
|
||||
// No WithMockUser — must be reachable without an active session
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -91,6 +95,7 @@ class AuthSessionControllerTest {
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -116,6 +121,7 @@ class AuthSessionControllerTest {
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -131,12 +137,24 @@ class AuthSessionControllerTest {
|
||||
.thenThrow(DomainException.invalidCredentials());
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||
}
|
||||
|
||||
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void authenticated_post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
// Red test: CSRF disabled → returns 204; after re-enabling returns 403.
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("user@test.de"))) // authenticated but no CSRF token
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||
}
|
||||
|
||||
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -144,15 +162,18 @@ class AuthSessionControllerTest {
|
||||
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
||||
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("user@test.de")))
|
||||
.with(user("user@test.de"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_returns_401_when_not_authenticated() throws Exception {
|
||||
// No authentication at all — Spring Security must return 401
|
||||
void logout_without_session_returns_403() throws Exception {
|
||||
// CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null,
|
||||
// ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403.
|
||||
mockMvc.perform(post("/api/auth/logout"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -163,7 +184,8 @@ class AuthSessionControllerTest {
|
||||
.when(authService).logout(anyString(), anyString(), anyString());
|
||||
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("ghost@test.de")))
|
||||
.with(user("ghost@test.de"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void login_sets_opaque_fa_session_cookie() {
|
||||
ResponseEntity<String> response = doLogin();
|
||||
String xsrf = fetchXsrfToken();
|
||||
ResponseEntity<String> response = doLogin(xsrf);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
String cookie = extractFaSessionCookie(response);
|
||||
@@ -73,7 +74,8 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void session_cookie_authenticates_subsequent_request() {
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
String xsrf = fetchXsrfToken();
|
||||
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||
|
||||
ResponseEntity<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
@@ -84,16 +86,17 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
String xsrf = fetchXsrfToken();
|
||||
String sessionCookie = extractFaSessionCookie(doLogin(xsrf));
|
||||
|
||||
ResponseEntity<Void> logout = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>(cookieHeaders(cookie)), Void.class);
|
||||
new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class);
|
||||
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||
|
||||
ResponseEntity<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||
new HttpEntity<>(cookieHeaders(sessionCookie)), String.class);
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@@ -101,7 +104,8 @@ class AuthSessionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void session_expired_by_idle_timeout_returns_401() {
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
String xsrf = fetchXsrfToken();
|
||||
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||
|
||||
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
||||
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
||||
@@ -115,11 +119,37 @@ class AuthSessionIntegrationTest {
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
// ─── Task: CSRF rejection at integration layer ────────────────────────────
|
||||
|
||||
private ResponseEntity<String> doLogin() {
|
||||
@Test
|
||||
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
|
||||
ResponseEntity<String> response = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>("{}", headers), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an XSRF token for use in integration tests.
|
||||
* CookieCsrfTokenRepository validates that Cookie: XSRF-TOKEN=X matches X-XSRF-TOKEN: X.
|
||||
* By supplying both with the same value we simulate exactly what a browser does.
|
||||
*/
|
||||
private String fetchXsrfToken() {
|
||||
return java.util.UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
private ResponseEntity<String> doLogin(String xsrfToken) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrfToken);
|
||||
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
||||
return http.postForEntity(baseUrl + "/api/auth/login",
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
@@ -131,6 +161,13 @@ class AuthSessionIntegrationTest {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private HttpHeaders csrfAndSessionHeaders(String sessionId, String xsrfToken) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrfToken);
|
||||
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
@@ -141,6 +178,7 @@ class AuthSessionIntegrationTest {
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LoginRateLimiterTest {
|
||||
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RateLimitProperties props = new RateLimitProperties();
|
||||
props.setMaxAttemptsPerIpEmail(10);
|
||||
props.setMaxAttemptsPerIp(20);
|
||||
props.setWindowMinutes(15);
|
||||
rateLimiter = new LoginRateLimiter(props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenth_attempt_from_same_ip_email_succeeds() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getRetryAfterSeconds())
|
||||
.isEqualTo(15 * 60L)); // windowMinutes=15 → 900 seconds
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_after_10_failures_resets_ip_email_bucket() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");
|
||||
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
|
||||
// Use a tighter limiter so the phantom-consumption effect is observable.
|
||||
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
|
||||
RateLimitProperties props = new RateLimitProperties();
|
||||
props.setMaxAttemptsPerIpEmail(3);
|
||||
props.setMaxAttemptsPerIp(3);
|
||||
props.setWindowMinutes(15);
|
||||
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
|
||||
|
||||
// Exhaust the per-IP bucket using "user@"
|
||||
for (int i = 0; i < 3; i++) {
|
||||
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||
}
|
||||
|
||||
// Three blocked attempts for "target@" while IP is exhausted
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
|
||||
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||
|
||||
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
|
||||
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
|
||||
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
|
||||
assertThatNoException().isThrownBy(
|
||||
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FlywayConfigTest {
|
||||
|
||||
@Test
|
||||
void resolveGrafanaDbPassword_throws_when_env_unset() {
|
||||
FlywayConfig config = new FlywayConfig(null, new MockEnvironment());
|
||||
|
||||
assertThatThrownBy(config::resolveGrafanaDbPassword)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveGrafanaDbPassword_throws_when_env_blank() {
|
||||
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", " ");
|
||||
FlywayConfig config = new FlywayConfig(null, env);
|
||||
|
||||
assertThatThrownBy(config::resolveGrafanaDbPassword)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveGrafanaDbPassword_returns_value_when_env_set() {
|
||||
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", "abc");
|
||||
FlywayConfig config = new FlywayConfig(null, env);
|
||||
|
||||
assertThat(config.resolveGrafanaDbPassword()).isEqualTo("abc");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
// GRAFANA_DB_PASSWORD is supplied via the global test default in
|
||||
// src/test/resources/application.properties — FlywayConfig fails closed
|
||||
// when it is unset, so all tests that load the migration path need it.
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class GrafanaReaderRoleIntegrationTest {
|
||||
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
|
||||
// --- positive grants (SELECT on the three explicitly granted tables) ---
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_select_on_audit_log() {
|
||||
assertThat(hasPrivilege("audit_log", "SELECT")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_select_on_documents() {
|
||||
assertThat(hasPrivilege("documents", "SELECT")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_select_on_transcription_blocks() {
|
||||
assertThat(hasPrivilege("transcription_blocks", "SELECT")).isTrue();
|
||||
}
|
||||
|
||||
// --- write-deny on the granted tables: SELECT-only means SELECT-only.
|
||||
// A future migration that GRANTs INSERT/UPDATE/DELETE on any of these
|
||||
// would fail these tests, even though the original positive grants still
|
||||
// pass. Locks the boundary in both directions.
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_no_INSERT_on_documents() {
|
||||
assertThat(hasPrivilege("documents", "INSERT")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_no_UPDATE_on_audit_log() {
|
||||
assertThat(hasPrivilege("audit_log", "UPDATE")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_no_DELETE_on_transcription_blocks() {
|
||||
assertThat(hasPrivilege("transcription_blocks", "DELETE")).isFalse();
|
||||
}
|
||||
|
||||
// --- negative grants: PII / sensitive tables MUST NOT be readable.
|
||||
// The parameterized form catches the "someone widened the grant to
|
||||
// ALL TABLES IN SCHEMA public" footgun — three specific positive grants
|
||||
// would still pass while this sweep turns red.
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"app_users",
|
||||
"user_groups",
|
||||
"persons",
|
||||
"notifications",
|
||||
"document_comments",
|
||||
"document_annotations",
|
||||
"geschichten"
|
||||
})
|
||||
void grafana_reader_has_no_SELECT_on_protected_table(String table) {
|
||||
assertThat(hasPrivilege(table, "SELECT")).isFalse();
|
||||
}
|
||||
|
||||
private boolean hasPrivilege(String table, String privilege) {
|
||||
Boolean result = jdbc.queryForObject(
|
||||
"SELECT has_table_privilege('grafana_reader', ?, ?)",
|
||||
Boolean.class,
|
||||
table,
|
||||
privilege);
|
||||
return Boolean.TRUE.equals(result);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.document.SearchMatchData;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -44,10 +43,12 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(DocumentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -128,16 +129,13 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void search_responseBodyItemsContainMatchData() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
.title("Brief an Anna")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
0, List.of(), matchData))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -146,6 +144,27 @@ class DocumentControllerTest {
|
||||
.value("Er schrieb einen langen Brief"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
0, List.of(), matchData))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
// flat id field present at top of item (not nested under $.items[0].document.id)
|
||||
.andExpect(jsonPath("$.items[0].id").value(docId.toString()))
|
||||
// sensitive storage fields must never appear in list response
|
||||
.andExpect(jsonPath("$.items[0].transcription").doesNotExist())
|
||||
.andExpect(jsonPath("$.items[0].filePath").doesNotExist())
|
||||
.andExpect(jsonPath("$.items[0].fileHash").doesNotExist());
|
||||
}
|
||||
|
||||
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -214,14 +233,14 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -235,7 +254,7 @@ class DocumentControllerTest {
|
||||
.build();
|
||||
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@@ -244,7 +263,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -252,7 +271,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -269,7 +288,7 @@ class DocumentControllerTest {
|
||||
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id)
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@@ -278,7 +297,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + UUID.randomUUID()))
|
||||
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -286,7 +305,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser
|
||||
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + UUID.randomUUID()))
|
||||
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -295,7 +314,7 @@ class DocumentControllerTest {
|
||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + id))
|
||||
.delete("/api/documents/" + id).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -303,14 +322,14 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -326,7 +345,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
@@ -345,7 +364,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
||||
@@ -360,7 +379,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
||||
@@ -490,7 +509,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
@@ -640,7 +659,7 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -649,7 +668,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -659,7 +678,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||
.andExpect(status().isNoContent());
|
||||
@@ -671,7 +690,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
|
||||
.andExpect(status().isNoContent());
|
||||
@@ -682,7 +701,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -696,7 +715,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file))
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -713,7 +732,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(id.toString()))
|
||||
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
||||
@@ -726,7 +745,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile))
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -743,7 +762,7 @@ class DocumentControllerTest {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@@ -800,7 +819,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created.length()").value(3))
|
||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||
@@ -827,7 +846,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||
@@ -859,7 +878,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||
@@ -883,7 +902,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -904,7 +923,7 @@ class DocumentControllerTest {
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||
@@ -926,7 +945,7 @@ class DocumentControllerTest {
|
||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||
}
|
||||
|
||||
mockMvc.perform(builder)
|
||||
mockMvc.perform(builder.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||
}
|
||||
@@ -945,7 +964,7 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -954,7 +973,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -965,7 +984,7 @@ class DocumentControllerTest {
|
||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"documentIds\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -976,7 +995,7 @@ class DocumentControllerTest {
|
||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -990,7 +1009,7 @@ class DocumentControllerTest {
|
||||
String[] ids = new String[501];
|
||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -1009,7 +1028,7 @@ class DocumentControllerTest {
|
||||
String tooLong = "x".repeat(256);
|
||||
|
||||
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -1025,7 +1044,7 @@ class DocumentControllerTest {
|
||||
String[] ids = new String[500];
|
||||
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1042,7 +1061,7 @@ class DocumentControllerTest {
|
||||
|
||||
// Same id sent three times — controller should dedupe and call the
|
||||
// service exactly once, returning updated=1, not 3.
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1061,7 +1080,7 @@ class DocumentControllerTest {
|
||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id1.toString(), id2.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1137,7 +1156,7 @@ class DocumentControllerTest {
|
||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -1146,7 +1165,7 @@ class DocumentControllerTest {
|
||||
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -1155,7 +1174,7 @@ class DocumentControllerTest {
|
||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[]}"))
|
||||
.content("{\"ids\":[]}").with(csrf()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -1172,7 +1191,7 @@ class DocumentControllerTest {
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(sb.toString()))
|
||||
.content(sb.toString()).with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
@@ -1187,7 +1206,7 @@ class DocumentControllerTest {
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
||||
.content("{\"ids\":[\"" + id + "\"]}").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||
@@ -1208,7 +1227,7 @@ class DocumentControllerTest {
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
||||
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1232,7 +1251,7 @@ class DocumentControllerTest {
|
||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(okId.toString(), badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
@@ -1337,4 +1356,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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ 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)
|
||||
@@ -70,12 +81,16 @@ class DocumentLazyLoadingTest {
|
||||
|
||||
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(() -> {
|
||||
assertThat(result.getTags()).isNotEmpty();
|
||||
result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
|
||||
assertThat(result.getReceivers()).isNotEmpty();
|
||||
result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
|
||||
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
|
||||
@@ -88,11 +103,15 @@ class DocumentLazyLoadingTest {
|
||||
|
||||
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 -> assertThat(d.getSender()).isNotNull());
|
||||
results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull());
|
||||
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
|
||||
@@ -108,7 +127,7 @@ class DocumentLazyLoadingTest {
|
||||
PageRequest.of(0, 20));
|
||||
assertThat(result.totalElements()).isGreaterThan(0);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> i.document().getSender().getLastName()))
|
||||
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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.ocr.TrainingLabel;
|
||||
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.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* AC #2: Document with trainingLabels does not cause LazyInitializationException in search.
|
||||
* AC #3: Detail API still returns trainingLabels after the Document.list graph change.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class DocumentListItemIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@MockitoBean
|
||||
AuditLogQueryService auditLogQueryService;
|
||||
|
||||
@Autowired
|
||||
DocumentRepository documentRepository;
|
||||
|
||||
@Autowired
|
||||
DocumentService documentService;
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
documentRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_doesNotThrow_whenDocumentHasTrainingLabels() {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Kurrent Brief")
|
||||
.originalFilename("kurrent.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||
.build());
|
||||
|
||||
assertThatCode(() -> documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_returns_list_item_without_sensitive_fields_when_document_has_training_labels() {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Kurrent Brief")
|
||||
.originalFilename("kurrent2.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||
.build());
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isGreaterThan(0);
|
||||
DocumentListItem item = result.items().get(0);
|
||||
assertThat(item.id()).isNotNull();
|
||||
assertThat(item.title()).isEqualTo("Kurrent Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detail_stillReturnsTrainingLabels() {
|
||||
Document saved = documentRepository.save(Document.builder()
|
||||
.title("Detail Test")
|
||||
.originalFilename("detail_test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||
.build());
|
||||
|
||||
// Document.full entity graph (used by getDocumentById) must still load trainingLabels
|
||||
Document loaded = documentService.getDocumentById(saved.getId());
|
||||
|
||||
assertThat(loaded.getTrainingLabels()).containsExactly(TrainingLabel.KURRENT_RECOGNITION);
|
||||
}
|
||||
}
|
||||
@@ -125,10 +125,10 @@ class DocumentSearchPagedIntegrationTest {
|
||||
|
||||
// No document id should appear on both pages — slicing must be exclusive.
|
||||
var idsOnPage0 = page0.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.map(item -> item.id())
|
||||
.toList();
|
||||
var idsOnPage1 = page1.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.map(item -> item.id())
|
||||
.toList();
|
||||
for (UUID id : idsOnPage0) {
|
||||
assertThat(idsOnPage1).doesNotContain(id);
|
||||
|
||||
@@ -3,8 +3,6 @@ package org.raddatz.familienarchiv.document;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.util.List;
|
||||
@@ -14,14 +12,11 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DocumentSearchResultTest {
|
||||
|
||||
private DocumentSearchItem item(UUID docId) {
|
||||
Document doc = Document.builder()
|
||||
.id(docId)
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
|
||||
private DocumentListItem item(UUID docId) {
|
||||
return new DocumentListItem(
|
||||
docId, "Test", "test.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
0, List.of(), SearchMatchData.empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -45,7 +40,7 @@ class DocumentSearchResultTest {
|
||||
|
||||
@Test
|
||||
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||
List<DocumentListItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||
|
||||
@@ -68,9 +63,10 @@ class DocumentSearchResultTest {
|
||||
void of_exposes_items_with_completion_and_contributors() {
|
||||
UUID id = UUID.randomUUID();
|
||||
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
||||
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
|
||||
DocumentListItem item = new DocumentListItem(
|
||||
id, "T", "t.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
75, List.of(actor), SearchMatchData.empty());
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class DocumentServiceSortTest {
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
|
||||
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
|
||||
}
|
||||
|
||||
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
|
||||
@@ -104,7 +104,7 @@ class DocumentServiceSortTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -121,7 +121,7 @@ class DocumentServiceSortTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
||||
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
|
||||
@@ -156,7 +156,7 @@ class DocumentServiceSortTest {
|
||||
DocumentSort.RELEVANCE, null, null, PAGE);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
|
||||
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
|
||||
}
|
||||
|
||||
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
|
||||
|
||||
@@ -11,7 +11,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.document.DocumentListItem;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
||||
@@ -1444,7 +1444,7 @@ class DocumentServiceTest {
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
assertThat(result.items()).hasSize(50);
|
||||
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
||||
assertThat(result.items().get(0).sender().getLastName()).isEqualTo("L050");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1565,7 +1565,7 @@ class DocumentServiceTest {
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
||||
}
|
||||
|
||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||
@@ -1584,7 +1584,7 @@ class DocumentServiceTest {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
}
|
||||
|
||||
@@ -1607,7 +1607,7 @@ class DocumentServiceTest {
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
.containsExactly("smith doc", "Null lastname doc");
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(AnnotationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -67,7 +68,7 @@ class AnnotationControllerTest {
|
||||
|
||||
@Test
|
||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -76,7 +77,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -92,7 +93,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -101,7 +102,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -133,7 +134,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -143,28 +144,28 @@ class AnnotationControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -174,7 +175,7 @@ class AnnotationControllerTest {
|
||||
|
||||
@Test
|
||||
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -183,7 +184,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -199,7 +200,7 @@ class AnnotationControllerTest {
|
||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isOk())
|
||||
@@ -217,7 +218,7 @@ class AnnotationControllerTest {
|
||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isOk());
|
||||
@@ -229,7 +230,7 @@ class AnnotationControllerTest {
|
||||
when(annotationService.updateAnnotation(any(), any(), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -238,7 +239,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"x\":-0.1,\"y\":0.3}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -247,7 +248,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"width\":0.005}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -256,7 +257,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"height\":0.005}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -265,7 +266,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"x\":1.1}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -276,7 +277,7 @@ class AnnotationControllerTest {
|
||||
@Test
|
||||
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
||||
// authentication == null → resolveUserId returns null
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -294,7 +295,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -312,7 +313,7 @@ class AnnotationControllerTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
@@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(CommentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -70,7 +71,7 @@ class CommentControllerTest {
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||
@@ -79,7 +80,7 @@ class CommentControllerTest {
|
||||
@Test
|
||||
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
@@ -88,7 +89,7 @@ class CommentControllerTest {
|
||||
@WithMockUser
|
||||
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
@@ -101,7 +102,7 @@ class CommentControllerTest {
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -116,7 +117,7 @@ class CommentControllerTest {
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -127,7 +128,7 @@ class CommentControllerTest {
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
@@ -136,7 +137,7 @@ class CommentControllerTest {
|
||||
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
@@ -151,7 +152,7 @@ class CommentControllerTest {
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -166,7 +167,7 @@ class CommentControllerTest {
|
||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
@@ -175,7 +176,7 @@ class CommentControllerTest {
|
||||
|
||||
@Test
|
||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
@@ -187,7 +188,7 @@ class CommentControllerTest {
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
@@ -199,7 +200,7 @@ class CommentControllerTest {
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
@@ -208,14 +209,14 @@ class CommentControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(TranscriptionBlockController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -143,7 +144,7 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -152,7 +153,7 @@ class TranscriptionBlockControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -164,7 +165,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
||||
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -177,7 +178,7 @@ class TranscriptionBlockControllerTest {
|
||||
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -192,7 +193,7 @@ class TranscriptionBlockControllerTest {
|
||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -206,7 +207,7 @@ class TranscriptionBlockControllerTest {
|
||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -217,7 +218,7 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -226,7 +227,7 @@ class TranscriptionBlockControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -243,7 +244,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
||||
.thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isOk())
|
||||
@@ -259,7 +260,7 @@ class TranscriptionBlockControllerTest {
|
||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -272,7 +273,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -286,7 +287,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -297,7 +298,7 @@ class TranscriptionBlockControllerTest {
|
||||
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -307,28 +308,28 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -339,7 +340,7 @@ class TranscriptionBlockControllerTest {
|
||||
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
||||
.when(transcriptionService).deleteBlock(any(), any());
|
||||
|
||||
mockMvc.perform(delete(URL_BLOCK))
|
||||
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@@ -347,7 +348,7 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_REORDER)
|
||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REORDER_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -356,7 +357,7 @@ class TranscriptionBlockControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REORDER)
|
||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REORDER_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -367,7 +368,7 @@ class TranscriptionBlockControllerTest {
|
||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
||||
|
||||
mockMvc.perform(put(URL_REORDER)
|
||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REORDER_JSON))
|
||||
.andExpect(status().isOk())
|
||||
@@ -434,7 +435,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
||||
|
||||
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
||||
DOC_ID, BLOCK_ID))
|
||||
DOC_ID, BLOCK_ID).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reviewed").value(true));
|
||||
}
|
||||
@@ -445,14 +446,14 @@ class TranscriptionBlockControllerTest {
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -469,7 +470,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of(b1, b2));
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||
@@ -483,7 +484,7 @@ class TranscriptionBlockControllerTest {
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
@@ -494,7 +495,7 @@ class TranscriptionBlockControllerTest {
|
||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(GeschichteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -130,7 +131,7 @@ class GeschichteControllerTest {
|
||||
|
||||
@Test
|
||||
void create_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten")
|
||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"title\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -139,7 +140,7 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void create_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten")
|
||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"title\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -155,7 +156,7 @@ class GeschichteControllerTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("New");
|
||||
|
||||
mockMvc.perform(post("/api/geschichten")
|
||||
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -167,7 +168,7 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void update_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -180,7 +181,7 @@ class GeschichteControllerTest {
|
||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(published(id, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id)
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"PUBLISHED\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -192,7 +193,7 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -201,7 +202,7 @@ class GeschichteControllerTest {
|
||||
void delete_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", id))
|
||||
mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(geschichteService).delete(id);
|
||||
|
||||
@@ -135,7 +135,7 @@ class MassImportServiceTest {
|
||||
@Test
|
||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||
|
||||
assertThatThrownBy(() -> service.runImportAsync())
|
||||
@@ -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<MassImportService.SkipReason> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||
|
||||
verify(documentService, never()).save(any());
|
||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
|
||||
|
||||
@Test
|
||||
void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception {
|
||||
// Document already exists with status UPLOADED (not PLACEHOLDER).
|
||||
// A physical PDF file is also present on disk (valid magic bytes).
|
||||
// Expected: ALREADY_EXISTS is returned and no S3 upload is attempted —
|
||||
// the guard fires before any file I/O, so no partial processing occurs.
|
||||
Document existing = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename("present.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing));
|
||||
|
||||
Path physicalFile = tempDir.resolve("present.pdf");
|
||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||
Files.write(physicalFile, pdfHeader);
|
||||
|
||||
Optional<MassImportService.SkipReason> result = service.importSingleDocument(
|
||||
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
|
||||
|
||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
|
||||
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
verify(documentService, never()).save(any());
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────
|
||||
|
||||
@Test
|
||||
void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
|
||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||
Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
|
||||
buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
|
||||
doThrow(new RuntimeException("S3 unavailable"))
|
||||
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
assertThat(service.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
|
||||
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", MassImportService.SkipReason.S3_UPLOAD_FAILED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
|
||||
buildMinimalImportXlsx(tempDir, "existing.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
Document existing = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename("existing.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
assertThat(service.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::reason)
|
||||
.containsExactly(MassImportService.SkipReason.ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — create new document (metadata only) ───────────
|
||||
@@ -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<MassImportService.SkipReason> result = service.importSingleDocument(
|
||||
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
||||
|
||||
verify(documentService, never()).save(any());
|
||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.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,12 +432,116 @@ 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");
|
||||
}
|
||||
|
||||
// ─── isValidImportFilename — security regression — do not remove ─────────
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameIsNull() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", (String) null);
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameIsBlank() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", " ");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameContainsForwardSlash() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "etc/passwd");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameContainsBackslash() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..\\etc\\passwd");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameContainsDotDot() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "doc..evil.pdf");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameIsDotDot() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameIsAbsolutePath() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "/etc/passwd");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameContainsNullByte() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "file\0.pdf");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsTrue_whenFilenameIsPlainBasename() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "document.pdf");
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeDivisionSlash() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo∕bar.pdf");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameContainsFullwidthSlash() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo/bar.pdf");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeReverseSolidus() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo⧵bar.pdf");
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsTrue_whenFilenameHasLeadingDot() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", ".hidden.pdf");
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidImportFilename_returnsTrue_whenFilenameHasSpaces() {
|
||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "Brief an Oma.pdf");
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void processRows_skipsRowAndContinues_whenFilenameIsPathTraversal() {
|
||||
when(documentService.findByOriginalFilename("legitimate.pdf")).thenReturn(Optional.empty());
|
||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<List<String>> rows = List.of(
|
||||
List.of("header"),
|
||||
minimalCells("../evil"), // row 1: path traversal — should be skipped
|
||||
minimalCells("legitimate.pdf") // row 2: valid — should be processed
|
||||
);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
|
||||
assertThat(result.processed()).isEqualTo(1);
|
||||
assertThat(result.skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::reason)
|
||||
.containsExactly(MassImportService.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — non-blank optional fields ────────────────────
|
||||
|
||||
@Test
|
||||
@@ -525,6 +697,82 @@ 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(MassImportService.SkipReason.FILE_READ_ERROR);
|
||||
}
|
||||
|
||||
// ─── findFileRecursive — symlink escape security regression — do not remove ─
|
||||
|
||||
@Test
|
||||
void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir(
|
||||
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
|
||||
Path outsideFile = outsideDir.resolve("secret.pdf");
|
||||
Files.writeString(outsideFile, "sensitive content");
|
||||
Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile);
|
||||
|
||||
ReflectionTestUtils.setField(service, "importDir", importDirPath.toString());
|
||||
|
||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "findFileRecursive", "secret.pdf"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||
|
||||
// Security regression — do not remove.
|
||||
@@ -621,4 +869,28 @@ class MassImportServiceTest {
|
||||
}
|
||||
return destination.toFile();
|
||||
}
|
||||
|
||||
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
|
||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
||||
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
||||
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
||||
Path xlsx = dir.resolve("import.xlsx");
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
|
||||
sheet.createRow(0).createCell(0).setCellValue("Index");
|
||||
for (int i = 0; i < filenames.length; i++) {
|
||||
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(NotificationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -141,7 +142,7 @@ class NotificationControllerTest {
|
||||
|
||||
@Test
|
||||
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/notifications/read-all"))
|
||||
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -151,7 +152,7 @@ class NotificationControllerTest {
|
||||
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
||||
when(userService.findByEmail("testuser")).thenReturn(user);
|
||||
|
||||
mockMvc.perform(post("/api/notifications/read-all"))
|
||||
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(notificationService).markAllRead(USER_ID);
|
||||
@@ -161,7 +162,7 @@ class NotificationControllerTest {
|
||||
|
||||
@Test
|
||||
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
|
||||
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -176,7 +177,7 @@ class NotificationControllerTest {
|
||||
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||
.when(notificationService).markRead(notifId, USER_ID);
|
||||
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -256,7 +257,7 @@ class NotificationControllerTest {
|
||||
.notifyOnReply(true).notifyOnMention(true).build();
|
||||
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -275,7 +276,7 @@ class NotificationControllerTest {
|
||||
.notifyOnReply(true).notifyOnMention(false).build();
|
||||
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -337,7 +338,7 @@ class NotificationControllerTest {
|
||||
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||
.when(notificationService).markRead(notifId, USER_ID);
|
||||
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(OcrController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -66,7 +67,7 @@ class OcrControllerTest {
|
||||
|
||||
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
||||
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -80,7 +81,7 @@ class OcrControllerTest {
|
||||
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
||||
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
||||
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -127,7 +128,7 @@ class OcrControllerTest {
|
||||
|
||||
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/batch")
|
||||
mockMvc.perform(post("/api/ocr/batch").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(dto)))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -179,14 +180,14 @@ class OcrControllerTest {
|
||||
|
||||
@Test
|
||||
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ class OcrControllerTest {
|
||||
when(ocrTrainingService.triggerTraining(any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@@ -209,7 +210,7 @@ class OcrControllerTest {
|
||||
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
||||
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train"))
|
||||
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("DONE"))
|
||||
.andExpect(jsonPath("$.blockCount").value(10));
|
||||
@@ -365,7 +366,7 @@ class OcrControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":null}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -373,7 +374,7 @@ class OcrControllerTest {
|
||||
|
||||
@Test
|
||||
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -382,7 +383,7 @@ class OcrControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -395,7 +396,7 @@ class OcrControllerTest {
|
||||
when(senderModelService.triggerManualSenderTraining(unknownId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + unknownId + "\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -410,7 +411,7 @@ class OcrControllerTest {
|
||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + personId + "\"}"))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -426,7 +427,7 @@ class OcrControllerTest {
|
||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + personId + "\"}"))
|
||||
.andExpect(status().isAccepted())
|
||||
@@ -442,7 +443,7 @@ class OcrControllerTest {
|
||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/ocr/train-sender")
|
||||
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"personId\":\"" + personId + "\"}"))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
@@ -36,6 +36,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(PersonController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -217,7 +218,7 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -226,7 +227,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -235,7 +236,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -244,7 +245,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -253,7 +254,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -265,7 +266,7 @@ class PersonControllerTest {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -278,7 +279,7 @@ class PersonControllerTest {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -293,7 +294,7 @@ class PersonControllerTest {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -307,7 +308,7 @@ class PersonControllerTest {
|
||||
when(personService.createPerson(any())).thenThrow(
|
||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -318,7 +319,7 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -327,7 +328,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -336,7 +337,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -349,7 +350,7 @@ class PersonControllerTest {
|
||||
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -360,7 +361,7 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -369,7 +370,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -378,7 +379,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\" \"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -390,7 +391,7 @@ class PersonControllerTest {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
@@ -402,7 +403,7 @@ class PersonControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -418,7 +419,7 @@ class PersonControllerTest {
|
||||
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||
@@ -436,7 +437,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
||||
String oversizedNotes = "x".repeat(5001);
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -447,7 +448,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
||||
String oversizedFirstName = "x".repeat(101);
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -458,7 +459,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -467,7 +468,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -476,7 +477,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -507,7 +508,7 @@ class PersonControllerTest {
|
||||
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
||||
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId)
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -517,7 +518,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void addAlias_returns403_withoutWritePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -531,7 +532,7 @@ class PersonControllerTest {
|
||||
UUID personId = UUID.randomUUID();
|
||||
UUID aliasId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId))
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(personService).removeAlias(personId, aliasId);
|
||||
@@ -540,14 +541,14 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -556,7 +557,7 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
@@ -28,6 +28,7 @@ import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(RelationshipController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -67,7 +68,7 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -76,14 +77,14 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
||||
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
|
||||
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"familyMember\":true}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -125,7 +126,7 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -141,7 +142,7 @@ class RelationshipControllerTest {
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -154,7 +155,7 @@ class RelationshipControllerTest {
|
||||
UUID relId = UUID.randomUUID();
|
||||
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
||||
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(TagController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -61,7 +62,7 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -70,7 +71,7 @@ class TagControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -82,7 +83,7 @@ class TagControllerTest {
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
||||
when(tagService.update(any(), any())).thenReturn(tag);
|
||||
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isOk());
|
||||
@@ -116,7 +117,7 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void mergeTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -125,7 +126,7 @@ class TagControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -134,7 +135,7 @@ class TagControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -146,7 +147,7 @@ class TagControllerTest {
|
||||
when(tagService.mergeTags(any(), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
|
||||
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -159,7 +160,7 @@ class TagControllerTest {
|
||||
Tag target = Tag.builder().id(targetId).name("Target").build();
|
||||
when(tagService.mergeTags(any(), any())).thenReturn(target);
|
||||
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetId\": \"" + targetId + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -171,21 +172,21 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@@ -193,21 +194,21 @@ class TagControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(AdminController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -46,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"))
|
||||
@@ -60,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"))
|
||||
@@ -83,14 +84,14 @@ class AdminControllerTest {
|
||||
|
||||
@Test
|
||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ class AdminControllerTest {
|
||||
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(1));
|
||||
}
|
||||
@@ -109,14 +110,14 @@ class AdminControllerTest {
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -125,7 +126,7 @@ class AdminControllerTest {
|
||||
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
@@ -134,14 +135,14 @@ class AdminControllerTest {
|
||||
|
||||
@Test
|
||||
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -152,7 +153,7 @@ class AdminControllerTest {
|
||||
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
||||
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.state").value("RUNNING"))
|
||||
.andExpect(jsonPath("$.total").value(10));
|
||||
|
||||
@@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(AuthController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -117,7 +118,7 @@ class AuthControllerTest {
|
||||
req.setFirstName("Max");
|
||||
req.setLastName("Muster");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -134,7 +135,7 @@ class AuthControllerTest {
|
||||
req.setEmail("dupe@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isConflict());
|
||||
@@ -150,7 +151,7 @@ class AuthControllerTest {
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("abc");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -166,7 +167,7 @@ class AuthControllerTest {
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -183,7 +184,7 @@ class AuthControllerTest {
|
||||
req.setPassword("password123");
|
||||
|
||||
// No WithMockUser — must still succeed (no auth challenge)
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
@@ -33,6 +33,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(InviteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -103,7 +104,7 @@ class InviteControllerTest {
|
||||
|
||||
@Test
|
||||
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/invites")
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -112,7 +113,7 @@ class InviteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com")
|
||||
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/invites")
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -142,7 +143,7 @@ class InviteControllerTest {
|
||||
req.setLabel("Für Familie");
|
||||
req.setMaxUses(1);
|
||||
|
||||
mockMvc.perform(post("/api/invites")
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated())
|
||||
@@ -164,7 +165,7 @@ class InviteControllerTest {
|
||||
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
||||
|
||||
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
||||
mockMvc.perform(post("/api/invites")
|
||||
mockMvc.perform(post("/api/invites").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isCreated());
|
||||
@@ -178,14 +179,14 @@ class InviteControllerTest {
|
||||
|
||||
@Test
|
||||
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com")
|
||||
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -194,7 +195,7 @@ class InviteControllerTest {
|
||||
void revokeInvite_returns204_whenSuccessful() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/invites/" + id))
|
||||
mockMvc.perform(delete("/api/invites/" + id).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(inviteService).revokeInvite(id);
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.springframework.mail.MailSendException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -36,8 +37,10 @@ class PasswordResetServiceTest {
|
||||
@Mock PasswordResetTokenRepository tokenRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock JavaMailSender mailSender;
|
||||
@Mock AuthService authService;
|
||||
@InjectMocks PasswordResetService service;
|
||||
|
||||
|
||||
private AppUser makeUser(String email) {
|
||||
return AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
@@ -176,6 +179,27 @@ class PasswordResetServiceTest {
|
||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_revokes_all_sessions_after_password_reset() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.token("validtoken123")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
||||
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("validtoken123");
|
||||
req.setNewPassword("newpass");
|
||||
service.resetPassword(req);
|
||||
|
||||
verify(authService).revokeAllSessions("user@example.com");
|
||||
}
|
||||
|
||||
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.raddatz.familienarchiv.user;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.auth.AuthService;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
@@ -10,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
@@ -17,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;
|
||||
@@ -24,6 +29,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(UserController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -32,6 +38,8 @@ class UserControllerTest {
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean AuthService authService;
|
||||
@MockitoBean AuditService auditService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/users/me ────────────────────────────────────────────────────────
|
||||
@@ -83,7 +91,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -92,7 +100,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||
void createUser_returns400_whenEmailContainsColon() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -101,7 +109,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||
void createUser_returns400_whenEmailIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
@@ -112,7 +120,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -121,7 +129,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
@@ -130,7 +138,7 @@ class UserControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -138,7 +146,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
mockMvc.perform(post("/api/users").with(csrf())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -146,7 +154,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
@@ -154,7 +162,92 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
// ─── POST /api/users/me/password (changePassword + session revocation) ────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@example.com")
|
||||
void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception {
|
||||
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build();
|
||||
when(userService.findByEmail("user@example.com")).thenReturn(user);
|
||||
when(authService.revokeOtherSessions(any(), any())).thenReturn(1);
|
||||
|
||||
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(authService).revokeOtherSessions(any(), eq("user@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePassword_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@example.com")
|
||||
void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/users/me/password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||
}
|
||||
|
||||
// ─── POST /api/users/{id}/force-logout ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
||||
void forceLogout_returns200_and_revokes_target_sessions() throws Exception {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build();
|
||||
AppUser target = AppUser.builder().id(targetId).email("target@example.com").build();
|
||||
when(userService.findByEmail("admin@example.com")).thenReturn(actor);
|
||||
when(userService.getById(targetId)).thenReturn(target);
|
||||
when(authService.revokeAllSessions("target@example.com")).thenReturn(2);
|
||||
|
||||
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forceLogout_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void forceLogout_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_USER")
|
||||
void forceLogout_returns404_whenUserNotFound() throws Exception {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
when(userService.getById(targetId)).thenThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
||||
void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
logging.level.root=WARN
|
||||
logging.level.org.raddatz=INFO
|
||||
|
||||
# Default test value so FlywayConfig's fail-closed check passes without each
|
||||
# test having to set GRAFANA_DB_PASSWORD explicitly. The actual value is
|
||||
# irrelevant in tests — Flyway only uses it to set the grafana_reader role's
|
||||
# password, which no test connects with.
|
||||
GRAFANA_DB_PASSWORD=test-grafana-reader-password
|
||||
|
||||
@@ -147,6 +147,9 @@ services:
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003}
|
||||
# Read-only password for the grafana_reader PostgreSQL role; interpolated
|
||||
# into the provisioned PostgreSQL datasource (see datasources.yml).
|
||||
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
@@ -165,6 +168,7 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- obs-net
|
||||
- archiv-net # PO Overview dashboard queries archive-db via the grafana_reader role
|
||||
|
||||
# --- Error Tracking: GlitchTip ---
|
||||
|
||||
|
||||
@@ -227,6 +227,9 @@ services:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
|
||||
SPRING_DATASOURCE_USERNAME: archiv
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
|
||||
# the read-only grafana_reader role's password.
|
||||
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
|
||||
# Application uses the bucket-scoped service account, not MinIO root.
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: archiv-app
|
||||
@@ -252,6 +255,8 @@ services:
|
||||
OTEL_METRICS_EXPORTER: none
|
||||
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
|
||||
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
LOGGING_STRUCTURED_FORMAT_CONSOLE: ecs
|
||||
networks:
|
||||
- archiv-net
|
||||
healthcheck:
|
||||
@@ -266,6 +271,10 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: production
|
||||
args:
|
||||
# Vite build-time variable — baked into the JS bundle at build time.
|
||||
# Empty default so deploys succeed before the secret is configured.
|
||||
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
@@ -276,6 +285,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:
|
||||
|
||||
@@ -163,6 +163,9 @@ services:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
|
||||
# the read-only grafana_reader role's password.
|
||||
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
@@ -228,6 +231,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — |
|
||||
| `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
|
||||
| `GRAFANA_DB_PASSWORD` | Password for the read-only `grafana_reader` PostgreSQL role used by the PO Overview dashboard (issue #651). Consumed by Flyway V68 and the Grafana PostgreSQL datasource. Generate with `openssl rand -hex 32`. | — | YES (prod) | YES |
|
||||
| `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — |
|
||||
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — |
|
||||
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
|
||||
@@ -256,6 +257,7 @@ git.raddatz.cloud A <server IP>
|
||||
| `MAIL_USERNAME` | release.yml | SMTP user |
|
||||
| `MAIL_PASSWORD` | release.yml | SMTP password |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
|
||||
| `GRAFANA_DB_PASSWORD` | both | Read-only `grafana_reader` role password — `openssl rand -hex 32` |
|
||||
| `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` |
|
||||
| `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||
| `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||
@@ -357,6 +359,7 @@ Both files are passed explicitly via `--env-file` to the compose command, so the
|
||||
| Gitea secret | Notes |
|
||||
|---|---|
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
|
||||
| `GRAFANA_DB_PASSWORD` | `openssl rand -hex 32`; shared by nightly and release — read-only DB role for the PO Overview dashboard |
|
||||
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
|
||||
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
|
||||
|
||||
@@ -427,6 +430,31 @@ docker exec obs-loki wget -qO- \
|
||||
|
||||
Prometheus port `9090` and Grafana port `3003` (default; configurable via `PORT_GRAFANA`) are bound to `127.0.0.1` on the host. No other observability ports are host-bound.
|
||||
|
||||
##### Rotate the `grafana_reader` DB password
|
||||
|
||||
The PO Overview dashboard reads `audit_log`, `documents`, and `transcription_blocks` through the SELECT-only `grafana_reader` PostgreSQL role (issue #651, ADR-024). The role's password is owned by `R__grafana_reader_password.sql` — a Flyway *repeatable* migration that re-runs whenever the resolved `${grafanaDbPassword}` placeholder changes. That makes rotation a two-restart operation, no manual `psql` required.
|
||||
|
||||
```bash
|
||||
# 1. Generate a new value
|
||||
openssl rand -hex 32
|
||||
|
||||
# 2. Update both sides:
|
||||
# - Gitea secret GRAFANA_DB_PASSWORD (nightly + release workflows pick it up)
|
||||
# - Local .env on the server / dev machine
|
||||
|
||||
# 3. Restart the backend. Flyway sees that R__'s resolved checksum changed and
|
||||
# re-applies it, issuing ALTER ROLE grafana_reader WITH PASSWORD '<new>'.
|
||||
docker compose restart backend
|
||||
|
||||
# 4. Restart obs-grafana so the provisioned datasource picks up the new env value.
|
||||
docker compose -f docker-compose.observability.yml restart obs-grafana
|
||||
|
||||
# 5. Verify the dashboard loads — PO Overview's Postgres panels should populate
|
||||
# instead of "Data source error".
|
||||
```
|
||||
|
||||
If `GRAFANA_DB_PASSWORD` is unset, the backend **refuses to start** (`IllegalStateException`). That is deliberate — see `FlywayConfig.resolveGrafanaDbPassword()` and the rationale in ADR-024.
|
||||
|
||||
#### GlitchTip
|
||||
|
||||
| Item | Value |
|
||||
|
||||
@@ -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)._
|
||||
|
||||
@@ -76,6 +80,14 @@ _See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
|
||||
|
||||
**Sütterlin** — A specific standardized style of Kurrent taught in German schools from 1915 to 1941.
|
||||
|
||||
**Illegible word** — a word whose recognition confidence falls below the configured threshold; replaced with the literal token `[unleserlich]` in the rendered block text and counted in the `ocr_illegible_words_total` Prometheus counter.
|
||||
|
||||
**Models-ready gauge** — the `ocr_models_ready` Prometheus gauge, flipped from `0` to `1` once the FastAPI lifespan startup has finished loading the Kraken model and the spell-checker. Used both for the `/health` endpoint and as the supervised signal for the `ocr_models_ready < 1 for 2m` alert.
|
||||
|
||||
**Recognition model accuracy** — the accuracy reported by `ketos train` for the recognition (text-line) model, exposed as `ocr_model_accuracy{kind="recognition"}`. Sourced from `_parse_best_checkpoint` on the highest-scoring checkpoint after training.
|
||||
|
||||
**Segmentation model accuracy** — the accuracy reported by `ketos segtrain` for the baseline layout analysis (`blla`) model, exposed as `ocr_model_accuracy{kind="segmentation"}`. Distinct from recognition accuracy because the two models are trained and improved independently.
|
||||
|
||||
---
|
||||
|
||||
## Other Domain Terms
|
||||
|
||||
@@ -118,11 +118,14 @@ To find a trace for a specific request in staging/production, either increase th
|
||||
|
||||
## Metrics (Prometheus → Grafana)
|
||||
|
||||
Prometheus scrapes the backend management endpoint every 15 s:
|
||||
Prometheus scrapes two targets every 15 s:
|
||||
|
||||
```
|
||||
Target: backend:8081/actuator/prometheus
|
||||
Labels: job="spring-boot", application="Familienarchiv"
|
||||
|
||||
Target: ocr:8000/metrics
|
||||
Labels: job="ocr-service"
|
||||
```
|
||||
|
||||
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
|
||||
@@ -146,6 +149,70 @@ jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
|
||||
hikaricp_connections_active
|
||||
```
|
||||
|
||||
### OCR-service custom metrics
|
||||
|
||||
Exposed at `ocr:8000/metrics` by `prometheus-fastapi-instrumentator`. The
|
||||
`http_*` metrics describe the FastAPI request layer; the `ocr_*` series are
|
||||
domain-specific. **Never label these with PII or document content** — labels
|
||||
have unbounded cardinality risk and are visible to anyone with Grafana access.
|
||||
|
||||
| Metric | Type | Labels | Unit | What it tracks |
|
||||
|---|---|---|---|---|
|
||||
| `ocr_jobs_total` | Counter | `engine` (`surya`/`kraken`), `script_type` | jobs | OCR jobs that started after a successful PDF download |
|
||||
| `ocr_pages_total` | Counter | `engine` | pages | Successfully OCR'd pages in the streaming generator |
|
||||
| `ocr_skipped_pages_total` | Counter | — | pages | Pages skipped because the engine raised on them |
|
||||
| `ocr_words_total` | Counter | — | words | Recognized words summed across every block |
|
||||
| `ocr_illegible_words_total` | Counter | — | words | Words below the confidence threshold (rendered as `[unleserlich]`) |
|
||||
| `ocr_processing_seconds` | Histogram | `engine` | seconds | Per-page (stream) or per-document (`/ocr`) engine time, excluding preprocessing |
|
||||
| `ocr_training_runs_total` | Counter | `kind` (`recognition`/`segmentation`), `outcome` (`success`/`error`) | runs | Completed training runs |
|
||||
| `ocr_model_accuracy` | Gauge | `kind` | ratio (0–1) | Latest accuracy reported by a successful training run |
|
||||
| `ocr_models_ready` | Gauge | — | 0\|1 | 1 once the lifespan startup has finished loading models |
|
||||
|
||||
Canonical example queries (the same ones referenced in issue #652):
|
||||
|
||||
```promql
|
||||
# OCR throughput by engine
|
||||
sum by (engine) (rate(ocr_pages_total[5m]))
|
||||
|
||||
# Share of words rendered as [unleserlich]
|
||||
sum(rate(ocr_illegible_words_total[5m]))
|
||||
/ sum(rate(ocr_words_total[5m]))
|
||||
|
||||
# p95 page processing time per engine
|
||||
histogram_quantile(0.95, sum by (engine, le) (
|
||||
rate(ocr_processing_seconds_bucket[5m])
|
||||
))
|
||||
|
||||
# Training error rate
|
||||
sum(rate(ocr_training_runs_total{outcome="error"}[1h]))
|
||||
/ sum(rate(ocr_training_runs_total[1h]))
|
||||
|
||||
# Latest recognition vs segmentation accuracy
|
||||
ocr_model_accuracy
|
||||
```
|
||||
|
||||
### Internal-only endpoints
|
||||
|
||||
`/metrics` is exposed by the OCR service over plain HTTP without
|
||||
authentication. The container is reachable only on the internal Docker
|
||||
network — Caddy never proxies to it directly. If the service is ever
|
||||
exposed (e.g. a `ports:` mapping is added), block the endpoint at the
|
||||
reverse proxy:
|
||||
|
||||
```caddy
|
||||
ocr.example.com {
|
||||
@internal_only path /metrics /health
|
||||
respond @internal_only 404
|
||||
reverse_proxy ocr:8000
|
||||
}
|
||||
```
|
||||
|
||||
The `MetricsPathFilter` in `ocr-service/main.py` suppresses uvicorn's
|
||||
**stdout** access log lines for `/metrics` and `/health` so the container
|
||||
console stays focused on real OCR traffic. Promtail/Loki still receive
|
||||
access lines from any other source. Treat the filter as console
|
||||
noise-control, not an audit-suppression mechanism.
|
||||
|
||||
## Errors (GlitchTip)
|
||||
|
||||
GlitchTip receives errors from both the backend (via Sentry Java SDK) and the frontend (via Sentry JavaScript SDK). It groups events by fingerprint, tracks first/last seen times, and links to the release that introduced the error.
|
||||
|
||||
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.
|
||||
@@ -102,3 +102,9 @@ open long enough for the caller to use the result."
|
||||
`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.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# ADR-023: Prometheus Instrumentator and Metrics Registry Injection
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Until issue #652 the OCR service exposed no `/metrics` endpoint. The
|
||||
observability stack already scrapes the Spring Boot backend's actuator
|
||||
endpoint, but it had nothing to scrape on the Python side. Without HTTP-
|
||||
and domain-level metrics from `ocr-service` we cannot answer questions
|
||||
like "what is the share of words rendered as `[unleserlich]`" or
|
||||
"is the training error rate above its budget" from Grafana.
|
||||
|
||||
Two implementation requirements influenced the design:
|
||||
|
||||
1. **Counter / gauge isolation in tests.** `prometheus_client` collectors
|
||||
are module-level singletons keyed by name on the global `REGISTRY`.
|
||||
Re-importing or naively re-instantiating them raises a duplicated-
|
||||
collector error and cross-test state leaks (a `.inc()` in test A is
|
||||
still readable by test B). A test harness needs a way to swap the
|
||||
active container for a fresh per-test instance.
|
||||
|
||||
2. **Minimal blast radius on the request path.** We did not want to
|
||||
hand-instrument every endpoint with FastAPI middleware. The
|
||||
`prometheus-fastapi-instrumentator` library already provides
|
||||
`http_requests_total`, `http_request_duration_seconds`, and the
|
||||
`/metrics` exposition route, all idiomatic Prometheus names.
|
||||
|
||||
## Decision
|
||||
|
||||
- Add `prometheus-fastapi-instrumentator==7.0.0` and pin its transitive
|
||||
dependency `prometheus-client==0.25.0` explicitly in
|
||||
`ocr-service/requirements.txt`.
|
||||
- Mount the instrumentator once at module load:
|
||||
`Instrumentator(excluded_handlers=["/health", "/metrics"]).instrument(app).expose(app)`.
|
||||
This adds `/metrics` and an HTTP-level dashboard surface without
|
||||
changing any endpoint code.
|
||||
- Define every domain metric (`ocr_jobs_total`, `ocr_pages_total`,
|
||||
`ocr_processing_seconds`, …) inside a `build_metrics(registry)`
|
||||
factory in `ocr-service/metrics.py` that returns a frozen `OcrMetrics`
|
||||
dataclass. Production code binds the container to the default
|
||||
`REGISTRY` once: `metrics: OcrMetrics = build_metrics(REGISTRY)`.
|
||||
- Tests use a `fresh_metrics` fixture that builds a new
|
||||
`CollectorRegistry()` per test and monkeypatches `main.metrics` with
|
||||
a container bound to it. The endpoint code keeps reading
|
||||
`metrics.<name>` without knowing whether it is talking to the global
|
||||
registry or a per-test one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One reusable factory captures the metric definitions; future metrics
|
||||
go in one place.
|
||||
- Tests run with full counter isolation. Cross-test state leakage is
|
||||
impossible because each test sees its own dataclass instance.
|
||||
- The instrumentator gives us `http_*` metrics for free, including a
|
||||
Grafana-ready histogram that pairs with the Spring Boot one.
|
||||
|
||||
**Negative**
|
||||
|
||||
- One extra level of indirection: any test that asserts on metric
|
||||
values must remember to monkeypatch `main.metrics`, not the registry
|
||||
directly. Rebinding through the registry is harmless but useless —
|
||||
the dataclass holds references to the original collectors.
|
||||
- `prometheus-client` is now pinned. Upgrading it requires an explicit
|
||||
bump and re-checking the instrumentator's compatibility range.
|
||||
- `/metrics` is exposed unauthenticated and relies on the Docker
|
||||
internal network for confidentiality. See
|
||||
[docs/OBSERVABILITY.md §Internal-only endpoints](../OBSERVABILITY.md)
|
||||
for the Caddy snippet that must be added if the service ever gets a
|
||||
host-side port mapping.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Hand-roll the `/metrics` endpoint.** Rejected: would have meant
|
||||
duplicating what `prometheus-fastapi-instrumentator` ships, plus
|
||||
middleware for the HTTP histograms.
|
||||
- **Skip the factory; pass `registry` as a function argument
|
||||
everywhere.** Rejected: clutters every endpoint signature and breaks
|
||||
the symmetry with the Spring Boot side, which also relies on a
|
||||
process-global Micrometer registry.
|
||||
- **Use a `pytest` autouse fixture that resets `REGISTRY` between
|
||||
tests.** Rejected: `prometheus_client` does not expose a clean
|
||||
"unregister all" hook, and we would be relying on private APIs.
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#652](https://git.raddatz.cloud/marcel/familienarchiv/issues/652)
|
||||
- Library: <https://github.com/trallnag/prometheus-fastapi-instrumentator>
|
||||
- Code: `ocr-service/metrics.py`, `ocr-service/main.py`,
|
||||
`ocr-service/test_metrics.py`
|
||||
123
docs/adr/024-grafana-reads-archive-db-via-bridged-network.md
Normal file
123
docs/adr/024-grafana-reads-archive-db-via-bridged-network.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ADR-024: Grafana reads archive-db via a bridged network and a SELECT-only role
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #651 (the PO Overview Grafana dashboard) needs aggregates over three
|
||||
tables in the main application database — `audit_log`, `documents`, and
|
||||
`transcription_blocks` — to answer the operator's four weekly questions: is
|
||||
everything working, are people using it, is the archive making progress, is
|
||||
OCR working well.
|
||||
|
||||
Until now, `obs-grafana` and the rest of the observability stack lived on
|
||||
their own Docker network (`obs-net`) and never touched `archiv-net`, where
|
||||
`archive-db` runs. The two were intentionally isolated: a compromise of any
|
||||
observability container could not pivot to the application database.
|
||||
|
||||
The PO Overview's archive-progress and user-activity panels need rolling
|
||||
7-day SQL aggregates that cannot be served by Prometheus or Loki. That
|
||||
forces a connection from `obs-grafana` to `archive-db` for the first time.
|
||||
|
||||
Two implementation requirements shaped the design:
|
||||
|
||||
1. **Least privilege on the database side.** The Spring Boot application
|
||||
role (`archiv`) has full read/write on every table. Letting Grafana
|
||||
connect with that role would mean a Grafana compromise becomes an
|
||||
application compromise. The dashboard only needs SELECT on three
|
||||
tables; the role must reflect that and nothing more.
|
||||
|
||||
2. **Operational simplicity of secret rotation.** The role's password is
|
||||
shared between the migration that sets it and the Grafana datasource
|
||||
that uses it. A first version of this work put the password in a
|
||||
versioned Flyway migration (V68), which Flyway only applies once —
|
||||
leaving rotation as an out-of-band `psql ALTER ROLE` step that no
|
||||
runbook documented. The shape must support rotation without manual
|
||||
SQL.
|
||||
|
||||
## Decision
|
||||
|
||||
- Provision a dedicated PostgreSQL role `grafana_reader` with `LOGIN` plus
|
||||
`GRANT SELECT` on `audit_log`, `documents`, `transcription_blocks` only.
|
||||
No INSERT/UPDATE/DELETE on any table, no access to any other table —
|
||||
enforced by the database, locked in by both positive and parameterized
|
||||
negative tests in `GrafanaReaderRoleIntegrationTest`.
|
||||
- Split the role's lifecycle across two migrations:
|
||||
- `V68__add_grafana_reader_role.sql` — versioned, immutable, idempotent.
|
||||
Creates the role and applies the grants. Runs exactly once per
|
||||
database, like every other versioned migration.
|
||||
- `R__grafana_reader_password.sql` — Flyway *repeatable* migration that
|
||||
issues `ALTER ROLE grafana_reader WITH PASSWORD '${grafanaDbPassword}'`.
|
||||
Flyway computes the checksum on the resolved content, so any change
|
||||
to `GRAFANA_DB_PASSWORD` flips the checksum and re-applies the
|
||||
migration on the next boot. Rotation becomes "bump env var, restart
|
||||
backend, restart obs-grafana" — see the runbook in
|
||||
`docs/DEPLOYMENT.md §4 → Rotate the grafana_reader DB password`.
|
||||
- Resolve the password through Spring's `Environment` rather than a raw
|
||||
`System.getenv()` call, so tests inject via `application.properties`
|
||||
and the resolver is unit-testable with `MockEnvironment`. Fail closed
|
||||
with `IllegalStateException` when the variable is unset — no fallback
|
||||
string. Same shape as `UserDataInitializer`'s refusal to seed default
|
||||
admin credentials outside dev/test/e2e.
|
||||
- Join `obs-grafana` to `archiv-net` in addition to `obs-net`. Only the
|
||||
Grafana container crosses the boundary; Loki, Tempo, Prometheus,
|
||||
GlitchTip, and the worker containers remain `obs-net`-only.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Database-level least privilege: a Grafana compromise gains SELECT on
|
||||
three tables. Cannot write, cannot read PII tables like `app_users`,
|
||||
`persons`, `notifications`, `document_comments`, `geschichten`. The
|
||||
parameterized PII negative sweep in `GrafanaReaderRoleIntegrationTest`
|
||||
is the regression gate; new sensitive tables get added to that list.
|
||||
- Rotation is documented, idempotent, and survives operator turnover.
|
||||
No "the password set on day 1 is the password forever" failure mode.
|
||||
- Tests pin down both sides of the boundary: positive grants must hold,
|
||||
write-deny must hold, and the PII negative list must stay empty.
|
||||
|
||||
**Negative / trade-offs**
|
||||
|
||||
- `obs-net` is no longer fully isolated from `archiv-net`. A Grafana RCE
|
||||
(e.g. via a future Grafana CVE) gains a TCP path to `archive-db` —
|
||||
contained, but not impossible. The least-privilege role is the
|
||||
mitigation; we accept that mitigation as sufficient for a single
|
||||
bridged container.
|
||||
- The backend must hold `GRAFANA_DB_PASSWORD` in its environment forever,
|
||||
so Flyway can resolve the placeholder on every boot. A backend RCE
|
||||
therefore also leaks the Grafana datasource password. Acceptable
|
||||
because that password's blast radius is itself bounded by the
|
||||
least-privilege grants on `grafana_reader`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Prometheus PostgreSQL exporter, no direct connection.** Loses ad-hoc
|
||||
SQL aggregates — the dashboard would need every metric pre-defined as
|
||||
an exporter query, with a redeploy to add a new one. The PO Overview
|
||||
is the type of dashboard that grows panels over time; pre-defining
|
||||
every aggregate is the wrong shape.
|
||||
- **Read replica or logical-replication slot dedicated to Grafana.**
|
||||
Real operational cost (extra Postgres instance, replication monitoring,
|
||||
storage doubled) disproportionate to a weekly PO glance.
|
||||
- **Versioned migration with `flyway repair` for rotation.** Rejected:
|
||||
conflates schema lifecycle with credential lifecycle, requires manual
|
||||
intervention to rotate, and the repair command's semantics are
|
||||
surprising to operators unfamiliar with Flyway internals.
|
||||
- **Hardcoded fallback password when env var is unset.** Rejected as a
|
||||
security blocker: publishes a known credential for a role with read
|
||||
access to user activity and full letter text. The fail-closed
|
||||
behavior is the explicit defense.
|
||||
|
||||
## References
|
||||
|
||||
- Issue #651 — PO Overview Grafana dashboard
|
||||
- `backend/src/main/resources/db/migration/V68__add_grafana_reader_role.sql`
|
||||
- `backend/src/main/resources/db/migration/R__grafana_reader_password.sql`
|
||||
- `backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java`
|
||||
- `backend/src/test/java/org/raddatz/familienarchiv/config/GrafanaReaderRoleIntegrationTest.java`
|
||||
- `infra/observability/grafana/provisioning/datasources/datasources.yml`
|
||||
- `docker-compose.observability.yml` — `archiv-net` bridge on `obs-grafana`
|
||||
- `docs/DEPLOYMENT.md §4` — rotation runbook
|
||||
@@ -43,9 +43,12 @@ Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
|
||||
Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
|
||||
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
|
||||
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
|
||||
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
|
||||
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||
Rel(grafana, db, "Read-only dashboard queries via grafana_reader role", "PostgreSQL / archiv-net")
|
||||
Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net")
|
||||
Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
313
docs/import-migration/01-findings-spreadsheet-analysis.md
Normal file
313
docs/import-migration/01-findings-spreadsheet-analysis.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Spreadsheet Analysis — Findings (2026-05-25)
|
||||
|
||||
Analysis of the **real raw archive** spreadsheets against the current `MassImportService`
|
||||
(`backend/.../importing/MassImportService.java`). Goal: import ~7,600 letter rows + a
|
||||
163-person register, with PDFs to follow.
|
||||
|
||||
Every issue has an ID (`IMP-NN`), severity, evidence, and a proposed approach.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context: how the importer reads a row today
|
||||
|
||||
`MassImportService` reads **sheet index 0** and maps columns by configurable indices
|
||||
(`app.import.col.*`, defaults in the source):
|
||||
|
||||
| Property | Default col | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `colIndex` | 0 | Index (→ filename `<index>.pdf`) |
|
||||
| `colBox` | 1 | Box |
|
||||
| `colFolder` | 2 | Mappe |
|
||||
| `colSender` | 3 | Sender (raw) |
|
||||
| `colReceivers` | 5 | Receivers (raw) |
|
||||
| `colDate` | 7 | Date |
|
||||
| `colLocation` | 9 | Location |
|
||||
| `colTags` | 10 | Tag (single) |
|
||||
| `colSummary` | 11 | Summary |
|
||||
| `colTranscription` | 13 | Transcription |
|
||||
|
||||
These defaults match the **ODS** file exactly (`Index, Box, Mappe, Von, BriefeschreiberIn,
|
||||
An, EmpfängerIn, Datum, Datum Originalformat, Ort, Schlagwort, Inhalt, Zeitlicher Kontext,
|
||||
Transkript` = 14 cols). The ODS was the development target. The new xlsx is a different beast.
|
||||
|
||||
Per-row pipeline: skip if Index blank → derive filename from Index → validate filename →
|
||||
look for file on disk (recursive; metadata-only if absent) → check PDF magic bytes →
|
||||
`importSingleDocument` (upsert by `originalFilename`, dedupe non-placeholders as
|
||||
`ALREADY_EXISTS`). Date parsing is **ISO-only** (`LocalDate.parse`).
|
||||
|
||||
---
|
||||
|
||||
## IMP-01 — New xlsx column layout ≠ importer defaults 🔴 BLOCKER
|
||||
|
||||
The new `…aktuell…xlsx` (sheet `Familienarchiv`, 7,943 rows × 12 cols) has a **denser,
|
||||
different** layout. There is an extra `Datei` column at index 1, and the normalized
|
||||
`Von`/`An`/ISO-`Datum` columns from the ODS **do not exist**.
|
||||
|
||||
| col | New xlsx header | Importer default expects | Result with defaults |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | Index | Index | ✅ ok |
|
||||
| 1 | **Datei** (path) | Box | ❌ Box ← `..\__scan\W-0001.pdf` |
|
||||
| 2 | Box | Mappe | ❌ Mappe ← `V` |
|
||||
| 3 | Mappe | Sender | ❌ Sender ← `1` |
|
||||
| 4 | BriefeschreiberIn (sender) | — (unused) | ❌ sender ignored |
|
||||
| 5 | EmpfängerIn (receiver) | Receivers | ✅ coincidentally ok |
|
||||
| 6 | Datum des Briefes | — (unused) | ❌ date ignored |
|
||||
| 7 | Ort (location) | Date | ❌ Date ← `Rotterdam` → null |
|
||||
| 8 | Schlagwort (tag) | — (unused) | ❌ tag ignored |
|
||||
| 9 | Inhalt (summary) | Location | ❌ Location ← summary text |
|
||||
| 10 | — | Tag | ❌ empty |
|
||||
| 11 | — | Summary | ❌ empty |
|
||||
| 13 | — | Transcription | ❌ column doesn't exist |
|
||||
|
||||
**Impact:** importing as-is produces almost entirely garbage metadata.
|
||||
|
||||
**Proposed approach (decide with Marcel):**
|
||||
- (a) Re-map via the existing `app.import.col.*` properties — fast, no code. New mapping:
|
||||
`index=0, box=2, folder=3, sender=4, receivers=5, date=6, location=7, tags=8, summary=9`,
|
||||
and there is **no** transcription column (point it past the end or add a "missing column"
|
||||
convention). Caveat: tags land in `colTags` but the real per-letter keywords are in
|
||||
`Inhalt` (col 9) — see IMP-08 note on tags vs summary.
|
||||
- (b) Make the importer **header-driven** (map by header name, not index) so it survives
|
||||
layout drift across files. More robust, needs a code change (→ Gitea issue).
|
||||
|
||||
Recommendation: (b) is the durable fix given we have ≥3 different layouts already.
|
||||
|
||||
---
|
||||
|
||||
## IMP-02 — 90% of dates are free-text the parser can't read 🔴 BLOCKER
|
||||
|
||||
The dates are written **as in the letter**. `parseDate()` only does `LocalDate.parse()`
|
||||
(ISO `yyyy-MM-dd`), so anything non-ISO becomes `null`.
|
||||
|
||||
Of **7,319** rows with a date value (col 6):
|
||||
|
||||
| kind | count | parses today? |
|
||||
| --- | --- | --- |
|
||||
| Real Excel date cells (→ ISO via POI) | 748 | ✅ |
|
||||
| Free-text date strings | 6,571 | ❌ → null |
|
||||
|
||||
→ **90% of dated rows lose their date.** (623 rows have no date at all.)
|
||||
|
||||
Observed free-text formats (counts approximate, from col 6):
|
||||
|
||||
| Format | Count | Examples |
|
||||
| --- | --- | --- |
|
||||
| `D.M.YY` | 1,338 | `11.10.08`, `13.5.09` |
|
||||
| `D.RomanMonth.YY/YYYY` | ~1,527 | `22.III.18`, `19.XII.1954`, `1.III.27` |
|
||||
| `D.Month YYYY` | 950 | `6.März 1888`, `9.März 1888` (note: **no space** after the dot) |
|
||||
| `D.M.YYYY` | 358 | `15.2.1888`, `7.3.1888` |
|
||||
| Approximate / unknown | 146 | `?`, `13.7.18?`, `17.Nov (?) 1887`, `13.Januar ? 1907` |
|
||||
| `Month YYYY` / season / holiday | 41+27 | `Mai 1895`, `Herbst 1913`, `Pfingsten 1922`, `Ostern 1890` |
|
||||
| `YYYY` only | 17 | `1905`, `1949` |
|
||||
| `D.M.` no year | 10 | `8.9.`, `14.3.` |
|
||||
| Ranges | 5+ | `8.1.1916 - 15.3.1916`, `1881/82`, `1945/46?` |
|
||||
| Abbrev/English months, no space | many | `29.Sept.1891`, `10.Oct.95`, `9.December1889`, `18.Dez.1916` |
|
||||
| Slash separator | ~315 | `2/2. 18`, `17/6. 1916`, `10/4. 1917` |
|
||||
| English `Month D. YYYY` | several | `April 12. 1922`, `Oct.5. 1916`, `Mai 23. 1917` |
|
||||
| Trailing notes | 5+ | `26.4.1888, 2. Brief`, `31.8.1888,2.Brief` |
|
||||
| 3-digit year (typo) | 107 | `30.1.889` (→ 1889), `4.3.1023` (in person file → 1923) |
|
||||
| Day-range within month | several | `7./8. Sept.1923` |
|
||||
|
||||
**Proposed approach:** build a tolerant German/historical date parser (→ Gitea issue, it's
|
||||
a code change). Requirements:
|
||||
- Numeric `D.M.YY[YY]` and `D/M. YY[YY]` (slash = dot).
|
||||
- Roman-numeral months (`I`–`XII`).
|
||||
- German + English month names, full + abbreviated, with/without separating space
|
||||
(`März`, `Sept.`, `Dez`, `December`, `Oct.`).
|
||||
- 2-digit and 3-digit year normalization (`08`→1908? needs a century rule; `889`→1889).
|
||||
- Partial dates → store what's known. The schema only has a single `documentDate
|
||||
LocalDate`; **decide** whether to (i) store first-of-month/year, (ii) add a
|
||||
`datePrecision` enum + `dateOriginal` text column, or (iii) keep raw text in a new
|
||||
`documentDateRaw` field and leave `documentate` null when imprecise. Recommendation:
|
||||
preserve the **original string** always (new column) + best-effort parsed date +
|
||||
precision flag, so nothing is lost and the UI can show "ca. 1916".
|
||||
- Unparseable/approximate (`?`, `Herbst 1913`) → keep raw, leave parsed date null, **do
|
||||
not drop the row**.
|
||||
|
||||
**Cross-check:** even after IMP-01 is fixed so the date column is read, IMP-02 still bites.
|
||||
Both must be solved before a real import.
|
||||
|
||||
---
|
||||
|
||||
## IMP-03 — New xlsx has no normalized/ISO date or name columns 🔴 BLOCKER
|
||||
|
||||
The ODS had helper columns the importer relied on: `Von`/`An` (normalized names) and
|
||||
`Datum` (ISO) alongside `Datum Originalformat`. The new xlsx has **only the raw**
|
||||
`BriefeschreiberIn` / `EmpfängerIn` / `Datum des Briefes`. So:
|
||||
- Names must be parsed from raw strings (PersonNameParser already does receivers; **sender
|
||||
is taken raw, never split** — fine for senders, which are single, but no normalization).
|
||||
- Dates must be parsed from raw (IMP-02).
|
||||
|
||||
This is the root reason IMP-01/02 exist: the new file is the *uncurated* source, not the
|
||||
hand-normalized ODS. Tie any importer redesign to this reality — we will not get clean
|
||||
helper columns in the 7k-row file.
|
||||
|
||||
---
|
||||
|
||||
## IMP-04 — Person register not imported at all 🟠 MAJOR
|
||||
|
||||
`Personendatei 2.xlsx` → sheet `Tabelle1`, **163 people**, columns:
|
||||
`Generation, Familienname, Vorname, geb als (maiden), Geburtsdatum, Geburtsort,
|
||||
Todesdatum, Sterbeort, verheiratet mit, Bemerkung`.
|
||||
|
||||
Today `MassImportService` has **no person-register import**. Persons are only
|
||||
auto-created as bare aliases from the document sender/receiver strings
|
||||
(`personService.findOrCreateByAlias`). All this rich genealogical data is unused:
|
||||
- birth/death dates + places,
|
||||
- maiden names (the key to dedup — see IMP-05),
|
||||
- `verheiratet mit` (marriage links → `PersonRelationship` domain),
|
||||
- `Bemerkung` relationship hints (`"Schwester v Marie Cram"`, `"Nichte von Herbert"`),
|
||||
- `Generation` (G 1–G 4),
|
||||
- nicknames in quotes (`"Tante Lolly"`).
|
||||
|
||||
Data-quality notes in this file too: multi-value `Vorname` (`Charlotte,Meta,Jacobi`);
|
||||
mixed Excel-date vs text dates; typos (`4.3.1023`); missing-day dates (`.12.1955`);
|
||||
trailing spaces (`30.8.1862 `).
|
||||
|
||||
**Proposed approach:** a separate **Person import** (→ Gitea issue). Order matters: import
|
||||
persons *first* so documents can link to real people instead of creating alias stubs.
|
||||
Use `geb als` + `verheiratet mit` to pre-build the alias/relationship graph.
|
||||
|
||||
---
|
||||
|
||||
## IMP-05 — Name variations create duplicate Persons 🟠 MAJOR
|
||||
|
||||
The same person appears under several surface forms across the document sheet:
|
||||
- `Eugenie Müller` (151) vs `Eugenie de Gruyter` (452) — maiden vs married.
|
||||
- `Clara Cram` (sender 1,284) vs `Clara de Gruyter` (455) vs `Clara de Gruyter sen.` (66).
|
||||
- `Walter de Gruyter` (589) vs bare `Walter` (78).
|
||||
|
||||
`findOrCreateByAlias` keys on the raw string, so each variant becomes (or matches) a
|
||||
distinct alias and likely a **distinct Person**. Result: fragmented person records,
|
||||
broken Briefwechsel pairing, wrong stats.
|
||||
|
||||
**Proposed approach:** drive dedup from the register's `geb als` column (IMP-04) —
|
||||
`Eugenie de Gruyter geb Müller` tells us the two strings are one person. Build an alias
|
||||
map (married ↔ maiden ↔ nickname) before/while importing documents. This is partly data
|
||||
(an alias mapping table/sheet) and partly code (consume it). Likely a Gitea issue once the
|
||||
mapping format is decided.
|
||||
|
||||
945 distinct sender strings / 274 distinct receiver strings — expect a long-tail of
|
||||
variants to reconcile. Don't try to be perfect on the first pass; get the high-frequency
|
||||
names right.
|
||||
|
||||
---
|
||||
|
||||
## IMP-06 — 93 data rows with blank Index are silently dropped 🟠 MAJOR
|
||||
|
||||
`processRows` does `if (index.isBlank()) continue;`. **93 rows** have a blank Index but
|
||||
carry other data (sender/receiver/date/etc.). These are silently skipped — they don't even
|
||||
appear in the `skippedFiles` report (that list only covers rows that *had* an index but
|
||||
failed file checks).
|
||||
|
||||
**Proposed approach:** before import, triage these 93 rows — are they continuation rows,
|
||||
section markers, or genuine letters missing an ID? At minimum, surface a count/warning so
|
||||
nothing vanishes unnoticed. Possibly a small importer change to report blank-index skips.
|
||||
|
||||
---
|
||||
|
||||
## IMP-07 — 43 duplicate Index values 🟡 MINOR
|
||||
|
||||
43 Index values repeat (e.g. `W-0388`, `Eu-0332`, `C-0234`, `C-0235`, `C-0236`, `J-0175`).
|
||||
Since the filename is derived from Index, the importer's upsert keys both rows on the same
|
||||
`originalFilename`: the second occurrence is treated as `ALREADY_EXISTS` (if the first
|
||||
isn't a placeholder) and **its metadata is lost**, or it overwrites a placeholder.
|
||||
|
||||
**Proposed approach:** list the 43 duplicates, check whether they're true duplicates or
|
||||
two distinct letters that share an ID by mistake. Fix in the source data, or extend the ID
|
||||
scheme. Data task first; software only if the ID scheme must change.
|
||||
|
||||
---
|
||||
|
||||
## IMP-08 — Section/title rows interleaved with data 🟡 MINOR
|
||||
|
||||
Row 2 of the sheet is a section header sitting only in the sender column
|
||||
(`Brautbriefe von Walter der Gruyter an Eugenie Müller`) with a blank Index — caught by the
|
||||
blank-Index skip (overlaps IMP-06). There may be more such banners scattered through 7,943
|
||||
rows. Also relevant: the per-letter **keywords live in `Inhalt` (col 9)** as comma-joined
|
||||
values (`Tilburg,Verwandschaft`, `poetisch,Reise nach Breda`), while `Schlagwort` (col 8)
|
||||
holds a single broad tag (`Brautbriefe`). The importer only takes **one** tag column —
|
||||
decide which column feeds tags vs summary, and whether to split comma-lists into multiple
|
||||
tags.
|
||||
|
||||
**Proposed approach:** scan for rows where Index is blank but other cells are set (already
|
||||
have the count: relates to the 93 in IMP-06). Confirm tag vs summary column choice with
|
||||
Marcel.
|
||||
|
||||
---
|
||||
|
||||
## IMP-09 — Index ↔ Datei filename mismatches 🟡 MINOR
|
||||
|
||||
The `Datei` column (col 1) holds explicit relative paths (`..\__scan\W-0001.pdf`) but they
|
||||
don't always agree with the Index. Example: row 20 has Index `W-0010x` but Datei
|
||||
`..\__scan\W-0011x.pdf`. The importer derives the filename from **Index**, so it will look
|
||||
for `W-0010x.pdf` and may miss the actual scan. (Note: the `Datei` paths themselves are
|
||||
Windows-style with `\` and `..` and would be **rejected** by `isValidImportFilename` if anyone
|
||||
tried to use that column directly — 7,623 rows use backslashes, 7,455 contain `..`.)
|
||||
|
||||
**Proposed approach:** when the PDFs arrive, reconcile Index-derived names against actual
|
||||
filenames; produce a mismatch report. Keep deriving from Index (stable IDs) but flag
|
||||
disagreements. Mostly a data/QA task.
|
||||
|
||||
---
|
||||
|
||||
## IMP-10 — `x`-suffix rows (letter backsides / enclosures) 🟡 MINOR
|
||||
|
||||
**42 rows** have an `x`-suffixed Index (`W-0001x`, `W-0002x`, …). They're sparse — typically
|
||||
only Index + Datei + sender + receiver, no box/folder/date. They appear to be the reverse
|
||||
side or an enclosure of the preceding letter. The importer treats each as an independent
|
||||
Document, and the `metadataComplete` heuristic flags them complete as soon as a sender is
|
||||
present (date/box/folder all missing).
|
||||
|
||||
**Proposed approach:** decide whether `x` rows should be (a) separate documents, (b) extra
|
||||
pages/files attached to their parent, or (c) skipped. Affects both the data model and the
|
||||
`metadataComplete` heuristic. Discuss with Marcel.
|
||||
|
||||
---
|
||||
|
||||
## IMP-11 — Multi-receiver separators include bare `u` / `u.` 🟡 MINOR
|
||||
|
||||
`PersonNameParser.parseReceivers` already handles ` und `, ` u `, `//`, `geb.`,
|
||||
parenthesised shared surnames, and `Familie` filtering — good. But the real data also uses
|
||||
the abbreviation in forms the top-receivers list shows are common:
|
||||
`Eugenie u Walter de Gruyter` (230), `Herbert u Clara` (94), `Juan u Marie Cram` (75),
|
||||
and space-joined pairs like `Ella Anita` (79) that may be two people.
|
||||
Raw separator tally on receivers: ` und ` ×70, `,` ×11, `;` ×2, `/` ×1 — plus the many ` u `
|
||||
cases above. Senders are **not** parsed at all (taken raw), which is fine unless a sender
|
||||
cell ever holds two names.
|
||||
|
||||
**Proposed approach:** add `MassImportServiceTest` cases for the real-world strings above;
|
||||
extend the parser only where it actually fails. `Ella Anita`-style space-joined pairs are
|
||||
ambiguous — likely leave as one person unless the register says otherwise (ties to IMP-05).
|
||||
|
||||
---
|
||||
|
||||
## IMP-12 — Importer reads only the first sheet, no validation 🟡 MINOR
|
||||
|
||||
`readXlsx` does `workbook.getSheetAt(0)`. For the new xlsx that's `Familienarchiv` (✅), but
|
||||
the file also contains `Inhaltsverzeichnis grob`, `Inhaltsverzeichnis WdG`, `Tabelle4`.
|
||||
There is no header validation: if the wrong file/sheet is dropped in `/import`, the importer
|
||||
will happily map columns positionally and import nonsense. Also `findSpreadsheetFile()` picks
|
||||
the **first** spreadsheet found in `/import` — with three spreadsheets present there today,
|
||||
which one wins is filesystem-order-dependent.
|
||||
|
||||
**Proposed approach:** (a) validate the header row against expected names before importing;
|
||||
(b) make the target sheet/file explicit (config or header match) rather than "first found".
|
||||
Ties into the header-driven mapping in IMP-01(b).
|
||||
|
||||
---
|
||||
|
||||
## Summary of recommended sequencing
|
||||
|
||||
1. **Decide the importer mapping strategy** (IMP-01): positional re-config vs header-driven.
|
||||
Header-driven is the durable choice and unblocks IMP-03/12.
|
||||
2. **Build the tolerant date parser** (IMP-02) with original-string preservation + precision.
|
||||
3. **Import the Person register first** (IMP-04) and build the alias/marriage graph,
|
||||
which feeds person dedup (IMP-05).
|
||||
4. **Then import documents**, with reporting for blank-index (IMP-06), duplicates (IMP-07),
|
||||
and section rows (IMP-08).
|
||||
5. **Reconcile files** when the ~7,000 PDFs arrive (IMP-09), and decide `x`-row semantics
|
||||
(IMP-10).
|
||||
|
||||
Code-change items (→ Gitea issues when we get there): IMP-01(b), IMP-02, IMP-04, IMP-05
|
||||
(consume side), IMP-06 reporting, IMP-12. Pure-data items stay in this folder.
|
||||
386
docs/import-migration/02-normalization-spec.md
Normal file
386
docs/import-migration/02-normalization-spec.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# Spec — Import Normalizer
|
||||
|
||||
> Authored in the voice of **"Elicit"**, requirements engineer (see
|
||||
> `.claude/personas/req_engineer.md`). This is a requirements artifact: it states
|
||||
> *what* the normalizer must do and *how we'll know it's done*, in problem/behaviour
|
||||
> language. Technology choices already made during brainstorming (Python, openpyxl,
|
||||
> overrides-and-rerun) are recorded as **constraints**, not re-litigated here.
|
||||
|
||||
- **Status:** Draft for review
|
||||
- **Date:** 2026-05-25
|
||||
- **Related:** [`01-findings-spreadsheet-analysis.md`](./01-findings-spreadsheet-analysis.md) (issues `IMP-01..12`), [`README.md`](./README.md)
|
||||
- **Scope boundary:** This spec covers the **offline normalizer** that turns the raw
|
||||
spreadsheets into a clean, canonical dataset + review artifacts. Wiring the canonical
|
||||
contract into the Java `MassImportService` and the `Document`/`Person` model is **Phase 2**
|
||||
and gets its own spec. This spec only *defines the contract* Phase 2 must satisfy.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Brief
|
||||
|
||||
**Vision.** Turn the family's human-curated, free-form archive spreadsheets into a clean,
|
||||
canonical dataset that imports deterministically — without hand-editing thousands of rows
|
||||
and without losing the historical nuance of how things were originally written.
|
||||
|
||||
**Problem.** The real archive (`…aktuell…xlsx`, 7,943 rows) and the person register
|
||||
(`Personendatei 2.xlsx`, 163 people) were authored for humans to read, not machines to
|
||||
import. Dates are written as they appeared in each letter (≈90% unparseable by the current
|
||||
importer), the column layout differs from what the importer expects, and the same person
|
||||
appears under many names. Importing as-is produces garbage (see `IMP-01..12`).
|
||||
|
||||
**Goal (measurable).**
|
||||
- G1 — After the automated pass, **≤ 5%** of dated rows remain `UNKNOWN`; after the
|
||||
overrides-iteration loop, **≤ 0.5%**.
|
||||
- G2 — **100%** of source rows are represented in the canonical output or in a review file —
|
||||
*zero silent drops*.
|
||||
- G3 — **100%** of original values (raw date string, raw name string, source row number)
|
||||
are preserved.
|
||||
- G4 — A full run over the current inputs completes in **< 60 s** on the dev laptop and is
|
||||
**content-deterministic** when re-run with unchanged inputs+overrides: identical canonical
|
||||
cell matrices and identical review-file contents. (Workbook metadata is pinned; literal xlsx
|
||||
byte-identity is not guaranteed because the zip container stores entry metadata.)
|
||||
|
||||
**Primary actor.** Marcel — solo owner & data steward (tech comfort 4/5). Also: a future
|
||||
agent re-running the pipeline; and the `MassImportService` as the downstream consumer.
|
||||
|
||||
**Non-Goals (explicitly out of scope).**
|
||||
- NG1 — Changing `MassImportService` or the DB schema (that is Phase 2).
|
||||
- NG2 — Uploading/attaching the ~7,000 PDFs (they arrive later; import matches by `index`).
|
||||
- NG3 — A GUI. The interface is spreadsheets in, CSVs out, an overrides file hand-edited.
|
||||
- NG4 — Perfect genealogical reconstruction. We resolve confidently-matchable people; the
|
||||
long tail stays as provisional persons.
|
||||
- NG5 — OCR/transcription content (the new xlsx has no transcription column).
|
||||
|
||||
**Key assumptions.** (A1) Sheet `Familienarchiv` is the document source of truth.
|
||||
(A2) Archive date range is **1873–1957** (drives the 2-digit-year century rule).
|
||||
(A3) `index` is the stable document key and the basis for future PDF matching.
|
||||
(A4) `Schlagwort` is a broad tag; `Inhalt` is a short summary/topic.
|
||||
|
||||
**Risks.** (R1) 2-digit/partial dates are genuinely ambiguous → mitigated by precision flag
|
||||
+ overrides. (R2) Name matching false-positives merge distinct people → mitigated by
|
||||
conservative matching + review before merge. (R3) Source spreadsheet may be re-exported with
|
||||
layout drift → mitigated by header-name-based mapping, not fixed indices.
|
||||
|
||||
---
|
||||
|
||||
## 2. Personas
|
||||
|
||||
**Marcel — Data Steward.** Role: solo owner of Familienarchiv. Context: holds the complete
|
||||
raw archive; PDFs follow. Tech comfort: 4/5 (semi-technical, reads CSV/spreadsheets fluently,
|
||||
not keen to hand-edit 7,600 rows). Primary goal: a clean, importable dataset he trusts.
|
||||
Frustrations: dates in ~20 formats; one ancestor under 4 name variants. **JTBD:** *"When I
|
||||
have raw, human-curated archive spreadsheets, I want to transform them into a clean importable
|
||||
dataset without losing how things were originally written, so I can load the archive and keep
|
||||
correcting edge cases as they surface."*
|
||||
|
||||
**The Returning Agent.** Role: a future assistant session resuming the work. Goal: re-run the
|
||||
pipeline deterministically and understand exactly what still needs human input. **JTBD:**
|
||||
*"When I pick this up cold, I want one command and a clear residue report, so I can continue
|
||||
without re-deriving context."*
|
||||
|
||||
---
|
||||
|
||||
## 3. Constraints & Decisions Already Made
|
||||
|
||||
These were settled during brainstorming and are fixed inputs to the requirements below.
|
||||
|
||||
| # | Decision | Rationale |
|
||||
| --- | --- | --- |
|
||||
| C1 | **New canonical layout** with explicit headers (not the old positional ODS shape). | Fits the new data; importer becomes header-driven in Phase 2. |
|
||||
| C2 | Dates stored as **parsed (nullable) + raw + precision**. | Historical archive; never lose the original; enable "ca. 1916". |
|
||||
| C3 | **Include person resolution** (register + alias/marriage map → canonical persons) in this effort. | Maiden-name dedup needs the register. |
|
||||
| C4 | **Overrides-file + re-run** loop for residue. | Deterministic, diffable, repeatable. |
|
||||
| C5 | Implementation: **Python 3.12 + openpyxl**, standalone tool at `tools/import-normalizer/`. | Fast iteration; no Spring rebuild / coverage gate on transform code. |
|
||||
| C6 | Century rule for archive **1873–1957**: 2-digit `00–57`→`19YY`, `73–99`→`18YY`, `58–72`→**flag**; 3-digit `DDD`→`1DDD`; never 20xx. | Stated by Marcel. Boundaries live in config. |
|
||||
| C7 | `Schlagwort`→tag, `Inhalt`→summary. | Matches importer's existing semantics. |
|
||||
| C8 | Non-register correspondents become **provisional persons**. | ~945 distinct sender strings vs 163 register people. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
Each requirement has a stable ID. User stories use Connextra + Given-When-Then; system rules
|
||||
use EARS. Traceability to findings in §8.
|
||||
|
||||
### 4.1 Ingest & layout (`FR-INGEST`, `FR-MAP`)
|
||||
|
||||
**US-MAP-01** — *As the data steward, I want each source column mapped to a named canonical
|
||||
field regardless of its position, so a re-exported spreadsheet with shifted columns still
|
||||
imports correctly.*
|
||||
- AC1 — Given the `Familienarchiv` sheet, when the normalizer reads the header row, then it
|
||||
maps columns by **header name** (not fixed index) to the canonical fields.
|
||||
- AC2 — Given a header the normalizer does not recognise, when it runs, then it records the
|
||||
unknown header in `review/summary.txt` and continues (does not crash).
|
||||
- AC3 — Given a required source header is **absent**, when it runs, then it aborts with a
|
||||
clear message naming the missing header (fail loud, before producing partial output).
|
||||
|
||||
- **REQ-INGEST-01** — The normalizer shall read only the `Familienarchiv` sheet of the
|
||||
document workbook and the `Tabelle1` sheet of the person workbook.
|
||||
- **REQ-MAP-01** — Header matching shall be case-insensitive and tolerant of internal
|
||||
multiple spaces (e.g. `"Datum des Briefes"`).
|
||||
|
||||
### 4.2 Row triage (`FR-TRIAGE`) — resolves IMP-06, IMP-07, IMP-08
|
||||
|
||||
**US-TRIAGE-01** — *As the data steward, I want rows that have data but no index surfaced
|
||||
rather than dropped, so I never lose a letter silently.*
|
||||
- AC1 — Given a row whose `index` is blank but which has any other non-empty cell, when the
|
||||
normalizer runs, then that row is written to `review/blank-index-rows.csv` with its source
|
||||
row number and is **not** emitted as a canonical document.
|
||||
- AC2 — Given a fully empty row, when it runs, then the row is skipped and counted (not
|
||||
reported as an anomaly).
|
||||
|
||||
- **REQ-TRIAGE-01** — If two or more rows resolve to the same `index`, then the normalizer
|
||||
shall emit all of them to `review/duplicate-index.csv` and mark each canonical row
|
||||
`needs_review = duplicate_index` (it shall **not** silently drop either).
|
||||
- **REQ-TRIAGE-02** — Where a row is identified as a section/banner row (blank index, text
|
||||
only in a name column), the normalizer shall classify it as such in the blank-index report.
|
||||
- **REQ-TRIAGE-03** — Rows whose `index` ends in `x` (a transcription/back-side of the base
|
||||
letter, not yet independently mappable) shall be **skipped** — not emitted as a canonical
|
||||
document — and written to `review/skipped-x-suffix.csv` with their source row and base index
|
||||
(`index` minus the trailing `x`), so they can be linked in a later pass. (Resolves IMP-10.)
|
||||
|
||||
### 4.3 Date normalization (`FR-DATE`) — resolves IMP-02, IMP-03
|
||||
|
||||
**US-DATE-01** — *As the data steward, I want every date interpreted as precisely as the
|
||||
source allows, with the original always kept, so I can sort the archive and still see what the
|
||||
letter actually said.*
|
||||
- AC1 — Given a parseable date, when normalized, then `date_iso` holds the best-effort ISO
|
||||
date, `date_raw` holds the verbatim source string, and `date_precision` ∈
|
||||
`{DAY, MONTH, SEASON, YEAR, RANGE, APPROX, UNKNOWN}`.
|
||||
- AC2 — Given an unparseable date, when normalized, then `date_iso` is empty,
|
||||
`date_precision = UNKNOWN`, `date_raw` is preserved, and the value appears in
|
||||
`review/unparsed-dates.csv`.
|
||||
- AC3 — Given the same `date_raw` appears in `overrides/dates.csv`, when normalized, then the
|
||||
override's `(iso, precision)` wins over the automatic parse.
|
||||
|
||||
- **REQ-DATE-01** — The parser shall accept, at minimum, these forms (see §10 examples):
|
||||
Excel/ISO; `D.M.YYYY`/`D.M.YY`; `D/M. YY[YY]` (slash treated as dot); Roman-numeral months
|
||||
`I–XII`; German + English month names, full and abbreviated, with or without a separating
|
||||
space; `Month YYYY`; season/holiday + year; bare `YYYY`; and start-anchored ranges.
|
||||
- **REQ-DATE-02** — Precision shall be assigned by what is known: full day → `DAY`; month+year
|
||||
→ `MONTH` (day = 1); a **named feast/holiday + year** → resolved to its **actual calendar
|
||||
date for that year** → `DAY`; a **season + year** → representative mid-season month (day = 1)
|
||||
→ `SEASON`; year only → `YEAR` (month = Jan, day = 1); a range → start date + `RANGE`; a
|
||||
value carrying an uncertainty marker (`?`, `um`, `ca`, `circa`) → `APPROX` with best-effort date.
|
||||
- **REQ-DATE-03** — Two-digit and three-digit years shall be expanded per **C6**; a 2-digit
|
||||
year in `58–72` shall yield `UNKNOWN` + a review entry rather than a guess.
|
||||
- **REQ-DATE-04** — Trailing editorial notes (e.g. `", 2. Brief"`) shall be stripped before
|
||||
parsing and preserved (kept within `date_raw`; not invented into the date).
|
||||
- **REQ-DATE-05** — The parser shall be pure and side-effect-free so it can be unit-tested in
|
||||
isolation (see NFR-TEST-01).
|
||||
- **REQ-DATE-06** — **Movable feasts are never mapped to a fixed month**; they shall be
|
||||
computed per year from Easter (Gauss/Butcher computus): Karfreitag = Easter−2, Ostern =
|
||||
Easter Sunday, Himmelfahrt = Easter+39, Pfingst(sonntag) = Easter+49, Pfingstmontag =
|
||||
Easter+50, Fronleichnam = Easter+60, 1.–4. Advent = the 4th…1st Sunday before 25 Dec. Fixed
|
||||
feasts use a lookup table (Neujahr=01-01, Heiligabend=12-24, Weihnachten=12-25,
|
||||
Silvester=12-31, …). Seasons map to representative months: Frühling/Frühjahr=Apr, Sommer=Jul,
|
||||
Herbst=Oct, Winter=Jan. The feast/season tables and Easter algorithm live in `config.py`
|
||||
(NFR-MAINT-01).
|
||||
|
||||
### 4.4 Person resolution & dedup (`FR-PERS`, `FR-DEDUP`) — resolves IMP-04, IMP-05, IMP-11
|
||||
|
||||
**US-PERS-01** — *As the data steward, I want the genealogical register turned into canonical
|
||||
people with all their known facts, so documents can link to real persons.*
|
||||
- AC1 — Given a register row, when parsed, then a canonical person is produced with
|
||||
`person_id`, name parts, `maiden_name`, birth/death (parsed + raw + place), spouse,
|
||||
generation, nickname, notes — applying the same date rules as §4.3 to birth/death dates.
|
||||
- AC2 — Given multi-value given names (`"Charlotte,Meta,Jacobi"`), when parsed, then the
|
||||
primary given name is the first; the remainder are retained as additional names/aliases.
|
||||
|
||||
**US-PERS-02** — *As the data steward, I want each sender/receiver string matched to a
|
||||
canonical person where possible and never dropped otherwise, so the correspondence graph is
|
||||
complete.*
|
||||
- AC1 — Given a sender/receiver string, when resolved, then it maps to a register
|
||||
`person_id` via the alias index (exact → normalized/casefold → conservative fuzzy).
|
||||
- AC2 — Given no confident match, when resolved, then a **provisional person** is created from
|
||||
the cleaned string, linked, and listed in `review/unmatched-names.csv` (occurrence count +
|
||||
example source rows).
|
||||
- AC3 — Given the string appears in `overrides/names.csv`, when resolved, then it maps to the
|
||||
specified `person_id` (override wins).
|
||||
- AC4 — Given a multi-person receiver cell (`"Eugenie u Walter de Gruyter"`, `"Herbert u
|
||||
Clara"`, `"…//…"`, `"Hedi und Tutu (Gruber)"`), when resolved, then it is split into
|
||||
individual people, each resolved independently; ambiguous space-joined pairs
|
||||
(`"Ella Anita"`) are emitted to `review/ambiguous-receivers.csv` rather than guessed.
|
||||
|
||||
- **REQ-DEDUP-01** — The alias index shall be derived from the register: canonical
|
||||
"First Last", maiden form (`geb als`), spouse-surname married form, nickname, and
|
||||
first-name-only **only when unambiguous** across the register.
|
||||
- **REQ-DEDUP-02** — The normalizer shall not merge two distinct strings into one person on
|
||||
fuzzy similarity alone above a configured threshold without the match being reported; merges
|
||||
must be auditable.
|
||||
- **REQ-PERS-01** — Sender cells shall be parsed for multi-person content using the same rules
|
||||
as receiver cells (today the importer parses only receivers — IMP-11).
|
||||
|
||||
### 4.5 Overrides & idempotency (`FR-OVR`) — supports the iteration loop
|
||||
|
||||
- **REQ-OVR-01** — When the normalizer runs, then it shall load `overrides/dates.csv` and
|
||||
`overrides/names.csv` if present and apply them; absence of either file shall not be an error.
|
||||
- **REQ-OVR-02** — While overrides are unchanged and inputs are unchanged, re-running shall
|
||||
produce **byte-identical** canonical outputs and review files (NFR-IDEM-01).
|
||||
- **REQ-OVR-03** — Each override application shall be counted in `review/summary.txt` (how many
|
||||
dates/names were resolved by override vs automatically).
|
||||
|
||||
### 4.6 Canonical output & provenance (`FR-OUT`, `FR-PROV`) — resolves IMP-01, IMP-09, IMP-12
|
||||
|
||||
- **REQ-OUT-01** — The normalizer shall write `out/canonical-documents.xlsx` and
|
||||
`out/canonical-persons.xlsx` with the headered schemas in §6.
|
||||
- **REQ-PROV-01** — Every canonical document row shall carry `source_row` (1-based row number
|
||||
in the source sheet) so any value can be traced back to the original.
|
||||
- **REQ-PROV-02** — Every canonical row shall carry a `needs_review` field listing zero or more
|
||||
flags (`duplicate_index`, `unparsed_date`, `unmatched_sender`, `unmatched_receiver`,
|
||||
`index_file_mismatch`, …) so the import and the UI can foreground uncertain data.
|
||||
- **REQ-OUT-02** — Where the source `Datei` path disagrees with the index-derived filename
|
||||
(IMP-09), the normalizer shall record the discrepancy in `review/index-file-mismatch.csv`
|
||||
and flag the row; it shall **not** alter the `index` (the stable key).
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Functional Requirements
|
||||
|
||||
| ID | Category | Requirement (measurable) |
|
||||
| --- | --- | --- |
|
||||
| NFR-DATA-01 | Data integrity | 100% of source rows are accounted for in output **or** a review file; 100% of original date/name strings preserved verbatim. |
|
||||
| NFR-IDEM-01 | Determinism | Identical inputs + overrides ⇒ identical *logical* output across runs/machines: identical canonical cell matrices and review-file contents. Workbook `created`/`modified` metadata is pinned to a constant; ordering of all generated rows/aliases is stable (no set-iteration leakage). xlsx byte-identity is explicitly not required — determinism is asserted on content. |
|
||||
| NFR-PERF-01 | Performance | Full run over 7,943 doc rows + 163 person rows completes in < 60 s on the dev laptop. |
|
||||
| NFR-ACCUR-01 | Date accuracy | After automated pass, `UNKNOWN` dates ≤ 5% of dated rows; after overrides iteration, ≤ 0.5%. |
|
||||
| NFR-ACCUR-02 | Name coverage | Every sender/receiver occurrence yields a linked person (register or provisional); 0 dropped. |
|
||||
| NFR-I18N-01 | Encoding | UTF-8 end-to-end; German diacritics and ß round-trip with no mojibake in any output. |
|
||||
| NFR-TEST-01 | Testability | `dates.py` and `persons.py` have pytest tests covering every format/alias category in §10 with real examples from the archive. |
|
||||
| NFR-MAINT-01 | Maintainability | Column-name map, century boundaries, season→month map, and fuzzy threshold live in `config.py`, not inline in logic. |
|
||||
| NFR-OBSERV-01 | Observability | `review/summary.txt` reports per-run stats: rows in, documents out, dates by precision, names matched vs provisional, overrides applied, anomalies by type. |
|
||||
| NFR-SAFETY-01 | Source safety | Source workbooks are opened read-only and never written. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Dictionary (canonical contract)
|
||||
|
||||
This is the contract Phase 2 (the importer) must consume. Field-level, format-level — not a
|
||||
DB schema.
|
||||
|
||||
### 6.1 `canonical-documents.xlsx`
|
||||
|
||||
| Field | Required | Format / values | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `index` | yes | string | Stable key; basis for PDF matching. |
|
||||
| `box` | no | string | from `Box`. |
|
||||
| `folder` | no | string | from `Mappe`. |
|
||||
| `sender_person_id` | no | person_id | resolved; empty if no sender. |
|
||||
| `sender_name` | no | string | canonical display name (or cleaned raw if provisional). |
|
||||
| `receiver_person_ids` | no | `id\|id\|…` | pipe-separated. |
|
||||
| `receiver_names` | no | `name\|name\|…` | pipe-separated, aligned with ids. |
|
||||
| `date_iso` | no | `YYYY-MM-DD` | best-effort; empty if `UNKNOWN`. |
|
||||
| `date_raw` | no | string | verbatim source date. |
|
||||
| `date_precision` | yes | enum | `DAY\|MONTH\|SEASON\|YEAR\|RANGE\|APPROX\|UNKNOWN`. |
|
||||
| `location` | no | string | from `Ort`. |
|
||||
| `tags` | no | `tag\|tag` | from `Schlagwort`. |
|
||||
| `summary` | no | string | from `Inhalt`. |
|
||||
| `source_row` | yes | int | provenance (NFR-DATA-01). |
|
||||
| `needs_review` | yes | `flag\|flag` or empty | review flags (REQ-PROV-02). |
|
||||
|
||||
### 6.2 `canonical-persons.xlsx`
|
||||
|
||||
| Field | Required | Format | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `person_id` | yes | slug | stable id (e.g. `de-gruyter-eugenie`); collisions suffixed. |
|
||||
| `last_name` | yes | string | from `Familienname`. |
|
||||
| `first_name` | no | string | primary given name. |
|
||||
| `maiden_name` | no | string | from `geb als` — drives dedup. |
|
||||
| `title` | no | string | e.g. honorifics if present. |
|
||||
| `nickname` | no | string | from quoted `Bemerkung`/spouse field. |
|
||||
| `birth_date` / `birth_date_raw` / `birth_place` | no | ISO / string / string | §4.3 rules. |
|
||||
| `death_date` / `death_date_raw` / `death_place` | no | ISO / string / string | §4.3 rules. |
|
||||
| `spouse` | no | person_id or name | from `verheiratet mit`. |
|
||||
| `generation` | no | string | `G 1`..`G 4`. |
|
||||
| `notes` | no | string | from `Bemerkung`. |
|
||||
| `aliases` | no | `a\|b\|c` | every surface form that maps here. |
|
||||
| `provisional` | yes | bool | true if created from a document string, not the register. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Prioritized Backlog (MoSCoW)
|
||||
|
||||
| ID | Item | MoSCoW | Effort | Depends on |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| B1 | Project scaffolding + read both workbooks (`FR-INGEST`, header map `FR-MAP`) | Must | S | — |
|
||||
| B2 | Row triage + blank/duplicate/empty reports (`FR-TRIAGE`) | Must | S | B1 |
|
||||
| B3 | Date parser + precision + century rule + Easter/feast computus + season map + tests (`FR-DATE`) | Must | L | B1 |
|
||||
| B4 | Person register parser → canonical persons (`FR-PERS` US-PERS-01) | Must | M | B1 |
|
||||
| B5 | Alias index + name resolution + multi-person split (`FR-DEDUP`, US-PERS-02) | Must | L | B4 |
|
||||
| B6 | Overrides load + apply + idempotency (`FR-OVR`) | Must | S | B3,B5 |
|
||||
| B7 | Canonical writers + provenance + review summary (`FR-OUT`, `FR-PROV`) | Must | M | B2,B3,B5 |
|
||||
| B8 | Index↔Datei mismatch report (`REQ-OUT-02`) | Should | XS | B1 |
|
||||
| B9 | Ambiguous-receiver review path (US-PERS-02 AC4) | Should | S | B5 |
|
||||
| B10 | Comma-split `Inhalt` into extra tags | Could | XS | B7 |
|
||||
| B11 | Phase-2 importer wiring (separate spec) | Won't (this spec) | — | B7 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Traceability — Findings → Requirements
|
||||
|
||||
| Finding | Severity | Addressed by |
|
||||
| --- | --- | --- |
|
||||
| IMP-01 layout mismatch | blocker | C1, FR-MAP, REQ-OUT-01 |
|
||||
| IMP-02 free-text dates | blocker | FR-DATE (all), C2, C6 |
|
||||
| IMP-03 no ISO/normalized cols | blocker | FR-DATE, FR-PERS |
|
||||
| IMP-04 register unimported | major | C3, US-PERS-01, §6.2 |
|
||||
| IMP-05 name variants → dupes | major | C3, FR-DEDUP |
|
||||
| IMP-06 blank-index dropped | major | US-TRIAGE-01 |
|
||||
| IMP-07 duplicate indices | minor | REQ-TRIAGE-01 |
|
||||
| IMP-08 section rows / tags vs summary | minor | REQ-TRIAGE-02, C7 |
|
||||
| IMP-09 index↔file mismatch | minor | REQ-OUT-02, B8 |
|
||||
| IMP-10 `x`-suffix rows | minor | REQ-TRIAGE-03 (skip + log this pass) |
|
||||
| IMP-11 sender not split / ` u ` sep | minor | REQ-PERS-01, US-PERS-02 AC4 |
|
||||
| IMP-12 first-sheet, no validation | minor | REQ-INGEST-01, FR-MAP AC2/AC3 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions / TBD Register
|
||||
|
||||
| ID | Question | Why it matters | Ref | Resolution |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| OQ-01 ✅ | Season/holiday → date. | Accuracy of ~70 SEASON/feast rows. | REQ-DATE-06 | **Resolved (2026-05-25):** movable feasts (Ostern, Pfingsten, Himmelfahrt, Advent, …) **computed per year from Easter — never a fixed month**; fixed feasts looked up (Weihnachten=12-25, Neujahr=01-01, …); seasons = mid-season month (Frühling=Apr, Sommer=Jul, Herbst=Oct, Winter=Jan). |
|
||||
| OQ-02 ✅ | Date ranges: start only, or start+end? | Sorting/display of ~315 range values. | REQ-DATE-02 | **Confirmed:** store **start** in `date_iso`, precision `RANGE`, full text in `date_raw`. |
|
||||
| OQ-03 ✅ | `person_id` format. | Stability across re-runs; diffability. | §6 | **Confirmed:** readable slug `lastname-firstname`, numeric suffix on collision. |
|
||||
| OQ-04 ✅ | `x`-suffix row handling. | 42 rows. | REQ-TRIAGE-03 | **Resolved (2026-05-25):** `x` rows are transcriptions of the base letter but not yet mappable → **skip this pass**, log to `review/skipped-x-suffix.csv` for later linking. |
|
||||
| OQ-05 ✅ | Importer output format. | Phase-2 reader. | B11 | **Confirmed:** `.xlsx` (openpyxl-native, headered). |
|
||||
| OQ-06 ✅ | Fuzzy-match policy. | False-positive person merges (R2). | REQ-DEDUP-02 | **Confirmed:** conservative — report all fuzzy matches; no silent merge. |
|
||||
|
||||
*All open questions resolved as of 2026-05-25. New ambiguities discovered during build go here.*
|
||||
|
||||
---
|
||||
|
||||
## 10. Glossary & Worked Examples
|
||||
|
||||
**Precision** — how exactly a date is known (`DAY` … `UNKNOWN`). **Provisional person** — a
|
||||
person created from a document name string with no register match. **Alias index** — map from
|
||||
every known surface form of a name to a canonical `person_id`. **Override** — a
|
||||
human-supplied correction applied deterministically on each run.
|
||||
|
||||
**Date examples → expected outcome:**
|
||||
|
||||
| `date_raw` | `date_iso` | `date_precision` |
|
||||
| --- | --- | --- |
|
||||
| `15.2.1888` | 1888-02-15 | DAY |
|
||||
| `6.März 1888` | 1888-03-06 | DAY |
|
||||
| `22.III.18` | 1918-03-22 | DAY |
|
||||
| `13.5.09` | 1909-05-13 | DAY |
|
||||
| `10.Oct.95` | 1895-10-10 | DAY |
|
||||
| `17/6. 1916` | 1916-06-17 | DAY |
|
||||
| `Mai 1895` | 1895-05-01 | MONTH |
|
||||
| `Pfingsten 1922` | 1922-06-04 | DAY (computed: Easter 1922 = Apr 16, +49 days) |
|
||||
| `Herbst 1913` | 1913-10-01 | SEASON |
|
||||
| `1905` | 1905-01-01 | YEAR |
|
||||
| `8.1.1916 - 15.3.1916` | 1916-01-08 | RANGE |
|
||||
| `17.Nov (?) 1887` | 1887-11-17 | APPROX |
|
||||
| `?` | *(empty)* | UNKNOWN |
|
||||
|
||||
**Name examples → expected outcome:**
|
||||
|
||||
| raw cell | resolves to |
|
||||
| --- | --- |
|
||||
| `Eugenie Müller` (+ register `geb Müller`) | `de-gruyter-eugenie` (matched via maiden alias) |
|
||||
| `Eugenie de Gruyter` | `de-gruyter-eugenie` |
|
||||
| `Herbert u Clara` | `cram-herbert` + `cram-clara` (split, surname distributed) |
|
||||
| `Hedi und Tutu (Gruber)` | `gruber-hedi` + `gruber-tutu` |
|
||||
| `Ella Anita` | → `review/ambiguous-receivers.csv` (not auto-split) |
|
||||
| `Hans Wittkopf` (not in register) | provisional `wittkopf-hans` |
|
||||
2281
docs/import-migration/03-normalizer-implementation-plan.md
Normal file
2281
docs/import-migration/03-normalizer-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
502
docs/import-migration/04-unresolved-names-plan.md
Normal file
502
docs/import-migration/04-unresolved-names-plan.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# Unresolved-Name Classification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a focused `review/unresolved-names.csv` that isolates sender/receiver strings whose *name itself* is problematic (unknown/illegible, single-token, relational-only, collective/group, prose-in-name-column, or a genuine two-given-name pair), and fix the ambiguous-pair heuristic so a plain `First Surname` external person (e.g. `Mieze Schefold`) is no longer falsely flagged.
|
||||
|
||||
**Architecture:** A pure `classify_name(raw, given_names)` function in `persons.py` returns a `NameClass`. `ResolutionContext` classifies every *unmatched* name and records the non-`RESOLVABLE` ones in `self.unresolved`. A runtime-built given-name set (register first names + a small config supplement) lets the classifier distinguish a two-given-name pair (`Ella Anita` → two people) from a first+surname single person (`Mieze Schefold`). The orchestrator writes the aggregated report and per-category stats, replacing the noisy `ambiguous-receivers.csv`.
|
||||
|
||||
**Tech Stack:** Python 3.12, openpyxl, pytest — extends the existing `tools/import-normalizer/`.
|
||||
|
||||
**Context:** This builds on the completed normalizer (PR #663). Run all tests with CWD = the tool dir, e.g. `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_X.py -v`. Reuse the existing venv at `tools/import-normalizer/.venv` (do NOT recreate it). Commit on the current branch `docs/import-migration` (never main, never push). Each commit message ends with a trailing `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>` line.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
tools/import-normalizer/
|
||||
├── config.py # + RELATIONAL_TERMS, COLLECTIVE_TERMS, UNKNOWN_NAME_MARKERS, PROSE_MAX_LEN, EXTRA_GIVEN_NAMES
|
||||
├── persons.py # + NameClass, classify_name(), build_given_names(); ResolutionContext gains given_names + self.unresolved
|
||||
├── normalize.py # writes unresolved-names.csv (replaces ambiguous-receivers.csv) + per-category stats
|
||||
├── README.md # + unresolved-names.csv row in the review-file table
|
||||
└── tests/
|
||||
├── test_config.py # + name-table presence test
|
||||
├── test_persons.py # + classify_name + build_given_names tests
|
||||
├── test_documents.py # ambiguous test → unresolved test (+ resolvable-pair test)
|
||||
└── test_normalize.py # integration asserts unresolved-names.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Config — name-classification tables
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/import-normalizer/config.py`
|
||||
- Modify: `tools/import-normalizer/tests/test_config.py`
|
||||
|
||||
- [ ] **Step 1: Add the failing test** to `tests/test_config.py`
|
||||
|
||||
```python
|
||||
def test_name_classification_tables():
|
||||
assert "tante" in config.RELATIONAL_TERMS
|
||||
assert "familie" in config.COLLECTIVE_TERMS
|
||||
assert "unbekannt" in config.UNKNOWN_NAME_MARKERS
|
||||
assert config.PROSE_MAX_LEN >= 30
|
||||
assert "anita" in config.EXTRA_GIVEN_NAMES
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_config.py::test_name_classification_tables -v && cd -`
|
||||
Expected: FAIL — `AttributeError: module 'config' has no attribute 'RELATIONAL_TERMS'`.
|
||||
|
||||
- [ ] **Step 3: Implement** — append to `config.py` (after the existing tables, before/after `KNOWN_LAST_NAMES` — anywhere at module level)
|
||||
|
||||
```python
|
||||
# --- Name classification (unresolved-name review) ---
|
||||
# Relational reference terms — a sender/receiver named by relation, not a proper name.
|
||||
RELATIONAL_TERMS = {
|
||||
"tante", "onkel", "mutter", "vater", "oma", "opa", "großmutter", "grossmutter",
|
||||
"großvater", "grossvater", "schwester", "bruder", "cousin", "cousine", "kusine",
|
||||
"neffe", "nichte", "tochter", "sohn", "schwager", "schwägerin", "schwiegermutter",
|
||||
"schwiegervater", "enkel", "enkelin", "vetter", "base", "witwe", "witwer",
|
||||
}
|
||||
# Collective/group terms — not a single person. Matched against alpha-only word tokens
|
||||
# (so "Fam.Cram" -> ["fam","cram"] matches "fam"), NOT as substrings/prefixes.
|
||||
COLLECTIVE_TERMS = {
|
||||
"familie", "fam", "kinder", "eltern", "geschwister", "großeltern",
|
||||
"grosseltern", "alle", "diverse", "div", "gebrüder", "gebr",
|
||||
}
|
||||
# Markers of an unknown/illegible name (the literal "?" is handled separately in code).
|
||||
# All long enough to be safe as SUBSTRING matches — do NOT add short tokens like "nn"
|
||||
# (it occurs inside real names: Hanni, Johanna, Anna).
|
||||
UNKNOWN_NAME_MARKERS = {"unbekannt", "unbek", "unleserlich", "unklar", "unsicher"}
|
||||
# A name-column value longer than this (chars) is treated as prose/description, not a name.
|
||||
PROSE_MAX_LEN = 40
|
||||
# Common given names that may appear in two-given-name pairs (e.g. "Ella Anita") but are not
|
||||
# in the family register. Only used to detect AMBIGUOUS_PAIR — extend as review surfaces more.
|
||||
EXTRA_GIVEN_NAMES = {
|
||||
"ella", "anita", "kurt", "georg", "hanni", "mieze", "ellen", "leni", "klara",
|
||||
"margret", "gustava", "emmy", "minna", "sophie", "helga", "raymonde", "augusta",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_config.py -v && cd -`
|
||||
Expected: PASS (all config tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/import-normalizer/config.py tools/import-normalizer/tests/test_config.py
|
||||
git commit -m "feat(normalizer): config tables for name classification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `classify_name` + `NameClass`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/import-normalizer/persons.py`
|
||||
- Modify: `tools/import-normalizer/tests/test_persons.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests** to `tests/test_persons.py`
|
||||
|
||||
```python
|
||||
from persons import NameClass
|
||||
|
||||
GIVEN = {"ella", "anita", "kurt", "georg", "clara", "eugenie"}
|
||||
|
||||
def test_classify_unknown():
|
||||
assert persons.classify_name("?", GIVEN) is NameClass.UNKNOWN
|
||||
assert persons.classify_name("A. Kredell?", GIVEN) is NameClass.UNKNOWN
|
||||
assert persons.classify_name("unbekannt", GIVEN) is NameClass.UNKNOWN
|
||||
|
||||
def test_classify_prose():
|
||||
assert persons.classify_name("Adressenliste v Clara Cram zur Kondolenz", GIVEN) is NameClass.PROSE
|
||||
assert persons.classify_name("Clara de Gruyter(*1871)", GIVEN) is NameClass.PROSE # digit
|
||||
assert persons.classify_name('"Cramiade" Gedicht', GIVEN) is NameClass.PROSE # quote
|
||||
|
||||
def test_classify_collective():
|
||||
assert persons.classify_name("Familie", GIVEN) is NameClass.COLLECTIVE
|
||||
assert persons.classify_name("Fam.Cram", GIVEN) is NameClass.COLLECTIVE
|
||||
assert persons.classify_name("Eltern Cram", GIVEN) is NameClass.COLLECTIVE
|
||||
assert persons.classify_name("seine Kinder", GIVEN) is NameClass.COLLECTIVE
|
||||
|
||||
def test_classify_relational():
|
||||
assert persons.classify_name("Cousine Emmy Haniel", GIVEN) is NameClass.RELATIONAL
|
||||
assert persons.classify_name("Schwester Hanni", GIVEN) is NameClass.RELATIONAL
|
||||
|
||||
def test_classify_single_token():
|
||||
assert persons.classify_name("Agnes", GIVEN) is NameClass.SINGLE_TOKEN
|
||||
assert persons.classify_name("A.B.", GIVEN) is NameClass.SINGLE_TOKEN
|
||||
|
||||
def test_classify_ambiguous_pair():
|
||||
assert persons.classify_name("Ella Anita", GIVEN) is NameClass.AMBIGUOUS_PAIR
|
||||
assert persons.classify_name("Kurt Georg", GIVEN) is NameClass.AMBIGUOUS_PAIR
|
||||
|
||||
def test_classify_resolvable_single_person():
|
||||
# first + surname (surname not a given name) -> one real person, NOT ambiguous
|
||||
assert persons.classify_name("Mieze Schefold", GIVEN) is NameClass.RESOLVABLE
|
||||
assert persons.classify_name("Adolf Butenandt", GIVEN) is NameClass.RESOLVABLE
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_persons.py -k classify -v && cd -`
|
||||
Expected: FAIL — `NameClass` / `classify_name` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement** — add to `persons.py`. Add `from enum import StrEnum` to the imports if not present, then add:
|
||||
|
||||
```python
|
||||
class NameClass(StrEnum):
|
||||
RESOLVABLE = "resolvable"
|
||||
UNKNOWN = "unknown"
|
||||
SINGLE_TOKEN = "single_token"
|
||||
RELATIONAL = "relational"
|
||||
COLLECTIVE = "collective"
|
||||
PROSE = "prose"
|
||||
AMBIGUOUS_PAIR = "ambiguous_pair"
|
||||
|
||||
|
||||
_QUOTE_CHARS = "\"'“”„‚‘’"
|
||||
|
||||
|
||||
def classify_name(raw: str, given_names: set[str]) -> NameClass:
|
||||
"""Classify a (post-split) sender/receiver string by why it may be unresolvable.
|
||||
|
||||
Precedence (first match wins): UNKNOWN -> PROSE -> COLLECTIVE -> RELATIONAL ->
|
||||
SINGLE_TOKEN -> AMBIGUOUS_PAIR -> RESOLVABLE.
|
||||
"""
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return NameClass.RESOLVABLE
|
||||
low = s.lower()
|
||||
tokens = s.split()
|
||||
# alpha-only word tokens: "Fam.Cram" -> ["fam","cram"], so collective/relational terms
|
||||
# are matched as whole words (no substring/prefix false positives like "Allerton").
|
||||
alpha_words = re.findall(r"[a-zäöüß]+", low)
|
||||
if "?" in s or any(m in low for m in config.UNKNOWN_NAME_MARKERS):
|
||||
return NameClass.UNKNOWN
|
||||
if (len(s) > config.PROSE_MAX_LEN or any(c.isdigit() for c in s)
|
||||
or any(q in s for q in _QUOTE_CHARS) or len(tokens) > 3):
|
||||
return NameClass.PROSE
|
||||
if any(w in config.COLLECTIVE_TERMS for w in alpha_words):
|
||||
return NameClass.COLLECTIVE
|
||||
if any(w in config.RELATIONAL_TERMS for w in alpha_words):
|
||||
return NameClass.RELATIONAL
|
||||
if len(tokens) == 1:
|
||||
return NameClass.SINGLE_TOKEN
|
||||
if len(tokens) == 2 and all(_norm(t) in given_names for t in tokens):
|
||||
return NameClass.AMBIGUOUS_PAIR
|
||||
return NameClass.RESOLVABLE
|
||||
|
||||
|
||||
# Known limitation: a 4+-token name with no digits/quotes (e.g. "Anna von der Heide") is
|
||||
# classified PROSE. Such multi-particle names are rare here and usually resolve via the
|
||||
# register; if they surface in review, lower-priority than the real prose entries.
|
||||
```
|
||||
|
||||
> Note: `_norm` already exists in `persons.py` (added in the alias-index task) and strips accents + lowercases. `classify_name` uses it so given-name matching is accent-insensitive.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_persons.py -v && cd -`
|
||||
Expected: PASS (all persons tests, including the 7 new classify tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/import-normalizer/persons.py tools/import-normalizer/tests/test_persons.py
|
||||
git commit -m "feat(normalizer): classify_name + NameClass"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `build_given_names`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/import-normalizer/persons.py`
|
||||
- Modify: `tools/import-normalizer/tests/test_persons.py`
|
||||
|
||||
- [ ] **Step 1: Add failing test** to `tests/test_persons.py`
|
||||
|
||||
```python
|
||||
def test_build_given_names():
|
||||
people = persons.parse_register([
|
||||
{"last_name": "de Gruyter", "first_name": "Eugenie"},
|
||||
{"last_name": "Cram", "first_name": "Charlotte,Meta"}, # comma -> primary + extra given
|
||||
])
|
||||
g = persons.build_given_names(people, {"Anita"})
|
||||
assert "eugenie" in g
|
||||
assert "charlotte" in g and "meta" in g # primary + extra given names
|
||||
assert "anita" in g # from the extra set, normalized
|
||||
assert "schefold" not in g
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_persons.py::test_build_given_names -v && cd -`
|
||||
Expected: FAIL — `build_given_names` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement** — add to `persons.py`
|
||||
|
||||
```python
|
||||
def build_given_names(register: list[Person], extra: set[str]) -> set[str]:
|
||||
"""Set of normalized given names from the register (first + extra given) plus a supplement.
|
||||
|
||||
Used by classify_name to tell a two-given-name pair (two people) from a first+surname.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
for p in register:
|
||||
if p.first_name:
|
||||
names.add(_norm(p.first_name))
|
||||
for g in p.extra_given_names:
|
||||
names.add(_norm(g))
|
||||
for e in extra:
|
||||
names.add(_norm(e))
|
||||
return names
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_persons.py -v && cd -`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/import-normalizer/persons.py tools/import-normalizer/tests/test_persons.py
|
||||
git commit -m "feat(normalizer): build_given_names from register + supplement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Integrate — ResolutionContext records unresolved; orchestrator writes the report
|
||||
|
||||
This task touches `persons.py`, `normalize.py`, and two test files together so the whole suite stays green in one commit (removing `ctx.ambiguous` requires updating its only consumer, `normalize.py`, in the same change).
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/import-normalizer/persons.py` (ResolutionContext)
|
||||
- Modify: `tools/import-normalizer/normalize.py`
|
||||
- Modify: `tools/import-normalizer/tests/test_documents.py`
|
||||
- Modify: `tools/import-normalizer/tests/test_normalize.py`
|
||||
|
||||
- [ ] **Step 1: Update the failing tests first**
|
||||
|
||||
In `tests/test_documents.py`, **replace** the existing `test_ambiguous_space_pair_flagged_not_split` function entirely with these two functions:
|
||||
|
||||
```python
|
||||
def test_ambiguous_pair_recorded_in_unresolved():
|
||||
people = persons.parse_register([{"last_name": "de Gruyter", "first_name": "Walter"}])
|
||||
ctx = persons.ResolutionContext(persons.AliasIndex(people), name_overrides={},
|
||||
given_names={"ella", "anita"})
|
||||
raw = documents.RawRow(source_row=7, index="C-0200", sender="", receivers="Ella Anita")
|
||||
doc = documents.to_canonical(raw, ctx, date_overrides={})
|
||||
assert len(doc.receiver_person_ids) == 1 # not split — one provisional
|
||||
assert any(name == "Ella Anita" and cat == "ambiguous_pair" for name, cat, _ in ctx.unresolved)
|
||||
|
||||
def test_resolvable_first_surname_pair_not_unresolved():
|
||||
ctx = persons.ResolutionContext(persons.AliasIndex([]), name_overrides={},
|
||||
given_names={"ella", "anita"})
|
||||
ctx.resolve_one("Mieze Schefold", source_row=1) # surname is not a given name
|
||||
assert ctx.unresolved == [] # RESOLVABLE -> not recorded
|
||||
```
|
||||
|
||||
In `tests/test_normalize.py`, in the `_doc_wb` fixture, change the `C-0001` row's receiver from empty to `"?"` so the run produces an unresolved entry. Find the line that appends the `C-0001` row and set its `EmpfängerIn` cell to `"?"`. For example the row currently reads:
|
||||
|
||||
```python
|
||||
ws.append(["C-0001", "", "", "", "Hans Wittkopf", "", "Freitag 1919", "", "", ""])
|
||||
```
|
||||
|
||||
change the 6th cell (EmpfängerIn) from `""` to `"?"`:
|
||||
|
||||
```python
|
||||
ws.append(["C-0001", "", "", "", "Hans Wittkopf", "?", "Freitag 1919", "", "", ""])
|
||||
```
|
||||
|
||||
Then add these assertions inside `test_run_end_to_end`, right after the existing `assert (review_dir / "unparsed-dates.csv").exists()` line:
|
||||
|
||||
```python
|
||||
assert (out_dir / "canonical-documents.xlsx").exists() # (keep existing asserts above)
|
||||
assert (review_dir / "unresolved-names.csv").exists()
|
||||
unresolved_text = (review_dir / "unresolved-names.csv").read_text(encoding="utf-8")
|
||||
assert "unknown" in unresolved_text and "?" in unresolved_text # the "?" receiver
|
||||
assert not (review_dir / "ambiguous-receivers.csv").exists() # replaced
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/test_documents.py tests/test_normalize.py -v && cd -`
|
||||
Expected: FAIL — `ResolutionContext` has no `given_names`/`unresolved`; `unresolved-names.csv` not written.
|
||||
|
||||
- [ ] **Step 3a: Implement — `ResolutionContext` in `persons.py`**
|
||||
|
||||
Replace the `ResolutionContext.__init__` body's two lines (`self.ambiguous` and add `given_names`) and the relevant methods. The new `__init__`:
|
||||
|
||||
```python
|
||||
def __init__(self, alias_index: AliasIndex, name_overrides: dict[str, str],
|
||||
given_names: set[str] | None = None):
|
||||
self.index = alias_index
|
||||
self.name_overrides = name_overrides
|
||||
self.given_names = given_names or set()
|
||||
self.provisional: dict[str, Person] = {}
|
||||
self.unmatched: dict[str, list] = {}
|
||||
self.unresolved: list[tuple] = [] # (raw_name, category, source_row) for non-RESOLVABLE names
|
||||
self._raw_to_pid: dict[str, str] = {}
|
||||
self.override_hits = 0
|
||||
```
|
||||
|
||||
In `resolve_one`, the provisional branch must classify the name. Replace this existing block:
|
||||
|
||||
```python
|
||||
# provisional person (unmatched) — never reuse a register id
|
||||
self.unmatched.setdefault(name, []).append(source_row)
|
||||
if name in self._raw_to_pid:
|
||||
return self._raw_to_pid[name], name, False
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
# provisional person (unmatched) — never reuse a register id
|
||||
self.unmatched.setdefault(name, []).append(source_row)
|
||||
category = classify_name(name, self.given_names)
|
||||
if category is not NameClass.RESOLVABLE:
|
||||
self.unresolved.append((name, str(category), source_row))
|
||||
if name in self._raw_to_pid:
|
||||
return self._raw_to_pid[name], name, False
|
||||
```
|
||||
|
||||
Replace the entire `resolve_receivers` method (the ambiguous detection now lives in `resolve_one` via `classify_name`):
|
||||
|
||||
```python
|
||||
def resolve_receivers(self, raw: str, source_row: int):
|
||||
return [self.resolve_one(part, source_row) for part in split_receivers(raw)]
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Implement — `normalize.py`**
|
||||
|
||||
Find the line that builds the context:
|
||||
|
||||
```python
|
||||
ctx = persons.ResolutionContext(alias_index, name_overrides)
|
||||
```
|
||||
|
||||
replace it with (build the given-name set from the register + config supplement):
|
||||
|
||||
```python
|
||||
given_names = persons.build_given_names(register, config.EXTRA_GIVEN_NAMES)
|
||||
ctx = persons.ResolutionContext(alias_index, name_overrides, given_names=given_names)
|
||||
```
|
||||
|
||||
Replace the `ambiguous-receivers.csv` write line:
|
||||
|
||||
```python
|
||||
writers.write_review_csv(review_dir / "ambiguous-receivers.csv", ["raw", "part", "source_row"], ctx.ambiguous)
|
||||
```
|
||||
|
||||
with an aggregated unresolved-names report:
|
||||
|
||||
```python
|
||||
unresolved_agg: dict[tuple, list] = {}
|
||||
for name, category, row in ctx.unresolved:
|
||||
unresolved_agg.setdefault((category, name), []).append(row)
|
||||
unresolved_rows = sorted(
|
||||
([cat, name, len(rows), " ".join(map(str, sorted(rows)[:5]))]
|
||||
for (cat, name), rows in unresolved_agg.items()),
|
||||
key=lambda r: (r[0], -r[2], r[1]))
|
||||
writers.write_review_csv(review_dir / "unresolved-names.csv",
|
||||
["category", "raw", "count", "example_rows"], unresolved_rows)
|
||||
```
|
||||
|
||||
In the `stats` dict, replace the `"ambiguous_receivers"` line:
|
||||
|
||||
```python
|
||||
"ambiguous_receivers": len(ctx.ambiguous),
|
||||
```
|
||||
|
||||
with a per-category breakdown:
|
||||
|
||||
```python
|
||||
"unresolved_name_occurrences": len(ctx.unresolved),
|
||||
"unresolved_unknown": sum(1 for _, c, _ in ctx.unresolved if c == "unknown"),
|
||||
"unresolved_single_token": sum(1 for _, c, _ in ctx.unresolved if c == "single_token"),
|
||||
"unresolved_relational": sum(1 for _, c, _ in ctx.unresolved if c == "relational"),
|
||||
"unresolved_collective": sum(1 for _, c, _ in ctx.unresolved if c == "collective"),
|
||||
"unresolved_prose": sum(1 for _, c, _ in ctx.unresolved if c == "prose"),
|
||||
"unresolved_ambiguous_pair": sum(1 for _, c, _ in ctx.unresolved if c == "ambiguous_pair"),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the whole suite to verify green**
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/ -q && cd -`
|
||||
Expected: PASS (all tests, no `ambiguous` references remain).
|
||||
|
||||
Also grep to confirm no dangling references:
|
||||
Run: `grep -rn "ctx.ambiguous\|ambiguous-receivers\|ambiguous_receivers\|self.ambiguous" tools/import-normalizer/*.py`
|
||||
Expected: no matches.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/import-normalizer/persons.py tools/import-normalizer/normalize.py tools/import-normalizer/tests/test_documents.py tools/import-normalizer/tests/test_normalize.py
|
||||
git commit -m "feat(normalizer): unresolved-names report + fix ambiguous-pair over-flagging"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: README — document the new report
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/import-normalizer/README.md`
|
||||
|
||||
- [ ] **Step 1: Update the review-file table** in `README.md`. Replace the `ambiguous-receivers.csv` row with an `unresolved-names.csv` row. Find the table row referencing `ambiguous-receivers.csv` and replace it with:
|
||||
|
||||
```markdown
|
||||
| `unresolved-names.csv` | Names whose value is itself problematic, grouped by `category`: `unknown` (`?`/illegible), `single_token` (first OR last name only), `relational` (`Tante …`), `collective` (`Familie …`), `prose` (a description landed in a name column), `ambiguous_pair` (two given names → likely two people, not auto-split). Review highest-impact categories first; add decisions to `overrides/names.csv`. |
|
||||
```
|
||||
|
||||
If the README has no such row (older version), add the row above to the review-file table.
|
||||
|
||||
- [ ] **Step 2: Add a note** to the iteration-loop section of `README.md` (after the table):
|
||||
|
||||
```markdown
|
||||
> `unresolved-names.csv` is the focused "names that need a human" list — distinct from
|
||||
> `unmatched-names.csv` (which is just non-family correspondents that got provisional persons).
|
||||
> The given-name set that drives `ambiguous_pair` detection is the register's first names plus
|
||||
> `config.EXTRA_GIVEN_NAMES` — add names there if a real two-person cell isn't being flagged.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the suite is still green** (README-only change, but confirm nothing references the old file)
|
||||
|
||||
Run: `cd tools/import-normalizer && .venv/bin/python -m pytest tests/ -q && cd -`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/import-normalizer/README.md
|
||||
git commit -m "docs(normalizer): document unresolved-names.csv review report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage** (against the agreed proposal):
|
||||
- Focused report isolating problem name classes → Task 4 writes `review/unresolved-names.csv` with a `category` column; categories defined in Task 2 `classify_name`. ✓
|
||||
- Fix ambiguous over-flagging of `First Surname` → Task 2 `AMBIGUOUS_PAIR` requires *both* tokens in the given-name set; `Mieze Schefold` → `RESOLVABLE` (tested). ✓
|
||||
- Distinguish "not fully known" (unknown/single-token/relational/collective/prose) from "can't split cleanly" (ambiguous_pair) → all are `NameClass` values, each its own category column value. ✓
|
||||
- Per-category counts in summary → Task 4 stats. ✓
|
||||
- Senders covered too (not just receivers) → classification happens in `resolve_one`, which both `resolve_sender` and `resolve_receivers` call. ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO; every code step has complete code. The README replacement gives the exact row text.
|
||||
|
||||
**Type consistency:** `NameClass` (StrEnum) defined Task 2; `classify_name(raw, given_names)` and `build_given_names(register, extra)` signatures used consistently in Task 4; `ResolutionContext(alias_index, name_overrides, given_names=…)` matches the new `__init__`; `self.unresolved` is `list[tuple]` of `(raw, category, source_row)` and read with that shape in both the report and the stats. `str(category)` yields the StrEnum value (e.g. `"ambiguous_pair"`), matching the stat comparisons and the test assertions.
|
||||
|
||||
**Cross-task green:** Task 4 deliberately bundles the `persons.py` + `normalize.py` + test changes into one commit because removing `ctx.ambiguous` breaks its consumer otherwise — no red commit is left behind (lesson from the prior build).
|
||||
|
||||
**Out of scope (future):** Spanish month names + `Mon DD-YYYY` date form (separate date-parser enhancement); promoting `unresolved` rows into a document-level `needs_review` flag; auto-splitting confirmed `ambiguous_pair` entries via overrides.
|
||||
62
docs/import-migration/README.md
Normal file
62
docs/import-migration/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Import Migration — Working Folder
|
||||
|
||||
This folder tracks the iterative work of mass-importing the **real, raw family archive**
|
||||
spreadsheets (≈7,600 letter rows + ~7,000 PDFs that arrive later) into Familienarchiv.
|
||||
|
||||
It is intentionally **local docs, not Gitea issues**. We only open a Gitea issue when a
|
||||
finding requires a *software* change (e.g. a new date parser). Pure data observations and
|
||||
the running plan live here so any agent can pick the work up cold.
|
||||
|
||||
## Source files (in `/import`)
|
||||
|
||||
| File | What it is | Importer support today |
|
||||
| --- | --- | --- |
|
||||
| `zzfamilienarchiv aktuell 2 - Kopie 2025-07-05.xlsx` | The **real raw archive** — 7,943 rows, sheet `Familienarchiv`. Human-readable, dates as written in the letters. | ❌ layout does **not** match importer defaults |
|
||||
| `Personendatei 2.xlsx` | Genealogical **person register** — 163 people, sheet `Tabelle1` (maiden names, birth/death, marriages, relationships). | ❌ no importer at all |
|
||||
| `zzfamilienarchiv Walter und Eugenie 2025-04-10.ods` | A small, **already-normalized** subset (Walter & Eugenie brautbriefe). 14 clean columns incl. ISO dates. | ✅ this is what `MassImportService` was built for |
|
||||
|
||||
The PDFs (~7,000) will follow later. The importer matches files by the **Index** column
|
||||
(e.g. `W-0001` → `W-0001.pdf`), and already imports metadata-only when a file is missing —
|
||||
so we can import all metadata now and the PDFs will attach on a re-run.
|
||||
|
||||
## How to inspect the spreadsheets
|
||||
|
||||
`openpyxl` is installed in the OCR service venv:
|
||||
|
||||
```bash
|
||||
/home/marcel/Desktop/familienarchiv/ocr-service/.venv/bin/python3 -c "import openpyxl; print(openpyxl.__version__)"
|
||||
```
|
||||
|
||||
## Documents in this folder
|
||||
|
||||
- [`01-findings-spreadsheet-analysis.md`](./01-findings-spreadsheet-analysis.md) — full analysis of every data-quality / importer issue found (2026-05-25). Each issue has an ID `IMP-NN`.
|
||||
- [`02-normalization-spec.md`](./02-normalization-spec.md) — requirements spec for the offline **import normalizer** (the agreed strategy: normalize the raw sheets into a clean canonical dataset before import). Requirements `FR-*`/`NFR-*`, traceable to the `IMP-NN` findings.
|
||||
- `WORKLOG.md` — running log of what each session did and what's next. **Start here when resuming.**
|
||||
|
||||
## Strategy (decided 2026-05-25)
|
||||
|
||||
Normalize **before** import. A standalone Python tool (`tools/import-normalizer/`, not yet
|
||||
built) transforms the raw xlsx + person register into a clean canonical dataset
|
||||
(`canonical-documents.xlsx`, `canonical-persons.xlsx`) plus review CSVs. Residual cases
|
||||
(unparseable dates, unmatched names) are fixed via a version-controlled overrides file and
|
||||
re-run. The Java importer is adjusted to consume the canonical contract in a later **Phase 2**.
|
||||
See the spec for the full contract.
|
||||
|
||||
## Status board
|
||||
|
||||
| ID | Issue | Severity | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| IMP-01 | New xlsx column layout ≠ importer defaults | 🔴 blocker | open |
|
||||
| IMP-02 | 90% of dates are free-text the parser can't read | 🔴 blocker | open |
|
||||
| IMP-03 | No ISO/normalized date column in the new xlsx | 🔴 blocker | open |
|
||||
| IMP-04 | Person register (`Personendatei 2.xlsx`) not imported | 🟠 major | open |
|
||||
| IMP-05 | Name variations = duplicate Persons (maiden vs married) | 🟠 major | open |
|
||||
| IMP-06 | 93 data rows with blank Index are silently dropped | 🟠 major | open |
|
||||
| IMP-07 | 43 duplicate Index values | 🟡 minor | open |
|
||||
| IMP-08 | Section/title rows interleaved in data | 🟡 minor | open |
|
||||
| IMP-09 | Index↔Datei filename mismatches | 🟡 minor | open |
|
||||
| IMP-10 | `x`-suffix rows (letter backsides/enclosures) | 🟡 minor | open |
|
||||
| IMP-11 | Multi-receiver separators incl. bare `u`/`u.` | 🟡 minor | open |
|
||||
| IMP-12 | Importer reads only the first sheet, no validation | 🟡 minor | open |
|
||||
|
||||
See the findings doc for detail and proposed approach per issue.
|
||||
147
docs/import-migration/WORKLOG.md
Normal file
147
docs/import-migration/WORKLOG.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Import Migration — Worklog
|
||||
|
||||
Running log of each working session. **Resume here.** Newest entry on top.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-25 (session 5) — Unresolved-name classification
|
||||
|
||||
**Did:** Implemented [`04-unresolved-names-plan.md`](./04-unresolved-names-plan.md) subagent-driven
|
||||
(5 tasks, TDD, per-task spec + code-quality review; 67 tests pass). Added `classify_name` +
|
||||
`NameClass` + `build_given_names` in `persons.py`; `ResolutionContext` now records non-RESOLVABLE
|
||||
names in `self.unresolved`; orchestrator writes `review/unresolved-names.csv` (replaces the noisy
|
||||
`ambiguous-receivers.csv`) with per-category stats.
|
||||
|
||||
**Why:** `unmatched-names.csv` mixes boring non-family correspondents (expected) with genuinely
|
||||
unresolvable entries. The new report isolates the latter so review focuses on ~440 real cases.
|
||||
|
||||
**Real-run result:** unresolved-names.csv = single_token 191 / prose 103 / unknown 74 /
|
||||
collective 46 / relational 21 / ambiguous_pair **5** (distinct). The ambiguous over-flagging fix
|
||||
cut `ambiguous_pair` from 303 → 5 (genuine two-given-name pairs only; `Mieze Schefold` etc. now
|
||||
correctly RESOLVABLE). given-name set = register first names ∪ `config.EXTRA_GIVEN_NAMES`.
|
||||
|
||||
**Next:** populate `overrides/names.csv` from unresolved-names.csv (highest-count first); extend
|
||||
`EXTRA_GIVEN_NAMES` if a real pair isn't flagged; still-open date work (Spanish months, 58–72 band).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-25 (session 4) — Built the normalizer (subagent-driven, all 17 tasks)
|
||||
|
||||
**Did:** Executed the plan subagent-driven (implementer + spec review + code-quality review per
|
||||
task). The tool `tools/import-normalizer/` is **complete and passing (57 tests)**. Final
|
||||
opus review: **READY** — determinism verified on the real corpus (two runs → identical cell
|
||||
matrices + byte-identical review files), zero silent drops.
|
||||
|
||||
**Per-task code review caught & fixed real issues** (all in the committed code): leading
|
||||
qualifiers `nach/vor/…` now → APPROX; English month-first matcher hardened to structurally
|
||||
not shadow `Mai 1895`; person-id collision de-dup suffixes *all* members; `split_receivers`
|
||||
returns `[]` for a `geb.`-only cell; boolean cells no longer coerced to `1/0`; duplicate-index
|
||||
flags every occurrence; provisional ids never steal a register id; CSV-injection defanged.
|
||||
|
||||
**REAL DRY-RUN** (`python normalize.py` over the actual archive — outputs are gitignored):
|
||||
- documents_emitted **7,582** (+225 empty +93 blank-index +42 x-suffix = 7,942 rows read, 0 dropped)
|
||||
- register_persons **163**, provisional_persons **942**
|
||||
- dates: DAY 6,509 / MONTH 36 / RANGE 36 / APPROX 28 / YEAR 17 / SEASON 1 / UNKNOWN 955
|
||||
- **unknown_date_rate 9.2%** (of dated rows; target ≤5% pre-override, ≤0.5% after overrides)
|
||||
- duplicate_index 85, index_file_mismatches 550, ambiguous_receivers 303
|
||||
|
||||
**⚠️ Concurrency incident:** a parallel Claude session committed reader-dashboard work to this
|
||||
branch and hard-reset it mid-execution, deleting the Task 15 files and orphaning a commit.
|
||||
Recovered via reflog (`reset --hard 366b4848` + `checkout 401160e3 -- <task15 files>`); no code
|
||||
lost. Casualty: my *during-execution* edits to the plan/spec docs (02/03) for Tasks 5–14 were
|
||||
discarded — **the committed code + tests are the source of truth**, not the plan doc, which now
|
||||
reflects the pre-execution + persona-review version.
|
||||
|
||||
**Next steps (iterative refinement — the overrides loop, as designed):**
|
||||
1. Shave the 9.2% UNKNOWN cheaply: add **Spanish month names** (Enero…Diciembre) and the
|
||||
`Mon DD-YYYY` dash form to `config.MONTHS`/the parser (Mexican-branch correspondence);
|
||||
revisit the 58–72 two-digit-year band (real `…58/59/60` dates = 1958–1960, just past the
|
||||
1873–1957 window — decide whether to extend the upper bound in `config`).
|
||||
2. `?` (99×) is genuinely "date unknown" — leave UNKNOWN or add a convention.
|
||||
3. Populate `overrides/dates.csv` + `overrides/names.csv` from the review CSVs and re-run.
|
||||
4. README note: a leading `'`/`!` in a `review/*.csv` `raw` cell may be a CSV-defang artifact —
|
||||
match against the true source value when writing overrides.
|
||||
5. Phase 2 (separate spec): wire the canonical contract into the Java `MassImportService`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-25 (session 3) — Implementation plan + persona review
|
||||
|
||||
**Did:**
|
||||
- Wrote [`03-normalizer-implementation-plan.md`](./03-normalizer-implementation-plan.md): 17
|
||||
bite-sized TDD tasks for `tools/import-normalizer/` (Python, openpyxl), bottom-up — date
|
||||
parser w/ Easter computus first, then persons/alias, ingest, mapping, orchestrator, writers.
|
||||
- Ran a 6-persona inline review (architect, developer, tester, req-engineer, security, devops;
|
||||
ui-expert too) via parallel agents. Acted on all material findings.
|
||||
|
||||
**Key fixes from review (see plan §"Review feedback incorporated"):**
|
||||
- Idempotency redefined byte-identical → **content-deterministic** (spec G4/NFR-IDEM-01);
|
||||
pinned workbook timestamps + deterministic alias ordering + a real two-run equality test.
|
||||
- Real bug: duplicate-index only reported repeats → now flags/reports every occurrence.
|
||||
- Provisional `person_id` could overwrite a register id → now suffixed.
|
||||
- Date parser gaps: invalid-calendar-date → UNKNOWN, intra-month day-range (`7./8. Sept.1923`).
|
||||
- Multi-person sender now split + flagged (REQ-PERS-01); CSV-injection defanged in review files;
|
||||
pinned deps + hardened root `.gitignore`.
|
||||
|
||||
**Next:**
|
||||
- Marcel reviews the plan. Then execute it (subagent-driven or inline) — the date parser
|
||||
(Task 3/8 + Easter computus) is the meatiest piece.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-25 (session 2) — Strategy + normalizer spec
|
||||
|
||||
**Did:**
|
||||
- Decided strategy with Marcel: **normalize the raw sheets first**, then import (higher
|
||||
leverage than making the Java importer tolerate every mess).
|
||||
- Locked design decisions (see spec §3): new canonical layout; dates = parsed + raw +
|
||||
precision; include person register + dedup in this effort; overrides-file + re-run loop;
|
||||
Python tool at `tools/import-normalizer/`.
|
||||
- Century rule fixed by Marcel: archive spans **1873–1957**; 2-digit `00–57`→19YY,
|
||||
`73–99`→18YY, `58–72`→flag; 3-digit→1DDD; never 20xx.
|
||||
- Wrote [`02-normalization-spec.md`](./02-normalization-spec.md) in the requirements-engineer
|
||||
persona (FR/NFR, Given-When-Then ACs, traceability to IMP-NN, TBD register).
|
||||
|
||||
**All 6 open questions resolved (spec §9):** OQ-01 — movable feasts (Ostern, Pfingsten, …)
|
||||
**computed per year from Easter**, never a fixed month; seasons → mid-season month
|
||||
(Sommer=Jul, Herbst=Oct). OQ-02 ranges → start+RANGE. OQ-03 slug ids. OQ-04 — `x`-suffix rows
|
||||
**skipped + logged** this pass (they're transcriptions of the base letter, not yet mappable).
|
||||
OQ-05 → `.xlsx`. OQ-06 → conservative, no silent merge.
|
||||
|
||||
**Git:** moved off the unrelated `feat/issue-356-…` branch; pulled `main`; created clean
|
||||
branch **`docs/import-migration`** and committed these docs there. (The dirty `.venv`
|
||||
pycache + `skills/implement/SKILL.md` in the tree are pre-existing/environmental noise — left
|
||||
uncommitted, not ours.)
|
||||
|
||||
**Next:**
|
||||
- Marcel reviews the spec.
|
||||
- Then writing-plans → build the normalizer at `tools/import-normalizer/` (backlog B1–B7 are
|
||||
the Musts; B3 date parser incl. Easter computus is the big one).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-25 (session 1) — Initial analysis
|
||||
|
||||
**Did:**
|
||||
- Got the real raw archive xlsx (7,943 rows) + person register (163 people). PDFs to follow.
|
||||
- Compared the new xlsx layout against `MassImportService` defaults and the old ODS.
|
||||
- Full statistical scan of all rows: dates, indices, senders/receivers, file column.
|
||||
- Wrote [`01-findings-spreadsheet-analysis.md`](./01-findings-spreadsheet-analysis.md)
|
||||
with 12 issues (IMP-01..IMP-12) + recommended sequencing.
|
||||
- Installed `openpyxl` into the OCR service venv for inspection.
|
||||
|
||||
**Key facts established:**
|
||||
- Importer defaults match the **ODS**, not the new xlsx → wrong column mapping (IMP-01).
|
||||
- **90%** of dated rows (6,571 / 7,319) are free-text dates the ISO-only parser drops (IMP-02).
|
||||
- Person register is rich but **unimported**; holds the maiden-name dedup key (IMP-04/05).
|
||||
|
||||
**Decisions pending from Marcel (blockers for any code work):**
|
||||
1. IMP-01: positional re-config of `app.import.col.*` vs header-driven mapping rewrite?
|
||||
2. IMP-02: how to store imprecise dates — new `dateOriginal` + `precision` columns, or lossy?
|
||||
3. IMP-04/05: format for the person/alias mapping; import persons before documents?
|
||||
4. IMP-10: are `x`-suffix rows separate documents, attachments, or skipped?
|
||||
|
||||
**Next:**
|
||||
- Get Marcel's calls on the 4 decisions above.
|
||||
- Then split the code-change items into Gitea issues (IMP-01b, IMP-02, IMP-04, IMP-06, IMP-12).
|
||||
- Pure-data tasks (IMP-07 dup list, IMP-09 file reconcile) stay here.
|
||||
@@ -16,6 +16,10 @@ CMD ["npm", "run", "dev"]
|
||||
# Compiles the SvelteKit Node-adapter output to /app/build.
|
||||
FROM node:20.19.0-alpine3.21 AS build
|
||||
WORKDIR /app
|
||||
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
|
||||
# Passed via docker-compose build.args; empty string disables the SDK.
|
||||
ARG VITE_SENTRY_DSN
|
||||
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,31 @@ export default defineConfig(
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
// Forbid test fixtures (*.test-fixture.svelte) from being imported by
|
||||
// production code. Tree-shaking keeps them out of the production bundle
|
||||
// today (no route reaches them), but a lint rule makes the boundary
|
||||
// explicit so an accidental autocomplete import in a route or component
|
||||
// fails fast. Test files (*.spec.ts / *.test.ts) and the fixtures
|
||||
// themselves are exempt — see the next block. Nora #2 on PR #629
|
||||
// round 3.
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js', '**/*.ts'],
|
||||
ignores: ['**/*.spec.ts', '**/*.test.ts', '**/*.test-fixture.svelte'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['**/*.test-fixture.svelte'],
|
||||
message:
|
||||
'Test fixtures (*.test-fixture.svelte) are test-only — do not import from production code. Tracked by #637.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: { boundaries },
|
||||
settings: {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
|
||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
||||
"error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.",
|
||||
"error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"nav_documents": "Dokumente",
|
||||
@@ -26,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",
|
||||
@@ -350,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.",
|
||||
@@ -387,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",
|
||||
@@ -432,8 +445,12 @@
|
||||
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||
"person_mention_loading": "Lade Person…",
|
||||
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||
"person_mention_search_label": "Person suchen",
|
||||
"person_mention_search_prompt": "Namen eingeben…",
|
||||
"person_mention_btn_label": "Person verlinken",
|
||||
"person_mention_create_new": "Neue Person anlegen",
|
||||
"person_mention_results_count_singular": "1 Person gefunden",
|
||||
"person_mention_results_count_plural": "{count} Personen gefunden",
|
||||
"transcription_editor_aria_label": "Transkriptionstext",
|
||||
"person_born_name_prefix": "geb.",
|
||||
"page_title_home": "Archiv",
|
||||
@@ -509,6 +526,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",
|
||||
@@ -620,6 +638,9 @@
|
||||
"transcription_block_review": "Als geprüft markieren",
|
||||
"transcription_block_unreview": "Markierung aufheben",
|
||||
"transcription_reviewed_count": "{reviewed} von {total} geprüft",
|
||||
"transcription_mark_all_reviewed": "Alle als fertig markieren",
|
||||
"transcription_mark_all_reviewed_disabled": "Alle Blöcke sind bereits als fertig markiert",
|
||||
"transcription_mark_all_reviewed_error": "Markierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"training_ocr_heading": "Kurrent-Erkennung trainieren",
|
||||
"training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.",
|
||||
"training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente",
|
||||
@@ -648,6 +669,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",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
|
||||
"error_unauthorized": "You are not logged in.",
|
||||
"error_forbidden": "You do not have permission for this action.",
|
||||
"error_csrf_token_missing": "Session error. Please reload the page.",
|
||||
"error_too_many_login_attempts": "Too many login attempts. Please try again later.",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
"nav_documents": "Documents",
|
||||
@@ -26,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",
|
||||
@@ -350,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.",
|
||||
@@ -387,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",
|
||||
@@ -432,8 +445,12 @@
|
||||
"person_mention_load_error": "Could not load person.",
|
||||
"person_mention_loading": "Loading person…",
|
||||
"person_mention_popup_empty": "No persons found",
|
||||
"person_mention_search_label": "Search for a person",
|
||||
"person_mention_search_prompt": "Enter a name…",
|
||||
"person_mention_btn_label": "Link person",
|
||||
"person_mention_create_new": "Create new person",
|
||||
"person_mention_results_count_singular": "1 person found",
|
||||
"person_mention_results_count_plural": "{count} persons found",
|
||||
"transcription_editor_aria_label": "Transcription text",
|
||||
"person_born_name_prefix": "née",
|
||||
"page_title_home": "Archive",
|
||||
@@ -509,6 +526,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",
|
||||
@@ -620,6 +638,9 @@
|
||||
"transcription_block_review": "Mark as reviewed",
|
||||
"transcription_block_unreview": "Unmark as reviewed",
|
||||
"transcription_reviewed_count": "{reviewed} of {total} reviewed",
|
||||
"transcription_mark_all_reviewed": "Mark all as reviewed",
|
||||
"transcription_mark_all_reviewed_disabled": "All blocks are already marked as reviewed",
|
||||
"transcription_mark_all_reviewed_error": "Failed to mark all as reviewed. Please try again.",
|
||||
"training_ocr_heading": "Train Kurrent recognition",
|
||||
"training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.",
|
||||
"training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents",
|
||||
@@ -648,6 +669,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",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.",
|
||||
"error_unauthorized": "No ha iniciado sesión.",
|
||||
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
||||
"error_csrf_token_missing": "Error de sesión. Recargue la página.",
|
||||
"error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
"nav_documents": "Documentos",
|
||||
@@ -26,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",
|
||||
@@ -350,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.",
|
||||
@@ -387,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",
|
||||
@@ -432,8 +445,12 @@
|
||||
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||
"person_mention_loading": "Cargando persona…",
|
||||
"person_mention_popup_empty": "No se encontraron personas",
|
||||
"person_mention_search_label": "Buscar persona",
|
||||
"person_mention_search_prompt": "Escribe un nombre…",
|
||||
"person_mention_btn_label": "Vincular persona",
|
||||
"person_mention_create_new": "Crear nueva persona",
|
||||
"person_mention_results_count_singular": "1 persona encontrada",
|
||||
"person_mention_results_count_plural": "{count} personas encontradas",
|
||||
"transcription_editor_aria_label": "Texto de transcripción",
|
||||
"person_born_name_prefix": "n.",
|
||||
"page_title_home": "Archivo",
|
||||
@@ -509,6 +526,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",
|
||||
@@ -620,6 +638,9 @@
|
||||
"transcription_block_review": "Marcar como revisado",
|
||||
"transcription_block_unreview": "Desmarcar como revisado",
|
||||
"transcription_reviewed_count": "{reviewed} de {total} revisados",
|
||||
"transcription_mark_all_reviewed": "Marcar todo como revisado",
|
||||
"transcription_mark_all_reviewed_disabled": "Todos los bloques ya están marcados como revisados",
|
||||
"transcription_mark_all_reviewed_error": "Error al marcar como revisado. Intente de nuevo.",
|
||||
"training_ocr_heading": "Entrenar reconocimiento Kurrent",
|
||||
"training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.",
|
||||
"training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos",
|
||||
@@ -648,6 +669,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) {
|
||||
@@ -96,42 +96,57 @@ const userGroup: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
// Auth endpoints that establish/check their own credentials — skip fa_session injection
|
||||
// but still need CSRF tokens on mutating requests.
|
||||
const PUBLIC_API_PATHS = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/invite/',
|
||||
'/api/auth/register'
|
||||
];
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
const isApi = request.url.startsWith(apiUrl) || new URL(request.url).pathname.startsWith('/api/');
|
||||
|
||||
if (isApi) {
|
||||
// Auth endpoints that establish/check their own credentials manage cookies themselves;
|
||||
// don't double-inject a stale fa_session.
|
||||
const PUBLIC_API_PATHS = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/invite/',
|
||||
'/api/auth/register'
|
||||
];
|
||||
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
|
||||
return fetch(request);
|
||||
}
|
||||
if (!isApi) return fetch(request);
|
||||
|
||||
const sessionId = event.cookies.get('fa_session');
|
||||
if (!sessionId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
const isMutating = MUTATING_METHODS.has(request.method);
|
||||
const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p));
|
||||
|
||||
// Clone first so the body stream is preserved on the new Request.
|
||||
const cloned = request.clone();
|
||||
const modified = new Request(cloned, {
|
||||
headers: {
|
||||
...Object.fromEntries(cloned.headers),
|
||||
Cookie: `fa_session=${sessionId}`
|
||||
}
|
||||
});
|
||||
return fetch(modified);
|
||||
const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null;
|
||||
if (!isPublicAuthApi && !sessionId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
// Read the browser's XSRF-TOKEN cookie; fall back to a fresh UUID for the
|
||||
// double-submit cookie pattern (both cookie and header must match — no server secret).
|
||||
const xsrfToken = isMutating ? (event.cookies.get('XSRF-TOKEN') ?? crypto.randomUUID()) : null;
|
||||
|
||||
const cookieParts: string[] = [];
|
||||
if (sessionId) cookieParts.push(`fa_session=${sessionId}`);
|
||||
if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`);
|
||||
|
||||
if (cookieParts.length === 0) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// Clone first so the body stream is preserved on the new Request.
|
||||
const cloned = request.clone();
|
||||
const extraHeaders: Record<string, string> = { Cookie: cookieParts.join('; ') };
|
||||
if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken;
|
||||
|
||||
const modified = new Request(cloned, {
|
||||
headers: {
|
||||
...Object.fromEntries(cloned.headers),
|
||||
...extraHeaders
|
||||
}
|
||||
});
|
||||
return fetch(modified);
|
||||
};
|
||||
|
||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user