Compare commits

..

10 Commits

Author SHA1 Message Date
Marcel
08c7dbcaa2 chore(renovate): require manual review for privileged CI image digest bumps
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 15s
CI / Backend Unit Tests (push) Successful in 4m7s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Successful in 57s
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (pull_request) Successful in 4m9s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
Adds a packageRule matching .gitea/workflows/** digest updates with
automerge: false. Digest bumps for images running --privileged --pid=host
have root-equivalent host access and must not be auto-merged.

Addresses Nora's review concern on #537.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:15:05 +02:00
Marcel
a3f1260c23 docs(ci): add Troubleshooting section for Reload Caddy failures
Covers the three failure modes Sara flagged: Caddy stopped (explicit
systemctl error), symlink missing/mis-pointed (silent reload, stale
smoke test), and Docker socket / nsenter unavailable (container error).
Each failure mode includes symptoms and recovery steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:14:35 +02:00
Marcel
e75ddc318b docs(adr): ADR-012 — nsenter via privileged container for host service management in CI
Captures the architectural decision, alternatives considered (sudo
systemctl, Caddy admin API, SSH), and consequences (symlink contract,
Renovate review requirement, step duplication tracked in #539).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:13:50 +02:00
Marcel
60cb9d5ad0 docs(deploy): note Caddyfile symlink is a CI dependency
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m4s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 58s
CI / Unit & Component Tests (pull_request) Failing after 2m49s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Compose Bucket Idempotency (pull_request) Successful in 56s
CI / Backend Unit Tests (pull_request) Successful in 4m12s
CI / fail2ban Regex (pull_request) Successful in 37s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:52:34 +02:00
Marcel
1ce0638ae6 docs(ci): update nsenter example to Alpine, document alternatives considered
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:47:41 +02:00
Marcel
f608838f7a fix(ci): pin Reload Caddy to alpine:3.21 digest, add reload-vs-restart rationale
- Switch ubuntu:22.04 (floating, ~70 MB) to alpine:3.21 pinned by sha256
  digest (~5 MB); util-linux installed at run time via apk add
- Add explicit comment explaining why `reload` not `restart`: SIGHUP
  re-reads config in-process without dropping TLS connections

Addresses Tobias + Nora blocker from PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:43:55 +02:00
Marcel
52a96f657d docs(ci): document DooD runner architecture and nsenter pattern
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 16s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Successful in 23s
CI / Unit & Component Tests (pull_request) Failing after 2m48s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (push) Successful in 4m4s
CI / Backend Unit Tests (pull_request) Successful in 4m6s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
Replace the stale generic runner provisioning docs with an accurate
description of the actual two-container setup on the Hetzner VPS.
Document the nsenter pattern for running host-level commands (systemctl)
from containerised CI steps, and the Caddyfile symlink contract that the
reload step depends on.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:29:39 +02:00
Marcel
f87504fb23 fix(ci): add Caddy reload step to release workflow
Same gap as nightly.yml: production deploys also need Caddy to reload
the updated Caddyfile before the smoke test validates the public surface.
Uses the same nsenter pattern introduced in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:29:02 +02:00
Marcel
99de6f1d07 fix(ci): reload Caddy via nsenter, not sudo systemctl
`sudo systemctl reload caddy` does not work from inside a DooD job
container: `systemctl` is absent from Ubuntu container images and
container processes cannot reach the host systemd without entering its
namespaces. Replace with `docker run --privileged --pid=host ubuntu:22.04
nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy`, which uses
the already-mounted Docker socket to spin up a privileged sibling
container that enters the host PID namespace via nsenter. Tested live on
the Hetzner VPS. No sudoers entry required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:28:24 +02:00
Marcel
432ae2ac83 ci(nightly): reload Caddy before smoke test
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m50s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m10s
CI / fail2ban Regex (push) Successful in 38s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
Adds a `sudo systemctl reload caddy` step between the docker compose
deploy and the smoke test. This ensures any committed Caddyfile changes
are applied before the public surface is verified.

Previously the workflow had no mechanism to push Caddyfile changes to
the running host daemon. A Caddyfile edit would land in the repo but
Caddy would keep serving the previous config, causing the smoke test to
catch a stale header or still-proxied /actuator route rather than the
intended current config.

This step also surfaces the root cause of today's port-443 failure
explicitly: if Caddy is not running, the step fails with a clear service
error rather than a misleading "Failed to connect to port 443" from curl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:49:32 +02:00
375 changed files with 3330 additions and 47583 deletions

View File

@@ -414,7 +414,7 @@ Never Kafka for teams under 10 or <100k events/day. Never gRPC inside a monolith
| PR contains | Required doc update | | PR contains | Required doc update |
|---|---| |---|---|
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml`**except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing | | New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` |
| New `@ManyToMany` join table or FK | Both DB diagrams | | New `@ManyToMany` join table or FK | Both DB diagrams |
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` | | New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` | | New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |

View File

@@ -984,7 +984,7 @@ Mark with `@pytest.mark.asyncio` so pytest runs the coroutine. Without it, the t
| What changed in code | Doc(s) to update | | What changed in code | Doc(s) to update |
|---|---| |---|---|
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line)**except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing | | New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) |
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above | | New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain | | New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain | | New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |

View File

@@ -26,46 +26,6 @@ PORT_MAILPIT_SMTP=1025
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" # Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
OCR_TRAINING_TOKEN=change-me-in-production OCR_TRAINING_TOKEN=change-me-in-production
# --- Observability ---
# Optional stack — start with: docker compose -f docker-compose.observability.yml up -d
# Requires the main stack to already be running (docker compose up -d creates archiv-net).
# In production the stack is managed from /opt/familienarchiv/ (see docs/DEPLOYMENT.md §4).
# Ports for host access
PORT_GRAFANA=3003
PORT_GLITCHTIP=3002
PORT_PROMETHEUS=9090
# Grafana admin password — change this before exposing Grafana beyond localhost
GRAFANA_ADMIN_PASSWORD=changeme
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
GLITCHTIP_DOMAIN=http://localhost:3002
# GlitchTip secret key — Django SECRET_KEY equivalent, used to sign sessions and tokens.
# REQUIRED in production — must not be empty or 'changeme'. Fail-closed: GlitchTip will
# refuse to start with an invalid key.
# Generate with: python3 -c "import secrets; print(secrets.token_hex(50))"
GLITCHTIP_SECRET_KEY=changeme-generate-a-real-secret
# PostgreSQL hostname for GlitchTip's db-init job and workers.
# Override when only the staging stack is running (container name differs from archive-db).
# Default (archive-db) is correct for production with the full stack up.
POSTGRES_HOST=archive-db
# $$ escaping note: passwords in /opt/familienarchiv/.env that contain a literal '$' must
# use '$$' so Docker Compose does not expand them as variable references.
# Example: a password 'p@$$word' should be written as 'p@$$$$word' in the .env file.
# Error reporting DSNs — leave empty to disable the SDK (safe default).
# SENTRY_DSN: backend (Spring Boot) — used by the GlitchTip/Sentry Java SDK
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=
# VITE_SENTRY_DSN: frontend (SvelteKit) — injected at build time via Vite
VITE_SENTRY_DSN=
# Sentry/GlitchTip auth token for source map upload at build time (optional)
SENTRY_AUTH_TOKEN=
# Production SMTP — uncomment and fill in to send real emails instead of catching them # Production SMTP — uncomment and fill in to send real emails instead of catching them
# APP_BASE_URL=https://your-domain.example.com # APP_BASE_URL=https://your-domain.example.com
# MAIL_HOST=smtp.example.com # MAIL_HOST=smtp.example.com

View File

@@ -2,7 +2,6 @@ name: CI
on: on:
push: push:
branches: [main]
pull_request: pull_request:
jobs: jobs:
@@ -13,7 +12,7 @@ jobs:
name: Unit & Component Tests name: Unit & Component Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.60.0-noble image: mcr.microsoft.com/playwright:v1.58.2-noble
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -29,92 +28,32 @@ jobs:
run: npm ci run: npm ci
working-directory: frontend working-directory: frontend
- name: Security audit (no dev deps)
run: npm audit --audit-level=high --omit=dev
working-directory: frontend
- name: Compile Paraglide i18n - name: Compile Paraglide i18n
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend working-directory: frontend
- name: Sync SvelteKit
run: npx svelte-kit sync
working-directory: frontend
- name: Lint - name: Lint
run: npm run lint run: npm run lint
working-directory: frontend working-directory: frontend
- name: Assert no banned vi.mock patterns - name: Run unit and component tests
shell: bash run: npm test
run: |
# Literal pdfjs-dist (libLoader pattern — ADR 012)
if grep -rF "vi.mock('pdfjs-dist'" frontend/src/; then
echo "FAIL: banned vi.mock('pdfjs-dist') pattern found — see ADR 012. Use the libLoader prop injection pattern instead."
exit 1
fi
# Async factory with dynamic import in body (named mechanism — ADR 012 / #553).
# Multiline PCRE matches `vi.mock(<arg>, async ... { ... await import(...) ... })`
# across line breaks. __meta__ is excluded because it contains fixture strings
# demonstrating the very pattern this check is meant to forbid.
if grep -rPzln 'vi\.mock\([^)]+,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(' \
--include='*.spec.ts' --include='*.test.ts' \
--exclude-dir='__meta__' \
frontend/src/; then
echo "FAIL: banned async vi.mock factory with dynamic import in body — see ADR 012 / #553. Use a synchronous factory + vi.hoisted instead."
exit 1
fi
- name: Assert no (upload|download)-artifact past v3
shell: bash
run: |
# Self-test: verify the regex catches v4+ and does not catch v3.
tmp=$(mktemp)
printf ' uses: actions/upload-artifact@v5\n' > "$tmp"
grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|| { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
printf ' uses: actions/upload-artifact@v3\n' > "$tmp"
grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|| { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
rm "$tmp"
# Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
# Both upload-artifact and download-artifact share the same incompatibility.
# Pin to @v3. See ADR-014 / #557.
if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
exit 1
fi
- name: Run unit and component tests with coverage
shell: bash
run: |
set -eo pipefail
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}.log
working-directory: frontend working-directory: frontend
env: env:
TZ: Europe/Berlin TZ: Europe/Berlin
# Diagnostic guard: covers the coverage run only. If `npm test` (above) - name: Run coverage (server + client)
# exits 1 with a birpc error, the named pattern appears here — not there. run: npm run test:coverage
- name: Assert no birpc teardown race in coverage run working-directory: frontend
shell: bash env:
if: always() TZ: Europe/Berlin
run: |
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log 2>/dev/null; then
echo "FAIL: [birpc] rpc is closed teardown race detected in coverage run"
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log
exit 1
fi
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- name: Upload coverage reports - name: Upload coverage reports
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: coverage-reports name: coverage-reports
path: | path: frontend/coverage/
frontend/coverage/
/tmp/coverage-test-${{ github.run_id }}.log
- name: Build frontend - name: Build frontend
run: npm run build run: npm run build
@@ -143,19 +82,15 @@ jobs:
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; } || { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
echo "PASS: only /hilfe/transkription.html prerendered." echo "PASS: only /hilfe/transkription.html prerendered."
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- name: Upload screenshots - name: Upload screenshots
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: unit-test-screenshots name: unit-test-screenshots
path: frontend/test-results/screenshots/ path: frontend/test-results/screenshots/
# ─── OCR Service Unit Tests ─────────────────────────────────────────────────── # ─── OCR Service Unit Tests ───────────────────────────────────────────────────
# Only stdlib/lightweight tests — no ML stack (PyTorch/Surya/Kraken) required. # Only spell_check.py, test_confidence.py, test_sender_registry.py — no ML stack required.
# test_tmpdir.py covers the TMPDIR env var and entrypoint mkdir behaviour (ADR-021).
# test_tmpdir_is_inside_persistent_cache_volume is skipped in CI (TMPDIR not
# set to /app/cache here); it runs inside the deployed Docker container.
ocr-tests: ocr-tests:
name: OCR Service Tests name: OCR Service Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -167,11 +102,11 @@ jobs:
python-version: '3.11' python-version: '3.11'
- name: Install test dependencies - name: Install test dependencies
run: pip install "pyspellchecker==0.9.0" "fastapi==0.115.6" pytest pytest-asyncio run: pip install "pyspellchecker==0.9.0" pytest pytest-asyncio
working-directory: ocr-service working-directory: ocr-service
- name: Run OCR unit tests (no ML stack required) - name: Run OCR unit tests (no ML stack required)
run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py test_tmpdir.py -v run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py -v
working-directory: ocr-service working-directory: ocr-service
# ─── Backend Unit & Slice Tests ─────────────────────────────────────────────── # ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
@@ -201,17 +136,9 @@ jobs:
- name: Run backend tests - name: Run backend tests
run: | run: |
chmod +x mvnw chmod +x mvnw
./mvnw clean verify ./mvnw clean test
working-directory: backend working-directory: backend
- name: Upload surefire reports
if: always()
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
uses: actions/upload-artifact@v3
with:
name: surefire-reports
path: backend/target/surefire-reports/
# ─── fail2ban Regex Regression ──────────────────────────────────────────────── # ─── fail2ban Regex Regression ────────────────────────────────────────────────
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders # The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
# the JSON keys would silently break it (fail2ban-regex would return # the JSON keys would silently break it (fail2ban-regex would return
@@ -283,27 +210,6 @@ jobs:
echo "$dump" | grep -qE "\['add', 'familienarchiv-auth', 'polling'\]" \ echo "$dump" | grep -qE "\['add', 'familienarchiv-auth', 'polling'\]" \
|| { echo "FAIL: familienarchiv-auth jail did not resolve to 'polling' backend"; exit 1; } || { echo "FAIL: familienarchiv-auth jail did not resolve to 'polling' backend"; exit 1; }
# ─── Semgrep Security Scan ───────────────────────────────────────────────────
# Catches XXE-unprotected XML parser factories and similar patterns defined in
# .semgrep/security.yml. Runs in parallel with backend-unit-tests for fast feedback.
# Uses local rules only (no SEMGREP_APP_TOKEN / OIDC — act_runner does not support it).
semgrep-scan:
name: Semgrep Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install Semgrep
run: pip install semgrep==1.163.0
- name: Run security rules
run: semgrep --config .semgrep/security.yml --error --metrics=off backend/src/
# ─── Compose Bucket-Bootstrap Idempotency ───────────────────────────────────── # ─── Compose Bucket-Bootstrap Idempotency ─────────────────────────────────────
# docker-compose.prod.yml's create-buckets service runs on every # docker-compose.prod.yml's create-buckets service runs on every
# `docker compose up` (one-shot, no restart). Must be idempotent — a # `docker compose up` (one-shot, no restart). Must be idempotent — a
@@ -332,8 +238,6 @@ jobs:
MAIL_HOST=mailpit MAIL_HOST=mailpit
MAIL_PORT=1025 MAIL_PORT=1025
APP_MAIL_FROM=noreply@local APP_MAIL_FROM=noreply@local
IMPORT_HOST_DIR=/tmp/dummy-import
COMPOSE_NETWORK_NAME=test-idem-archiv-net
EOF EOF
- name: Bring up minio - name: Bring up minio

View File

@@ -1,65 +0,0 @@
name: Coverage Flake Probe
# Manually-triggered probe for the birpc teardown race documented in ADR 012
# / #553. Runs the full coverage suite 20× in parallel against a single SHA
# and asserts zero `[birpc] rpc is closed` lines across every cell. Verifies
# the acceptance criterion that the race no longer surfaces under coverage.
on:
workflow_dispatch:
jobs:
coverage-flake-probe:
name: Coverage flake probe (run ${{ matrix.run }})
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
strategy:
fail-fast: false
matrix:
run: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: frontend/node_modules
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
working-directory: frontend
- name: Compile Paraglide i18n
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend
- name: Run unit and component tests with coverage
shell: bash
run: |
set -eo pipefail
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
working-directory: frontend
env:
TZ: Europe/Berlin
- name: Assert no birpc teardown race
shell: bash
if: always()
run: |
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log 2>/dev/null; then
echo "FAIL: [birpc] rpc is closed teardown race detected in run ${{ matrix.run }}"
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
exit 1
fi
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- name: Upload coverage log on failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: coverage-log-run-${{ matrix.run }}
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log

View File

@@ -30,9 +30,6 @@ name: nightly
# STAGING_OCR_TRAINING_TOKEN # STAGING_OCR_TRAINING_TOKEN
# STAGING_APP_ADMIN_USERNAME # STAGING_APP_ADMIN_USERNAME
# STAGING_APP_ADMIN_PASSWORD # STAGING_APP_ADMIN_PASSWORD
# GRAFANA_ADMIN_PASSWORD
# GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
on: on:
schedule: schedule:
@@ -76,33 +73,8 @@ jobs:
MAIL_SMTP_AUTH=false MAIL_SMTP_AUTH=false
MAIL_STARTTLS_ENABLE=false MAIL_STARTTLS_ENABLE=false
APP_MAIL_FROM=noreply@staging.raddatz.cloud APP_MAIL_FROM=noreply@staging.raddatz.cloud
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
EOF EOF
- name: Verify backend /import:ro mount is wired
# Regression guard for #526: the /admin/system mass-import card
# only works when the backend service mounts the host import
# payload at /import (read-only). If a future "compose cleanup"
# PR drops the volumes block, mass import silently breaks again.
# `compose config` renders both shorthand and longform mounts as
# `target: /import` + `read_only: true`, so we assert against
# the rendered form rather than the raw source YAML.
run: |
set -e
docker compose \
-f docker-compose.prod.yml \
-p archiv-staging \
--env-file .env.staging \
--profile staging \
config > /tmp/compose-rendered.yml
grep -q '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
|| { echo "::error::backend is missing the /import bind mount (see #526)"; exit 1; }
grep -A2 '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
| grep -q 'read_only: true' \
|| { echo "::error::backend /import mount is not read-only (see #526)"; exit 1; }
- name: Build images - name: Build images
# `--pull` forces re-fetching pinned base images so a CVE # `--pull` forces re-fetching pinned base images so a CVE
# re-publication of the same tag (e.g. node:20.19.0-alpine3.21, # re-publication of the same tag (e.g. node:20.19.0-alpine3.21,
@@ -125,77 +97,6 @@ jobs:
--profile staging \ --profile staging \
up -d --wait --remove-orphans up -d --wait --remove-orphans
- name: Deploy observability configs
# Copies the compose file and config tree from the workspace checkout
# into /opt/familienarchiv/ — the permanent location that persists
# between CI runs. Containers started in the next step bind-mount
# from there, so a future workspace wipe cannot corrupt a running
# config file.
#
# obs-secrets.env is written fresh from Gitea secrets on every run so
# Gitea is always the single source of truth for secret rotation.
# Non-secret config lives in infra/observability/obs.env (tracked in git).
run: |
rm -rf /opt/familienarchiv/infra/observability
mkdir -p /opt/familienarchiv/infra/observability
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-staging-db-1
EOF
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-staging)
# and service name (db). A project rename requires updating this value.
chmod 600 /opt/familienarchiv/obs-secrets.env
- name: Validate observability compose config
# Dry-run: resolves all variable substitutions and reports any missing
# required keys before containers start. Catches undefined variables and
# YAML errors in config files updated by the previous step.
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
# second (CI-written secrets). Later files win on duplicate keys, so
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
run: |
docker compose \
-f /opt/familienarchiv/docker-compose.observability.yml \
--env-file /opt/familienarchiv/infra/observability/obs.env \
--env-file /opt/familienarchiv/obs-secrets.env \
config --quiet
- name: Start observability stack
# Runs with absolute paths so bind mounts resolve to stable host paths
# that survive workspace wipes between nightly runs (see ADR-016).
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
# obs-secrets.env second — later file wins on duplicate keys.
run: |
docker compose \
-f /opt/familienarchiv/docker-compose.observability.yml \
--env-file /opt/familienarchiv/infra/observability/obs.env \
--env-file /opt/familienarchiv/obs-secrets.env \
up -d --wait --remove-orphans
- name: Assert observability stack health
# docker compose up --wait covers services WITH healthcheck directives only.
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
# no healthcheck — they are considered "started" as soon as the process runs.
# This step explicitly asserts the five healthchecked critical services are
# healthy before the smoke test proceeds.
run: |
set -e
unhealthy=""
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
if [ "$status" != "healthy" ]; then
echo "::error::$svc is not healthy (status: $status)"
unhealthy="$unhealthy $svc"
fi
done
[ -z "$unhealthy" ] || exit 1
echo "All critical observability services are healthy"
- name: Reload Caddy - name: Reload Caddy
# Apply any committed Caddyfile changes before smoke-testing the # Apply any committed Caddyfile changes before smoke-testing the
# public surface. Without this step, a Caddyfile edit lands in the # public surface. Without this step, a Caddyfile edit lands in the
@@ -234,38 +135,28 @@ jobs:
# public surface works. This step catches: Caddy not reloaded, HSTS # public surface works. This step catches: Caddy not reloaded, HSTS
# header dropped, /actuator block bypassed. # header dropped, /actuator block bypassed.
# #
# --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP # --resolve pins staging.raddatz.cloud to the runner's loopback so we
# (the host) so we do NOT depend on hairpin NAT on the host router. # do NOT depend on the host router doing hairpin NAT (many SOHO
# 127.0.0.1 cannot be used: job containers run in bridge network mode # routers do not, or do so only after a firmware update). SNI still
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not # uses the public hostname so the cert validates correctly.
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
# and is therefore reachable from the container via that IP.
# SNI still uses the public hostname so the TLS cert validates correctly.
#
# Gateway detection reads /proc/net/route (always present, no package
# required) instead of `ip route` to avoid a dependency on iproute2.
# Field $2=="00000000" is the default route; field $3 is the gateway as
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
run: | run: |
set -e set -e
HOST="staging.raddatz.cloud" HOST="staging.raddatz.cloud"
URL="https://$HOST" URL="https://$HOST"
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route) RESOLVE="--resolve $HOST:443:127.0.0.1"
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; } echo "Smoke test: $URL (pinned to 127.0.0.1)"
RESOLVE=(--resolve "$HOST:443:$HOST_IP") curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence: # Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently. # fail this check rather than pass it silently.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera, # Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the # microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step. # header now fails the smoke step.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed" echo "All smoke checks passed"

View File

@@ -34,9 +34,6 @@ name: release
# MAIL_PORT # MAIL_PORT
# MAIL_USERNAME # MAIL_USERNAME
# MAIL_PASSWORD # MAIL_PASSWORD
# GRAFANA_ADMIN_PASSWORD
# GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
on: on:
push: push:
@@ -74,9 +71,6 @@ jobs:
MAIL_SMTP_AUTH=true MAIL_SMTP_AUTH=true
MAIL_STARTTLS_ENABLE=true MAIL_STARTTLS_ENABLE=true
APP_MAIL_FROM=noreply@raddatz.cloud APP_MAIL_FROM=noreply@raddatz.cloud
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
EOF EOF
- name: Build images - name: Build images
@@ -98,75 +92,6 @@ jobs:
--env-file .env.production \ --env-file .env.production \
up -d --wait --remove-orphans up -d --wait --remove-orphans
- name: Deploy observability configs
# Mirrors the nightly approach: copies obs compose file and config tree
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
# then writes obs-secrets.env fresh from Gitea secrets.
# Non-secret config lives in infra/observability/obs.env (tracked in git).
run: |
rm -rf /opt/familienarchiv/infra/observability
mkdir -p /opt/familienarchiv/infra/observability
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-production-db-1
EOF
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-production)
# and service name (db). A project rename requires updating this value.
chmod 600 /opt/familienarchiv/obs-secrets.env
- name: Validate observability compose config
# Dry-run: resolves all variable substitutions and reports any missing
# required keys before containers start. Catches undefined variables and
# YAML errors in config files updated by the previous step.
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
# second (CI-written secrets). Later files win on duplicate keys, so
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
# Keep in sync with the equivalent step in nightly.yml (#603).
run: |
docker compose \
-f /opt/familienarchiv/docker-compose.observability.yml \
--env-file /opt/familienarchiv/infra/observability/obs.env \
--env-file /opt/familienarchiv/obs-secrets.env \
config --quiet
- name: Start observability stack
# Runs with absolute paths so bind mounts resolve to stable host paths
# that survive workspace wipes between runs (see ADR-016).
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
# obs-secrets.env second — later file wins on duplicate keys.
# Keep in sync with the equivalent step in nightly.yml (#603).
run: |
docker compose \
-f /opt/familienarchiv/docker-compose.observability.yml \
--env-file /opt/familienarchiv/infra/observability/obs.env \
--env-file /opt/familienarchiv/obs-secrets.env \
up -d --wait --remove-orphans
- name: Assert observability stack health
# docker compose up --wait covers services WITH healthcheck directives only.
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
# no healthcheck — they are considered "started" as soon as the process runs.
# This step explicitly asserts the five healthchecked critical services are
# healthy before the smoke test proceeds.
# Keep in sync with the equivalent step in nightly.yml (#603).
run: |
set -e
unhealthy=""
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
if [ "$status" != "healthy" ]; then
echo "::error::$svc is not healthy (status: $status)"
unhealthy="$unhealthy $svc"
fi
done
[ -z "$unhealthy" ] || exit 1
echo "All critical observability services are healthy"
- name: Reload Caddy - name: Reload Caddy
# See nightly.yml — same rationale and mechanism: DooD job containers # See nightly.yml — same rationale and mechanism: DooD job containers
# cannot call systemctl directly; nsenter via a privileged sibling # cannot call systemctl directly; nsenter via a privileged sibling
@@ -181,31 +106,26 @@ jobs:
- name: Smoke test deployed environment - name: Smoke test deployed environment
# See nightly.yml — same three checks, against the prod vhost. # See nightly.yml — same three checks, against the prod vhost.
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two # --resolve pins archiv.raddatz.cloud to the runner's loopback so
# separate arguments; a quoted string would pass the flag and its value # the smoke test does NOT depend on hairpin NAT on the host router.
# as one token and curl would reject it as an unknown option.
# Gateway detection via /proc/net/route — no iproute2 dependency.
# See nightly.yml for the full network topology explanation.
run: | run: |
set -e set -e
HOST="archiv.raddatz.cloud" HOST="archiv.raddatz.cloud"
URL="https://$HOST" URL="https://$HOST"
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route) RESOLVE="--resolve $HOST:443:127.0.0.1"
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; } echo "Smoke test: $URL (pinned to 127.0.0.1)"
RESOLVE=(--resolve "$HOST:443:$HOST_IP") curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence: # Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently. # fail this check rather than pass it silently.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera, # Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the # microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step. # header now fails the smoke step.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed" echo "All smoke checks passed"

View File

@@ -1,54 +0,0 @@
# Semgrep security rules for Familienarchiv backend.
# These rules catch the absence of XXE protection on XML parser factories.
# CWE-611: Improper Restriction of XML External Entity Reference.
# Run: semgrep --config .semgrep/security.yml --error backend/src/
rules:
# DocumentBuilderFactory without XXE hardening.
# All call sites must call setFeature("…disallow-doctype-decl", true) before use.
- id: dbf-xxe-default
patterns:
- pattern: $X = DocumentBuilderFactory.newInstance();
- pattern-not-inside: |
...
$X.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
...
message: >
DocumentBuilderFactory without XXE protection (CWE-611).
Call XxeSafeXmlParser.hardenedFactory() instead of DocumentBuilderFactory.newInstance().
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
languages: [java]
severity: ERROR
# SAXParserFactory without XXE hardening.
- id: sax-xxe-default
patterns:
- pattern: $X = SAXParserFactory.newInstance();
- pattern-not-inside: |
...
$X.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
...
message: >
SAXParserFactory without XXE protection (CWE-611).
Set disallow-doctype-decl=true, external-general-entities=false, external-parameter-entities=false,
and load-external-dtd=false before use. Follow the pattern in XxeSafeXmlParser.hardenedFactory().
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
languages: [java]
severity: ERROR
# XMLInputFactory without XXE hardening (StAX parser).
- id: stax-xxe-default
patterns:
- pattern: $X = XMLInputFactory.newInstance();
- pattern-not-inside: |
...
$X.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
...
message: >
XMLInputFactory without XXE protection (CWE-611).
Set IS_SUPPORTING_EXTERNAL_ENTITIES=false and SUPPORT_DTD=false before use.
Follow the pattern in XxeSafeXmlParser.hardenedFactory().
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
languages: [java]
severity: ERROR

View File

@@ -77,7 +77,6 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
``` ```
backend/src/main/java/org/raddatz/familienarchiv/ backend/src/main/java/org/raddatz/familienarchiv/
├── audit/ Audit logging ├── audit/ Audit logging
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
├── config/ Infrastructure config (Minio, Async, Web) ├── config/ Infrastructure config (Minio, Async, Web)
├── dashboard/ Dashboard analytics + StatsController/StatsService ├── dashboard/ Dashboard analytics + StatsController/StatsService
├── document/ Document domain (entities, controller, service, repository, DTOs) ├── document/ Document domain (entities, controller, service, repository, DTOs)
@@ -94,7 +93,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain │ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain ├── tag/ Tag domain
└── user/ User domain — AppUser, UserGroup, UserService └── user/ User domain — AppUser, UserGroup, UserService, auth controllers
``` ```
### Layering Rules ### Layering Rules
@@ -160,7 +159,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). **LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) mirror in `frontend/src/lib/shared/errors.ts`, (3) add i18n keys in `messages/{de,en,es}.json`.
### Security / Permissions ### Security / Permissions
@@ -203,7 +202,8 @@ frontend/src/routes/
├── profile/ User profile settings ├── profile/ User profile settings
├── users/[id]/ Public user profile page ├── users/[id]/ Public user profile page
├── login/ logout/ register/ ├── login/ logout/ register/
── forgot-password/ reset-password/ ── forgot-password/ reset-password/
└── demo/ Dev-only demos
``` ```
### API Client Pattern ### API Client Pattern
@@ -267,7 +267,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). **LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
--- ---
@@ -275,35 +275,6 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md) → See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
### Observability stack (separate compose file)
Run via `docker-compose.observability.yml` — requires the main stack to be running first. Full setup procedure: [docs/DEPLOYMENT.md §4](./docs/DEPLOYMENT.md#4-logs--observability).
| Service | Container | Default Port | Purpose |
|---------|-----------|-------------|---------|
| Grafana | `obs-grafana` | 3003 | Metrics / logs / traces dashboard |
| Prometheus | `obs-prometheus` | 9090 (dev only — `127.0.0.1` bound) | Metrics store |
| Loki | `obs-loki` | — (internal) | Log store |
| Tempo | `obs-tempo` | — (internal) | Trace store |
| GlitchTip | `obs-glitchtip` | 3002 | Error tracking (Sentry-compatible) |
### Observability env vars
| Variable | Purpose |
|----------|---------|
| `PORT_GRAFANA` | Host port for Grafana UI (default: `3003`) |
| `PORT_GLITCHTIP` | Host port for GlitchTip UI (default: `3002`) |
| `PORT_PROMETHEUS` | Host port for Prometheus UI (default: `9090`) |
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` login password — generate with `openssl rand -hex 32` |
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` |
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (email links, CORS), e.g. `https://glitchtip.example.com` |
| `SENTRY_DSN` | GlitchTip/Sentry DSN for the backend (Spring Boot) — leave empty to disable |
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite |
## Observability
→ See [docs/OBSERVABILITY.md](./docs/OBSERVABILITY.md) — where to look for logs, traces, metrics, and errors.
## API Testing ## API Testing
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension. HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.

View File

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

View File

@@ -24,7 +24,6 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
``` ```
src/main/java/org/raddatz/familienarchiv/ src/main/java/org/raddatz/familienarchiv/
├── audit/ # Audit logging (AuditService, AuditLogQueryService) ├── audit/ # Audit logging (AuditService, AuditLogQueryService)
├── auth/ # AuthService, AuthSessionController, LoginRequest (Spring Session JDBC — ADR-020)
├── config/ # Infrastructure config (MinioConfig, AsyncConfig, WebConfig) ├── config/ # Infrastructure config (MinioConfig, AsyncConfig, WebConfig)
├── dashboard/ # Dashboard analytics + StatsController/StatsService ├── dashboard/ # Dashboard analytics + StatsController/StatsService
├── document/ # Document domain — entities, controller, service, repository, DTOs ├── document/ # Document domain — entities, controller, service, repository, DTOs
@@ -41,7 +40,7 @@ src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ # PersonRelationship sub-domain │ └── relationship/ # PersonRelationship sub-domain
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect ├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ # Tag domain — Tag, TagService, TagController ├── tag/ # Tag domain — Tag, TagService, TagController
└── user/ # User domain — AppUser, UserGroup, UserService └── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
``` ```
For per-domain ownership and public surface, see each domain's `README.md`. For per-domain ownership and public surface, see each domain's `README.md`.
@@ -97,10 +96,7 @@ public class MyEntity {
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`. - Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
- Write methods: `@Transactional`. - Write methods: `@Transactional`.
- Read methods: no annotation (default non-transactional)**except** when the method returns - Read methods: no annotation (default non-transactional).
an entity whose lazy associations must remain accessible to the caller after the method
returns. In that case, use `@Transactional(readOnly = true)` to keep the Hibernate session
open. Removing this annotation causes `LazyInitializationException` in production. See ADR-022.
- Cross-domain access goes through the other domain's service, never its repository. - Cross-domain access goes through the other domain's service, never its repository.
## Error Handling ## Error Handling

View File

@@ -5,7 +5,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.6</version> <version>4.0.0</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>org.raddatz</groupId> <groupId>org.raddatz</groupId>
@@ -29,30 +29,11 @@
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
</properties> </properties>
<dependencyManagement>
<dependencies>
<!-- opentelemetry-spring-boot-starter:2.27.0 was built against opentelemetry-api:1.61.0,
but Spring Boot 4.0.0 BOM only manages 1.55.0 (missing GlobalOpenTelemetry.getOrNoop()).
Import the core OTel BOM here to override it before the Spring Boot BOM applies. -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-bom</artifactId>
<version>1.61.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<!-- Spring Boot 4.0 splits Micrometer metrics export (incl. Prometheus scrape endpoint) into its own starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-micrometer-metrics</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
@@ -69,10 +50,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-session-jdbc</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId> <artifactId>spring-boot-starter-webmvc</artifactId>
@@ -180,16 +157,11 @@
<artifactId>flyway-database-postgresql</artifactId> <artifactId>flyway-database-postgresql</artifactId>
</dependency> </dependency>
<!-- Caffeine cache + Bucket4j for in-memory rate limiting --> <!-- Caffeine cache for in-memory rate limiting -->
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile --> <!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
<dependency> <dependency>
@@ -216,7 +188,7 @@
<dependency> <dependency>
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId> <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
<artifactId>owasp-java-html-sanitizer</artifactId> <artifactId>owasp-java-html-sanitizer</artifactId>
<version>20260101.1</version> <version>20240325.1</version>
</dependency> </dependency>
<!-- HTML → plain-text extraction for comment previews --> <!-- HTML → plain-text extraction for comment previews -->
@@ -225,42 +197,6 @@
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.18.1</version> <version>1.18.1</version>
</dependency> </dependency>
<!-- Observability: Prometheus metrics scrape endpoint (version managed by Spring Boot BOM) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Observability: Micrometer → OpenTelemetry tracing bridge (version managed by Spring Boot BOM) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<!-- Observability: OTel Spring Boot auto-instrumentation — NOT in Spring Boot BOM, pinned explicitly -->
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
<version>2.27.0</version>
<exclusions>
<!-- Excludes AzureAppServiceResourceProvider which references ServiceAttributes.SERVICE_INSTANCE_ID
that does not exist in the semconv version pulled by this project. -->
<exclusion>
<groupId>io.opentelemetry.contrib</groupId>
<artifactId>opentelemetry-azure-resources</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Sentry error reporting (GlitchTip-compatible) — sentry-spring-boot-4 is the
Spring Boot 4 / Spring Framework 7 compatible module (replaces the jakarta starter
which crashes with SF7 due to bean-name generation for triply-nested @Import classes) -->
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry-spring-boot-4</artifactId>
<version>8.41.0</version>
</dependency>
</dependencies> </dependencies>
@@ -306,7 +242,7 @@
<phase>verify</phase> <phase>verify</phase>
<goals><goal>report</goal></goals> <goals><goal>report</goal></goals>
</execution> </execution>
<!-- Gate: ratchet at 0.77 — actual measured coverage after drift; raise via #496 --> <!-- Gate: baseline 89.4% overall / service 90.2% / controller 80.0% -->
<execution> <execution>
<id>check</id> <id>check</id>
<phase>verify</phase> <phase>verify</phase>
@@ -319,7 +255,7 @@
<limit> <limit>
<counter>BRANCH</counter> <counter>BRANCH</counter>
<value>COVEREDRATIO</value> <value>COVEREDRATIO</value>
<minimum>0.77</minimum> <minimum>0.88</minimum>
</limit> </limit>
</limits> </limits>
</rule> </rule>
@@ -337,16 +273,6 @@
</profiles> </profiles>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
<systemPropertyVariables>
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>

View File

@@ -35,22 +35,7 @@ public enum AuditKind {
USER_DELETED, USER_DELETED,
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */ /** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
GROUP_MEMBERSHIP_CHANGED, GROUP_MEMBERSHIP_CHANGED;
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
LOGIN_SUCCESS,
/** 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...", "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( public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -1,84 +0,0 @@
package org.raddatz.familienarchiv.auth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {
private final AuthenticationManager authenticationManager;
private final UserService userService;
private final AuditService auditService;
private final LoginRateLimiter loginRateLimiter;
private final SessionRevocationPort sessionRevocationPort;
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));
AppUser user = userService.findByEmail(email);
auditService.log(AuditKind.LOGIN_SUCCESS, user.getId(), null, Map.of(
"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.
// DaoAuthenticationProvider already runs a dummy BCrypt on unknown users to
// equalise timing between "user not found" and "wrong password" paths.
auditService.log(AuditKind.LOGIN_FAILED, null, null, Map.of(
"email", email,
"ip", ip,
"ua", truncateUa(ua)));
throw DomainException.invalidCredentials();
}
}
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(
"userId", user.getId().toString(),
"ip", ip,
"ua", truncateUa(ua)));
}
private static String truncateUa(String ua) {
if (ua == null) return "";
return ua.length() > 200 ? ua.substring(0, 200) : ua;
}
public record LoginResult(AppUser user, Authentication authentication) {}
}

View File

@@ -1,102 +0,0 @@
package org.raddatz.familienarchiv.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.user.AppUser;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.*;
// @RequirePermission is intentionally absent: login is unauthenticated by design;
// logout requires an authenticated session (enforced by SecurityConfig), not a specific permission.
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthSessionController {
private final AuthService authService;
private final SessionAuthenticationStrategy sessionAuthenticationStrategy;
@PostMapping("/login")
public ResponseEntity<AppUser> login(
@RequestBody LoginRequest request,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
String ip = resolveClientIp(httpRequest);
String ua = resolveUserAgent(httpRequest);
AuthService.LoginResult result = authService.login(request.email(), request.password(), ip, ua);
// Session-fixation defense (CWE-384): rotate the session ID at the authentication
// boundary. ChangeSessionIdAuthenticationStrategy invalidates any pre-auth session ID
// an attacker may have planted and mints a fresh one before we attach the SecurityContext.
httpRequest.getSession(true);
sessionAuthenticationStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse);
// Spring Session JDBC intercepts setAttribute() and persists the record under the
// (now rotated) opaque ID; the Set-Cookie: fa_session=<opaque-id> is added automatically.
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(result.authentication());
SecurityContextHolder.setContext(context);
httpRequest.getSession()
.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
return ResponseEntity.ok(result.user());
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) {
String email = authentication.getName();
String ip = resolveClientIp(httpRequest);
String ua = resolveUserAgent(httpRequest);
// CWE-613 defense: invalidate the session first — that is the contract the user
// is relying on when they click "Log out." Audit is best-effort and must not
// bubble up: if the user record was deleted while the session was live, the
// audit lookup throws, but the session row in spring_session must still die.
HttpSession session = httpRequest.getSession(false);
if (session != null) {
session.invalidate();
}
SecurityContextHolder.clearContext();
try {
authService.logout(email, ip, ua);
} catch (Exception ex) {
log.warn("Audit logout failed for {}; session was already invalidated", email, ex);
}
return ResponseEntity.noContent().build();
}
/**
* Resolves the client IP for audit-log purposes.
*
* <p>Trust model: the leftmost {@code X-Forwarded-For} value is taken at face value.
* This is correct <em>only</em> if the ingress (Caddy in production) strips any
* client-supplied XFF before forwarding — otherwise an attacker can pin audit-log
* IPs to whatever they want. Verify the reverse-proxy config before exposing this
* service behind a different ingress.
*/
private static String resolveClientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
private static String resolveUserAgent(HttpServletRequest request) {
String ua = request.getHeader("User-Agent");
return ua != null ? ua : "";
}
}

View File

@@ -1,29 +0,0 @@
package org.raddatz.familienarchiv.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
@RequiredArgsConstructor
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
private final JdbcIndexedSessionRepository sessionRepository;
@Override
public int revokeOtherSessions(String currentSessionId, String principalName) {
int count = 0;
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
if (!id.equals(currentSessionId)) {
sessionRepository.deleteById(id);
count++;
}
}
return count;
}
@Override
public int revokeAllSessions(String principalName) {
var sessions = sessionRepository.findByPrincipalName(principalName);
sessions.keySet().forEach(sessionRepository::deleteById);
return sessions.size();
}
}

View File

@@ -1,72 +0,0 @@
package org.raddatz.familienarchiv.auth;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class LoginRateLimiter {
private final LoadingCache<String, Bucket> byIpEmail;
private final LoadingCache<String, Bucket> byIp;
private final int maxPerIpEmail;
private final int maxPerIp;
private final int windowMinutes;
public LoginRateLimiter(RateLimitProperties props) {
this.maxPerIpEmail = props.getMaxAttemptsPerIpEmail();
this.maxPerIp = props.getMaxAttemptsPerIp();
this.windowMinutes = props.getWindowMinutes();
this.byIpEmail = Caffeine.newBuilder()
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
.build(key -> newBucket(maxPerIpEmail, windowMinutes));
this.byIp = Caffeine.newBuilder()
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
.build(key -> newBucket(maxPerIp, windowMinutes));
}
// NOTE: This cache is node-local (in-memory). In a multi-replica deployment,
// effective limits would be multiplied by replica count.
// For the current single-VPS setup this is the correct, simplest implementation.
public void checkAndConsume(String ip, String email) {
long retryAfterSeconds = windowMinutes * 60L;
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
if (!byIpEmail.get(key).tryConsume(1)) {
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
"Too many login attempts from " + ip, retryAfterSeconds);
}
if (!byIp.get(ip).tryConsume(1)) {
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
byIpEmail.get(key).addTokens(1);
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
"Too many login attempts from " + ip, retryAfterSeconds);
}
}
public void invalidateOnSuccess(String ip, String email) {
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
byIp.invalidate(ip);
}
private static Bucket newBucket(int limit, int minutes) {
return Bucket.builder()
.addLimit(Bandwidth.builder()
.capacity(limit)
.refillGreedy(limit, Duration.ofMinutes(minutes))
.build())
.build();
}
}

View File

@@ -1,3 +0,0 @@
package org.raddatz.familienarchiv.auth;
public record LoginRequest(String email, String password) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.auth;
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
@Override
public int revokeOtherSessions(String currentSessionId, String principalName) {
return 0;
}
@Override
public int revokeAllSessions(String principalName) {
return 0;
}
}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.auth;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties("rate-limit.login")
@Data
public class RateLimitProperties {
private int maxAttemptsPerIpEmail = 10;
private int maxAttemptsPerIp = 20;
private int windowMinutes = 15;
}

View File

@@ -1,19 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
@Configuration
class SessionRevocationConfig {
@Bean
SessionRevocationPort sessionRevocationPort(
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
if (sessionRepository != null) {
return new JdbcSessionRevocationAdapter(sessionRepository);
}
return new NoOpSessionRevocationAdapter();
}
}

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
package org.raddatz.familienarchiv.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;
@Configuration
public class SpringSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("fa_session");
serializer.setSameSite("Strict");
// cookieHttpOnly: true is the DefaultCookieSerializer default
// useSecureCookie not set: auto-detects from request.isSecure().
// With forward-headers-strategy: native, Caddy's X-Forwarded-Proto: https
// causes isSecure() → true in production; direct HTTP in dev/tests → false.
return serializer;
}
}

View File

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

View File

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

View File

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

View File

@@ -10,21 +10,11 @@ public class DomainException extends RuntimeException {
private final ErrorCode code; private final ErrorCode code;
private final HttpStatus status; private final HttpStatus status;
/** Seconds until the rate-limit window resets; {@code null} when not applicable. */
private final Long retryAfterSeconds;
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) { public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
super(developerMessage); super(developerMessage);
this.code = code; this.code = code;
this.status = status; this.status = status;
this.retryAfterSeconds = null;
}
private DomainException(ErrorCode code, HttpStatus status, String developerMessage, Long retryAfterSeconds) {
super(developerMessage);
this.code = code;
this.status = status;
this.retryAfterSeconds = retryAfterSeconds;
} }
public ErrorCode getCode() { public ErrorCode getCode() {
@@ -35,11 +25,6 @@ public class DomainException extends RuntimeException {
return status; return status;
} }
/** Returns the {@code Retry-After} value in seconds, or {@code null} if not set. */
public Long getRetryAfterSeconds() {
return retryAfterSeconds;
}
// --- Static factories for common cases --- // --- Static factories for common cases ---
public static DomainException notFound(ErrorCode code, String message) { public static DomainException notFound(ErrorCode code, String message) {
@@ -54,11 +39,6 @@ public class DomainException extends RuntimeException {
return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message); return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
} }
public static DomainException invalidCredentials() {
return new DomainException(ErrorCode.INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED,
"Invalid email or password");
}
public static DomainException conflict(ErrorCode code, String message) { public static DomainException conflict(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.CONFLICT, message); return new DomainException(code, HttpStatus.CONFLICT, message);
} }
@@ -70,12 +50,4 @@ public class DomainException extends RuntimeException {
public static DomainException internal(ErrorCode code, String message) { public static DomainException internal(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message); return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
} }
public static DomainException tooManyRequests(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message);
}
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
}
} }

View File

@@ -30,8 +30,6 @@ public enum ErrorCode {
// --- Users --- // --- Users ---
/** A user with the given ID or username does not exist. 404 */ /** A user with the given ID or username does not exist. 404 */
USER_NOT_FOUND, USER_NOT_FOUND,
/** A group with the given ID does not exist. 404 */
GROUP_NOT_FOUND,
/** The supplied email address is already used by another account. 409 */ /** The supplied email address is already used by another account. 409 */
EMAIL_ALREADY_IN_USE, EMAIL_ALREADY_IN_USE,
/** The supplied current password does not match the stored hash. 400 */ /** The supplied current password does not match the stored hash. 400 */
@@ -54,24 +52,14 @@ public enum ErrorCode {
INVITE_REVOKED, INVITE_REVOKED,
/** The invite has passed its expiry date. 410 */ /** The invite has passed its expiry date. 410 */
INVITE_EXPIRED, INVITE_EXPIRED,
/** A group cannot be deleted because one or more active invites reference it. 409 */
GROUP_HAS_ACTIVE_INVITES,
// --- Auth --- // --- Auth ---
/** The request is not authenticated. 401 */ /** The request is not authenticated. 401 */
UNAUTHORIZED, UNAUTHORIZED,
/** The authenticated user lacks the required permission. 403 */ /** The authenticated user lacks the required permission. 403 */
FORBIDDEN, FORBIDDEN,
/** The supplied email/password combination does not match any active account. 401 */
INVALID_CREDENTIALS,
/** The session has expired or been invalidated. 401 */
SESSION_EXPIRED,
/** The password-reset token is missing, expired, or already used. 400 */ /** The password-reset token is missing, expired, or already used. 400 */
INVALID_RESET_TOKEN, INVALID_RESET_TOKEN,
/** CSRF token is missing or does not match the expected value. 403 */
CSRF_TOKEN_MISSING,
/** The login rate limit has been exceeded for this IP/email combination. 429 */
TOO_MANY_LOGIN_ATTEMPTS,
// --- Annotations --- // --- Annotations ---
/** The annotation with the given ID does not exist. 404 */ /** The annotation with the given ID does not exist. 404 */

View File

@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.exception;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import io.sentry.Sentry;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -23,11 +22,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class) @ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) { public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
var builder = ResponseEntity.status(ex.getStatus()); return ResponseEntity
if (ex.getRetryAfterSeconds() != null) { .status(ex.getStatus())
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds())); .body(new ErrorResponse(ex.getCode(), ex.getMessage()));
}
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
} }
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
@@ -66,7 +63,6 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) { public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
Sentry.captureException(ex);
log.error("Unhandled exception", ex); log.error("Unhandled exception", ex);
return ResponseEntity.internalServerError() return ResponseEntity.internalServerError()
.body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred")); .body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred"));

View File

@@ -1,8 +1,5 @@
package org.raddatz.familienarchiv.importing; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*; import org.apache.poi.ss.usermodel.*;
@@ -33,7 +30,6 @@ import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -56,33 +52,9 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED } public enum State { IDLE, RUNNING, DONE, FAILED }
public record SkippedFile( public record ImportStatus(State state, String message, int processed, LocalDateTime startedAt) {}
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
) {}
public record ImportStatus( private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null);
@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() { public ImportStatus getStatus() {
return currentStatus; return currentStatus;
@@ -127,9 +99,7 @@ public class MassImportService {
@Value("${app.import.col.transcription:13}") @Value("${app.import.col.transcription:13}")
private int colTranscription; private int colTranscription;
@Value("${app.import.dir:/import}") private static final String IMPORT_DIR = "/import";
private String importDir;
private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN); private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
// ODS XML namespaces // ODS XML namespaces
@@ -144,39 +114,30 @@ public class MassImportService {
if (currentStatus.state() == State.RUNNING) { if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress"); throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
} }
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now()); currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now());
try { try {
File spreadsheet = findSpreadsheetFile(); File spreadsheet = findSpreadsheetFile();
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath()); log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
ProcessResult result = processRows(readSpreadsheet(spreadsheet)); int processed = processRows(readSpreadsheet(spreadsheet));
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE", currentStatus = new ImportStatus(State.DONE,
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.", "Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
result.processed(), result.skippedFiles(), currentStatus.startedAt()); processed, 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, List.of(), currentStatus.startedAt());
} catch (Exception e) { } catch (Exception e) {
log.error("Massenimport fehlgeschlagen", e); log.error("Massenimport fehlgeschlagen", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL", currentStatus = new ImportStatus(State.FAILED, "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
} }
} }
private static class NoSpreadsheetException extends RuntimeException {
NoSpreadsheetException(String message) { super(message); }
}
private File findSpreadsheetFile() throws IOException { private File findSpreadsheetFile() throws IOException {
try (Stream<Path> files = Files.list(Paths.get(importDir))) { try (Stream<Path> files = Files.list(Paths.get(IMPORT_DIR))) {
return files return files
.filter(p -> { .filter(p -> {
String name = p.toString().toLowerCase(); String name = p.toString().toLowerCase();
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls"); return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
}) })
.findFirst() .findFirst()
.orElseThrow(() -> new NoSpreadsheetException( .orElseThrow(() -> new RuntimeException(
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!")) "Keine Tabellendatei (.ods/.xlsx/.xls) in " + IMPORT_DIR + " gefunden!"))
.toFile(); .toFile();
} }
} }
@@ -195,14 +156,14 @@ public class MassImportService {
* Reads an ODS file by parsing its content.xml directly (no extra library needed). * Reads an ODS file by parsing its content.xml directly (no extra library needed).
* ODS is a ZIP archive; content.xml holds the spreadsheet data as XML. * ODS is a ZIP archive; content.xml holds the spreadsheet data as XML.
*/ */
List<List<String>> readOds(File file) throws Exception { private List<List<String>> readOds(File file) throws Exception {
List<List<String>> result = new ArrayList<>(); List<List<String>> result = new ArrayList<>();
try (ZipFile zip = new ZipFile(file)) { try (ZipFile zip = new ZipFile(file)) {
var entry = zip.getEntry("content.xml"); var entry = zip.getEntry("content.xml");
if (entry == null) throw new RuntimeException("Ungültige ODS-Datei: content.xml fehlt"); if (entry == null) throw new RuntimeException("Ungültige ODS-Datei: content.xml fehlt");
var factory = XxeSafeXmlParser.hardenedFactory(); var factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true); factory.setNamespaceAware(true);
var builder = factory.newDocumentBuilder(); var builder = factory.newDocumentBuilder();
var doc = builder.parse(zip.getInputStream(entry)); var doc = builder.parse(zip.getInputStream(entry));
@@ -281,10 +242,8 @@ public class MassImportService {
// --- Import logic (works on neutral List<String> rows) --- // --- Import logic (works on neutral List<String> rows) ---
private ProcessResult processRows(List<List<String>> rows) { private int processRows(List<List<String>> rows) {
int processed = 0; int count = 0;
List<SkippedFile> skippedFiles = new ArrayList<>();
for (int i = 1; i < rows.size(); i++) { // skip header row for (int i = 1; i < rows.size(); i++) { // skip header row
List<String> cells = rows.get(i); List<String> cells = rows.get(i);
String index = getCell(cells, colIndex); String index = getCell(cells, colIndex);
@@ -295,58 +254,18 @@ public class MassImportService {
if (fileOnDisk.isEmpty()) { if (fileOnDisk.isEmpty()) {
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename); log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
} }
importSingleDocument(cells, fileOnDisk, filename, index);
if (fileOnDisk.isPresent()) { count++;
try {
if (!isPdfMagicBytes(fileOnDisk.get())) {
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
continue;
}
} catch (IOException e) {
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
continue;
}
}
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
if (skipReason.isPresent()) {
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
} else {
processed++;
}
} }
return new ProcessResult(processed, skippedFiles); return count;
} }
// 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 @Transactional
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) { protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename); Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) { if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename); log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
return Optional.of("ALREADY_EXISTS"); return;
} }
String archiveBox = getCell(cells, colBox); String archiveBox = getCell(cells, colBox);
@@ -382,7 +301,7 @@ public class MassImportService {
status = DocumentStatus.UPLOADED; status = DocumentStatus.UPLOADED;
} catch (Exception e) { } catch (Exception e) {
log.error("S3 Upload Fehler für {}", file.get().getName(), e); log.error("S3 Upload Fehler für {}", file.get().getName(), e);
return Optional.of("S3_UPLOAD_FAILED"); return;
} }
} }
@@ -424,7 +343,6 @@ public class MassImportService {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
} }
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename); log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
return Optional.empty();
} }
// --- Helpers --- // --- Helpers ---
@@ -460,7 +378,7 @@ public class MassImportService {
} }
private Optional<File> findFileRecursive(String filename) { private Optional<File> findFileRecursive(String filename) {
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) { try (Stream<Path> walk = Files.walk(Paths.get(IMPORT_DIR))) {
return walk.filter(p -> !Files.isDirectory(p)) return walk.filter(p -> !Files.isDirectory(p))
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.map(Path::toFile) .map(Path::toFile)

View File

@@ -1,20 +0,0 @@
package org.raddatz.familienarchiv.importing;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
class XxeSafeXmlParser {
private XxeSafeXmlParser() {}
static DocumentBuilderFactory hardenedFactory() throws ParserConfigurationException {
var factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
}
}

View File

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

View File

@@ -0,0 +1,137 @@
package org.raddatz.familienarchiv.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Enumeration;
/**
* Promotes the {@code auth_token} cookie to an {@code Authorization} header
* so that browser-side requests to {@code /api/*} authenticate the same way
* SSR fetches do.
*
* <p>The SvelteKit login action stores the full HTTP Basic header value
* ({@code "Basic <base64>"}) in an HttpOnly cookie. SSR fetches from
* {@code hooks.server.ts} read the cookie and pass it explicitly as the
* {@code Authorization} header. In the dev environment, Vite's proxy does
* the same on every {@code /api/*} request (see {@code vite.config.ts}).
* In production, Caddy proxies {@code /api/*} straight to the backend and
* does NOT translate the cookie — so client-side {@code fetch} and
* {@code EventSource} calls reach the backend without auth, get
* {@code 401 WWW-Authenticate: Basic}, and the browser pops a native dialog.
*
* <p>This filter closes that gap: if a request has an {@code auth_token}
* cookie but no explicit {@code Authorization} header, promote the cookie
* value (URL-decoded) into the header before Spring Security inspects it.
* Explicit {@code Authorization} headers are preserved unchanged.
*
* <p>See #520. Filter runs at {@code Ordered.HIGHEST_PRECEDENCE} so it
* mutates the request before any Spring Security filter sees it.
*
* <p><b>Scope:</b> only {@code /api/*} requests are touched. The
* {@code /actuator/*} block in Caddy plus the open auth/reset paths in
* {@link SecurityConfig} must NOT receive a promoted Authorization.
*
* <p><b>⚠ Log-leakage warning:</b> the wrapped request exposes the
* Authorization header via {@code getHeaderNames}/{@code getHeaders}. Any
* filter or interceptor that iterates request headers will see the live
* Basic credential. Do NOT add a request-header logger downstream of this
* filter without explicitly scrubbing the {@code Authorization} field.
*/
@Component
@Order(org.springframework.core.Ordered.HIGHEST_PRECEDENCE)
public class AuthTokenCookieFilter extends OncePerRequestFilter {
static final String COOKIE_NAME = "auth_token";
static final String SCOPE_PREFIX = "/api/";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// Scope: only /api/* needs cookie promotion. /actuator/health (open),
// /api/auth/forgot-password (open), /login etc. don't.
if (!request.getRequestURI().startsWith(SCOPE_PREFIX)) {
chain.doFilter(request, response);
return;
}
// An explicit Authorization header wins — this is the SSR fetch path
// (hooks.server.ts builds the header itself).
if (request.getHeader(HttpHeaders.AUTHORIZATION) != null) {
chain.doFilter(request, response);
return;
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
chain.doFilter(request, response);
return;
}
for (Cookie c : cookies) {
if (COOKIE_NAME.equals(c.getName()) && c.getValue() != null && !c.getValue().isBlank()) {
String decoded;
try {
decoded = URLDecoder.decode(c.getValue(), StandardCharsets.UTF_8);
} catch (IllegalArgumentException malformed) {
// Malformed percent-encoding — refuse to forward a bogus
// Authorization header. Spring Security will treat the
// request as unauthenticated.
chain.doFilter(request, response);
return;
}
chain.doFilter(new AuthHeaderRequest(request, decoded), response);
return;
}
}
chain.doFilter(request, response);
}
/**
* Adds (or overrides) the {@code Authorization} header on a wrapped request.
* All other headers pass through unchanged.
*/
static final class AuthHeaderRequest extends HttpServletRequestWrapper {
private final String authorization;
AuthHeaderRequest(HttpServletRequest request, String authorization) {
super(request);
this.authorization = authorization;
}
@Override
public String getHeader(String name) {
if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
return authorization;
}
return super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
return Collections.enumeration(Collections.singletonList(authorization));
}
return super.getHeaders(name);
}
@Override
public Enumeration<String> getHeaderNames() {
Enumeration<String> base = super.getHeaderNames();
java.util.Set<String> names = new java.util.LinkedHashSet<>();
while (base.hasMoreElements()) names.add(base.nextElement());
names.add(HttpHeaders.AUTHORIZATION);
return Collections.enumeration(names);
}
}
}

View File

@@ -1,42 +1,24 @@
package org.raddatz.familienarchiv.security; package org.raddatz.familienarchiv.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.user.CustomUserDetailsService; import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import java.util.Map;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
// cannot be injected here. A static instance is safe because the response
// only serializes fixed String keys — no custom naming strategy or module needed.
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
private final Environment environment; private final Environment environment;
@@ -52,57 +34,28 @@ public class SecurityConfig {
return authProvider; return authProvider;
} }
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
// ChangeSessionIdAuthenticationStrategy rotates the session ID via the Servlet 3.1+
// HttpServletRequest.changeSessionId() — preserves attributes, mints a fresh ID.
// Used by AuthSessionController.login to defend against session fixation (CWE-384).
return new ChangeSessionIdAuthenticationStrategy();
}
@Bean
@Order(1)
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> {
// Health and Prometheus are open — Docker health checks and Prometheus scraping need no credentials.
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
// All other actuator endpoints (metrics, info, env, heapdump…) require authentication.
auth.anyRequest().authenticated();
})
// Explicitly return 401 for any unauthenticated actuator request.
// Without this override, Spring Security's DelegatingAuthenticationEntryPoint
// would redirect browser-like clients to the form-login page (302 → /login),
// making it impossible to distinguish "not authenticated" from "not found" in tests.
.exceptionHandling(ex -> ex.authenticationEntryPoint(
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
.formLogin(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103). // CSRF is intentionally disabled. With the cookie-promotion model
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it). // (auth_token cookie → Authorization header via AuthTokenCookieFilter,
// All state-changing requests must include X-XSRF-TOKEN matching the cookie. // see #520), every authenticated request to /api/* now carries the
// See ADR-022 and issue #524 for the full security rationale. // credential automatically once the cookie is set. The CSRF defence
.csrf(csrf -> csrf // for state-changing endpoints is therefore LOAD-BEARING on:
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) //
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) // 1. SameSite=strict on the auth_token cookie (login/+page.server.ts).
// 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 (e.g. cookie flipped to
// SameSite=lax, CORS allowedOrigins expanded), CSRF protection
// MUST be re-enabled here.
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> { .authorizeHttpRequests(auth -> {
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above. // Health endpoint must be open so CI/Docker health checks work without credentials
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll(); auth.requestMatchers("/actuator/health").permitAll();
// Login is unauthenticated by definition
auth.requestMatchers("/api/auth/login").permitAll();
// Password reset endpoints are unauthenticated by nature // Password reset endpoints are unauthenticated by nature
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll(); auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
// Invite-based registration endpoints are public // Invite-based registration endpoints are public
@@ -122,18 +75,9 @@ public class SecurityConfig {
// erlaubt pdf im Iframe // erlaubt pdf im Iframe
.headers(headers -> headers .headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.sameOrigin())) .frameOptions(frameOptions -> frameOptions.sameOrigin()))
// Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures. // Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
.exceptionHandling(ex -> ex .httpBasic(Customizer.withDefaults())
.authenticationEntryPoint( .formLogin(form -> form.usernameParameter("email"));
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
.accessDeniedHandler((req, res, e) -> {
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
res.setContentType("application/json;charset=UTF-8");
ErrorCode code = (e instanceof CsrfException)
? ErrorCode.CSRF_TOKEN_MISSING
: ErrorCode.FORBIDDEN;
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
}));
return http.build(); return http.build();
} }

View File

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

View File

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

View File

@@ -52,11 +52,7 @@ public class InviteService {
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) { public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
Set<UUID> groupIds = new HashSet<>(); Set<UUID> groupIds = new HashSet<>();
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) { if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
Set<UUID> uniqueIds = new HashSet<>(dto.getGroupIds()); List<UserGroup> groups = userService.findGroupsByIds(dto.getGroupIds());
List<UserGroup> groups = userService.findGroupsByIds(new ArrayList<>(uniqueIds));
if (groups.size() != uniqueIds.size()) {
throw DomainException.notFound(ErrorCode.GROUP_NOT_FOUND, "One or more group IDs do not exist");
}
groups.forEach(g -> groupIds.add(g.getId())); groups.forEach(g -> groupIds.add(g.getId()));
} }

View File

@@ -24,7 +24,4 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID>
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC") @Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
List<InviteToken> findAllOrderedByCreatedAt(); List<InviteToken> findAllOrderedByCreatedAt();
@Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM InviteToken t JOIN t.groupIds g WHERE g = :groupId AND t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses)")
boolean existsActiveWithGroupId(@Param("groupId") UUID groupId);
} }

View File

@@ -5,7 +5,6 @@ import java.time.LocalDateTime;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
import org.raddatz.familienarchiv.auth.AuthService;
import org.raddatz.familienarchiv.user.ResetPasswordRequest; import org.raddatz.familienarchiv.user.ResetPasswordRequest;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -33,7 +32,6 @@ public class PasswordResetService {
private final UserService userService; private final UserService userService;
private final PasswordResetTokenRepository tokenRepository; private final PasswordResetTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuthService authService;
@Autowired(required = false) @Autowired(required = false)
private JavaMailSender mailSender; private JavaMailSender mailSender;
@@ -87,8 +85,6 @@ public class PasswordResetService {
resetToken.setUsed(true); resetToken.setUsed(true);
tokenRepository.save(resetToken); tokenRepository.save(resetToken);
authService.revokeAllSessions(user.getEmail());
} }
/** /**

View File

@@ -4,11 +4,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.auth.AuthService;
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest; import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.user.ChangePasswordDTO; import org.raddatz.familienarchiv.user.ChangePasswordDTO;
import org.raddatz.familienarchiv.user.CreateUserRequest; import org.raddatz.familienarchiv.user.CreateUserRequest;
@@ -30,15 +26,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor; import lombok.AllArgsConstructor;
@RestController @RestController
@RequestMapping("/api/") @RequestMapping("/api/")
@RequiredArgsConstructor @AllArgsConstructor
public class UserController { public class UserController {
private final UserService userService; private UserService userService;
private final AuthService authService;
private final AuditService auditService;
@GetMapping("users/me") @GetMapping("users/me")
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) { public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
@@ -62,14 +56,9 @@ public class UserController {
@PostMapping("users/me/password") @PostMapping("users/me/password")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void changePassword(Authentication authentication, public void changePassword(Authentication authentication,
HttpSession session,
@RequestBody ChangePasswordDTO dto) { @RequestBody ChangePasswordDTO dto) {
AppUser current = userService.findByEmail(authentication.getName()); AppUser current = userService.findByEmail(authentication.getName());
userService.changePassword(current.getId(), dto); userService.changePassword(current.getId(), dto);
int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName());
auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of(
"reason", "password_change",
"revokedCount", revoked));
} }
@GetMapping("users/{id}") @GetMapping("users/{id}")
@@ -112,18 +101,6 @@ public class UserController {
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@PostMapping("/users/{id}/force-logout")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<Map<String, Object>> forceLogout(Authentication authentication,
@PathVariable UUID id) {
AppUser target = userService.getById(id);
int revoked = authService.revokeAllSessions(target.getEmail());
auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of(
"targetUserId", target.getId().toString(),
"revokedCount", revoked));
return ResponseEntity.ok(Map.of("revokedCount", revoked));
}
private UUID actorId(Authentication auth) { private UUID actorId(Authentication auth) {
return userService.findByEmail(auth.getName()).getId(); return userService.findByEmail(auth.getName()).getId();
} }

View File

@@ -37,9 +37,6 @@ public class UserService {
private final AppUserRepository userRepository; private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository; private final UserGroupRepository groupRepository;
// Injected directly (not via InviteService) to avoid a constructor injection cycle:
// InviteService → UserService → InviteService. Spring Framework 7 forbids such cycles.
private final InviteTokenRepository inviteTokenRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuditService auditService; private final AuditService auditService;
@@ -291,10 +288,6 @@ public class UserService {
@Transactional @Transactional
public void deleteGroup(UUID id) { public void deleteGroup(UUID id) {
if (inviteTokenRepository.existsActiveWithGroupId(id)) {
throw DomainException.conflict(ErrorCode.GROUP_HAS_ACTIVE_INVITES,
"Cannot delete group " + id + " — referenced by one or more active invites");
}
groupRepository.deleteById(id); groupRepository.deleteById(id);
} }
} }

View File

@@ -1,9 +1,6 @@
spring: spring:
jpa: jpa:
show-sql: true show-sql: true
# spring.session.cookie.secure is no longer a supported Boot 4.x property.
# DefaultCookieSerializer auto-detects Secure from request.isSecure().
# Direct HTTP in dev → isSecure()=false → cookie sent without Secure attribute.
springdoc: springdoc:
api-docs: api-docs:

View File

@@ -38,13 +38,6 @@ spring:
starttls: starttls:
enable: true enable: true
session:
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
jdbc:
initialize-schema: never # Flyway owns schema creation (V67)
# Cookie name, SameSite, and Secure are configured via SpringSessionConfig#cookieSerializer
# (spring.session.cookie.* is not supported in Spring Boot 4.x).
server: server:
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that # Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
# request.getScheme(), redirect URLs, and Spring Session "Secure" cookies # request.getScheme(), redirect URLs, and Spring Session "Secure" cookies
@@ -52,50 +45,9 @@ server:
forward-headers-strategy: native forward-headers-strategy: native
management: management:
server:
# Management port is separate from the app port so that:
# (a) Caddy never proxies /actuator/* (it only routes :8080 → the app port)
# (b) Prometheus scrapes backend:8081 directly inside archiv-net, not via Caddy
# Note: in Spring Boot 4.0 the management port shares the security filter chain; /actuator/health
# and /actuator/prometheus must be explicitly permitted in SecurityConfig — see SecurityConfig.java.
port: 8081
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
prometheus:
enabled: true
# Spring Boot 4.0: metrics export is disabled by default — explicitly opt in for Prometheus
prometheus:
metrics:
export:
enabled: true
metrics:
tags:
# Common tag applied to every metric so Grafana's Spring Boot dashboard can filter by application name.
# Override via MANAGEMENT_METRICS_TAGS_APPLICATION env var.
application: ${spring.application.name}
health: health:
mail: mail:
enabled: false enabled: false
tracing:
sampling:
probability: 1.0 # 100% in dev; override via MANAGEMENT_TRACING_SAMPLING_PROBABILITY in prod compose
# OpenTelemetry trace export — failures are non-fatal (app starts cleanly without Tempo running)
# Port 4318 = OTLP HTTP (the default transport for Spring Boot's HttpExporter).
# Port 4317 is gRPC-only; sending HTTP/1.1 to it produces "Connection reset".
otel:
service:
name: familienarchiv-backend
exporter:
otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}
logs:
exporter: none # Promtail captures Docker logs; disable OTLP log export (Tempo only accepts traces)
metrics:
exporter: none # Prometheus scrapes /actuator/prometheus; disable OTLP metric export to Tempo
springdoc: springdoc:
api-docs: api-docs:
@@ -141,18 +93,3 @@ ocr:
sender-model: sender-model:
activation-threshold: 100 activation-threshold: 100
retrain-delta: 50 retrain-delta: 50
sentry:
dsn: ${SENTRY_DSN:}
environment: ${SPRING_PROFILES_ACTIVE:dev}
traces-sample-rate: ${SENTRY_TRACES_SAMPLE_RATE:1.0}
send-default-pii: false
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

View File

@@ -1,3 +0,0 @@
-- The composite PK (invite_token_id, group_id) does not support efficient lookups by group_id alone.
-- Add a dedicated index to support existsActiveWithGroupId queries.
CREATE INDEX idx_itg_group_id ON invite_token_group_ids (group_id);

View File

@@ -1,27 +0,0 @@
-- Re-introduces the Spring Session JDBC tables that were dropped by V2 as unused.
-- DDL copied verbatim from Spring Session 3.x schema-postgresql.sql.
-- See ADR-020 and issue #523.
CREATE TABLE spring_session (
PRIMARY_ID CHAR(36) NOT NULL,
SESSION_ID CHAR(36) NOT NULL,
CREATION_TIME BIGINT NOT NULL,
LAST_ACCESS_TIME BIGINT NOT NULL,
MAX_INACTIVE_INTERVAL INT NOT NULL,
EXPIRY_TIME BIGINT NOT NULL,
PRINCIPAL_NAME VARCHAR(100),
CONSTRAINT spring_session_pk PRIMARY KEY (PRIMARY_ID)
);
CREATE UNIQUE INDEX spring_session_ix1 ON spring_session (SESSION_ID);
CREATE INDEX spring_session_ix2 ON spring_session (EXPIRY_TIME);
CREATE INDEX spring_session_ix3 ON spring_session (PRINCIPAL_NAME);
CREATE TABLE spring_session_attributes (
SESSION_PRIMARY_ID CHAR(36) NOT NULL,
ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
ATTRIBUTE_BYTES BYTEA NOT NULL,
CONSTRAINT spring_session_attributes_pk PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
CONSTRAINT spring_session_attributes_fk FOREIGN KEY (SESSION_PRIMARY_ID)
REFERENCES spring_session (PRIMARY_ID) ON DELETE CASCADE
);

View File

@@ -1,63 +0,0 @@
package org.raddatz.familienarchiv;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalManagementPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class ActuatorPrometheusIT {
@LocalManagementPort
private int managementPort;
@MockitoBean
S3Client s3Client;
@Test
void prometheus_endpoint_returns_200_without_credentials() {
ResponseEntity<String> response = noThrowTemplate().getForEntity(
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
}
@Test
void prometheus_endpoint_returns_jvm_metrics() {
ResponseEntity<String> response = noThrowTemplate().getForEntity(
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
assertThat(response.getBody()).contains("jvm_memory_used_bytes");
}
@Test
void actuator_metrics_requires_authentication() {
ResponseEntity<String> response = noThrowTemplate().getForEntity(
"http://localhost:" + managementPort + "/actuator/metrics", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(401);
}
private RestTemplate noThrowTemplate() {
RestTemplate template = new RestTemplate();
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

@@ -1,55 +0,0 @@
package org.raddatz.familienarchiv;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalManagementPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class ActuatorSecurityTest {
@LocalManagementPort
private int managementPort;
@MockitoBean
S3Client s3Client;
@Test
void actuator_health_is_accessible_without_authentication() {
ResponseEntity<String> response = noThrowTemplate().getForEntity(
"http://localhost:" + managementPort + "/actuator/health", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
}
@Test
void actuator_env_requires_authentication() {
ResponseEntity<String> response = noThrowTemplate().getForEntity(
"http://localhost:" + managementPort + "/actuator/env", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(401);
}
private RestTemplate noThrowTemplate() {
RestTemplate template = new RestTemplate();
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

@@ -1,18 +1,14 @@
package org.raddatz.familienarchiv; package org.raddatz.familienarchiv;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(PostgresContainerConfig.class) @Import(PostgresContainerConfig.class)
@@ -21,18 +17,9 @@ class ApplicationContextTest {
@MockitoBean @MockitoBean
S3Client s3Client; S3Client s3Client;
@Autowired
ApplicationContext ctx;
@Test @Test
void contextLoads() { void contextLoads() {
// verifies that the Spring context starts successfully with all beans wired, // verifies that the Spring context starts successfully with all beans wired,
// Flyway migrations applied, and no configuration errors // Flyway migrations applied, and no configuration errors
} }
@Test
void sentry_is_disabled_when_no_dsn_is_configured() {
// application-test.yaml has no sentry.dsn — SDK must stay inactive so tests are clean
assertThat(io.sentry.Sentry.isEnabled()).isFalse();
}
} }

View File

@@ -1,11 +1,11 @@
package org.raddatz.familienarchiv.audit; package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
@@ -18,6 +18,7 @@ import static org.awaitility.Awaitility.await;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(PostgresContainerConfig.class) @Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class AuditServiceIntegrationTest { class AuditServiceIntegrationTest {
@MockitoBean S3Client s3Client; @MockitoBean S3Client s3Client;
@@ -25,11 +26,6 @@ class AuditServiceIntegrationTest {
@Autowired AuditLogRepository auditLogRepository; @Autowired AuditLogRepository auditLogRepository;
@Autowired TransactionTemplate transactionTemplate; @Autowired TransactionTemplate transactionTemplate;
@BeforeEach
void resetAuditLog() {
auditLogRepository.deleteAll();
}
@Test @Test
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() { void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
transactionTemplate.execute(status -> { transactionTemplate.execute(status -> {

View File

@@ -1,191 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
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";
private static final String UA = "Mozilla/5.0 (Test)";
@Test
void login_returns_user_on_valid_credentials() {
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.LoginResult result = authService.login("user@test.de", "pass123", IP, UA);
assertThat(result.user()).isEqualTo(user);
assertThat(result.authentication()).isEqualTo(auth);
}
@Test
void login_fires_LOGIN_SUCCESS_audit_on_valid_credentials() {
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(auditService).log(
eq(AuditKind.LOGIN_SUCCESS),
eq(userId),
isNull(),
argThat(payload -> userId.toString().equals(payload.get("userId").toString())
&& IP.equals(payload.get("ip"))
&& !payload.containsKey("password"))
);
}
@Test
void login_throws_INVALID_CREDENTIALS_on_bad_password() {
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.INVALID_CREDENTIALS));
}
@Test
void login_fires_LOGIN_FAILED_audit_on_bad_credentials_without_password_in_payload() {
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
.isInstanceOf(DomainException.class);
verify(auditService).log(
eq(AuditKind.LOGIN_FAILED),
isNull(),
isNull(),
argThat(payload -> "user@test.de".equals(payload.get("email"))
&& IP.equals(payload.get("ip"))
&& !payload.containsKey("password")
&& !payload.containsKey("pwd")
&& !payload.containsKey("passwordAttempt"))
);
}
@Test
void login_treats_unknown_user_identically_to_bad_password() {
when(authenticationManager.authenticate(any()))
.thenThrow(new BadCredentialsException("unknown user hidden as bad creds"));
assertThatThrownBy(() -> authService.login("unknown@test.de", "any", IP, UA))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.INVALID_CREDENTIALS));
verify(auditService).log(eq(AuditKind.LOGIN_FAILED), isNull(), isNull(), anyMap());
}
@Test
void logout_fires_LOGOUT_audit() {
UUID userId = UUID.randomUUID();
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
when(userService.findByEmail("user@test.de")).thenReturn(user);
authService.logout("user@test.de", IP, UA);
verify(auditService).log(
eq(AuditKind.LOGOUT),
eq(userId),
isNull(),
argThat(payload -> userId.toString().equals(payload.get("userId").toString())
&& IP.equals(payload.get("ip"))
&& !payload.containsKey("password"))
);
}
@Test
void login_checks_rate_limit_before_authenticating() {
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
verify(authenticationManager, never()).authenticate(any());
}
@Test
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
.isInstanceOf(DomainException.class);
verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(),
argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email"))));
}
@Test
void login_invalidates_rate_limit_on_success() {
UUID userId = UUID.randomUUID();
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
when(authenticationManager.authenticate(any())).thenReturn(auth);
when(userService.findByEmail("user@test.de")).thenReturn(user);
authService.login("user@test.de", "pass123", IP, UA);
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
}
@Test
void revokeOtherSessions_delegates_to_port() {
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
assertThat(count).isEqualTo(2);
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
}
@Test
void revokeAllSessions_delegates_to_port() {
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
int count = authService.revokeAllSessions("user@test.de");
assertThat(count).isEqualTo(3);
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
}
}

View File

@@ -1,191 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.auth.AuthService.LoginResult;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
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.core.Authentication;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
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.*;
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.*;
@WebMvcTest(AuthSessionController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class AuthSessionControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean AuthService authService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@MockitoBean SessionAuthenticationStrategy sessionAuthenticationStrategy;
// ─── POST /api/auth/login ──────────────────────────────────────────────────
@Test
void login_returns_200_with_user_on_valid_credentials() throws Exception {
UUID userId = UUID.randomUUID();
AppUser appUser = AppUser.builder().id(userId).email("user@test.de").build();
Authentication auth = mock(Authentication.class);
when(authService.login(anyString(), anyString(), anyString(), anyString()))
.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())
.andExpect(jsonPath("$.email").value("user@test.de"))
.andExpect(jsonPath("$.id").value(userId.toString()));
}
@Test
void login_returns_401_with_INVALID_CREDENTIALS_on_bad_credentials() throws Exception {
when(authService.login(anyString(), anyString(), anyString(), anyString()))
.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(jsonPath("$.code").value(ErrorCode.INVALID_CREDENTIALS.name()));
}
@Test
void login_is_public_no_session_required() throws Exception {
UUID userId = UUID.randomUUID();
AppUser appUser = AppUser.builder().id(userId).email("pub@test.de").build();
Authentication auth = mock(Authentication.class);
when(authService.login(anyString(), anyString(), anyString(), anyString()))
.thenReturn(new LoginResult(appUser, auth));
// 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());
}
@Test
void login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection() throws Exception {
UUID userId = UUID.randomUUID();
AppUser appUser = AppUser.builder().id(userId).email("fix@test.de").build();
Authentication auth = mock(Authentication.class);
when(authService.login(anyString(), anyString(), anyString(), anyString()))
.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());
// Session-fixation defense (CWE-384): the controller must hand the new
// Authentication to Spring Security's strategy, which rotates the session ID.
verify(sessionAuthenticationStrategy).onAuthentication(eq(auth), any(), any());
}
@Test
void login_response_body_does_not_contain_password_field() throws Exception {
// Regression guard: AppUser.password is @JsonProperty(WRITE_ONLY). If anyone
// ever drops that annotation, this assertion catches the credential leak on
// the very next CI run.
UUID userId = UUID.randomUUID();
AppUser appUser = AppUser.builder()
.id(userId)
.email("leak@test.de")
.password("$2a$10$shouldnotappearinresponse")
.build();
Authentication auth = mock(Authentication.class);
when(authService.login(anyString(), anyString(), anyString(), anyString()))
.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())
.andExpect(jsonPath("$.password").doesNotExist())
.andExpect(jsonPath("$.pwd").doesNotExist())
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("$2a$10$shouldnotappearinresponse"))));
}
@Test
void login_does_not_set_cookie_on_failure() throws Exception {
when(authService.login(anyString(), anyString(), anyString(), anyString()))
.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
void logout_returns_204_when_authenticated() throws Exception {
doNothing().when(authService).logout(anyString(), anyString(), anyString());
mockMvc.perform(post("/api/auth/logout")
.with(user("user@test.de"))
.with(csrf()))
.andExpect(status().isNoContent());
}
@Test
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().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
}
@Test
void logout_returns_204_even_when_audit_throws() throws Exception {
// CWE-613 defense: the session MUST be invalidated even if the audit lookup
// explodes (e.g. user deleted between login and logout). Audit is best-effort.
doThrow(new RuntimeException("audit DB down"))
.when(authService).logout(anyString(), anyString(), anyString());
mockMvc.perform(post("/api/auth/logout")
.with(user("ghost@test.de"))
.with(csrf()))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,192 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.io.IOException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class AuthSessionIntegrationTest {
@LocalServerPort int port;
@MockitoBean S3Client s3Client;
@Autowired AppUserRepository userRepository;
@Autowired PasswordEncoder passwordEncoder;
@Autowired JdbcTemplate jdbcTemplate;
private RestTemplate http;
private String baseUrl;
private static final String TEST_EMAIL = "session-it@test.de";
private static final String TEST_PASSWORD = "pass4Session!";
@BeforeEach
void setUp() {
http = noThrowRestTemplate();
baseUrl = "http://localhost:" + port;
// spring_session_attributes cascades on delete — removing the parent row is enough
jdbcTemplate.update("DELETE FROM spring_session");
jdbcTemplate.update("DELETE FROM app_users WHERE email = ?", TEST_EMAIL);
userRepository.save(AppUser.builder()
.email(TEST_EMAIL)
.password(passwordEncoder.encode(TEST_PASSWORD))
.build());
}
// ─── Task 13: full session lifecycle ──────────────────────────────────────
@Test
void login_sets_opaque_fa_session_cookie() {
String xsrf = fetchXsrfToken();
ResponseEntity<String> response = doLogin(xsrf);
assertThat(response.getStatusCode().value()).isEqualTo(200);
String cookie = extractFaSessionCookie(response);
assertThat(cookie).isNotBlank();
// Opaque token — must not look like Basic-auth credentials (email:password)
assertThat(cookie).doesNotContain(":");
}
@Test
void session_cookie_authenticates_subsequent_request() {
String xsrf = fetchXsrfToken();
String cookie = extractFaSessionCookie(doLogin(xsrf));
ResponseEntity<String> me = http.exchange(
baseUrl + "/api/users/me", HttpMethod.GET,
new HttpEntity<>(cookieHeaders(cookie)), String.class);
assertThat(me.getStatusCode().value()).isEqualTo(200);
}
@Test
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
String xsrf = fetchXsrfToken();
String sessionCookie = extractFaSessionCookie(doLogin(xsrf));
ResponseEntity<Void> logout = http.postForEntity(
baseUrl + "/api/auth/logout",
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(sessionCookie)), String.class);
assertThat(me.getStatusCode().value()).isEqualTo(401);
}
// ─── Task 14: idle-timeout ────────────────────────────────────────────────
@Test
void session_expired_by_idle_timeout_returns_401() {
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;
jdbcTemplate.update(
"UPDATE spring_session SET LAST_ACCESS_TIME = ?, EXPIRY_TIME = ?",
nineHoursAgoMs, nineHoursAgoMs);
ResponseEntity<String> me = http.exchange(
baseUrl + "/api/users/me", HttpMethod.GET,
new HttpEntity<>(cookieHeaders(cookie)), String.class);
assertThat(me.getStatusCode().value()).isEqualTo(401);
}
// ─── Task: CSRF rejection at integration layer ────────────────────────────
@Test
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
ResponseEntity<String> response = http.postForEntity(
baseUrl + "/api/auth/logout",
new HttpEntity<>("{}", headers), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(403);
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
}
// ─── helpers ─────────────────────────────────────────────────────────────
/**
* 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);
}
private HttpHeaders cookieHeaders(String sessionId) {
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId);
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 "";
return setCookieHeader.stream()
.filter(c -> c.startsWith("fa_session="))
.map(c -> c.split(";")[0].substring("fa_session=".length()))
.findFirst()
.orElse("");
}
private RestTemplate noThrowRestTemplate() {
RestTemplate template = new RestTemplate();
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

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

View File

@@ -1,52 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
import java.util.HashMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class JdbcSessionRevocationAdapterTest {
@Mock JdbcIndexedSessionRepository sessionRepository;
@InjectMocks JdbcSessionRevocationAdapter adapter;
@SuppressWarnings("unchecked")
@Test
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
var sessions = new HashMap<String, Object>();
sessions.put("session-keep", null);
sessions.put("session-del-1", null);
sessions.put("session-del-2", null);
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
int count = adapter.revokeOtherSessions("session-keep", "user@test.de");
assertThat(count).isEqualTo(2);
verify(sessionRepository, never()).deleteById("session-keep");
verify(sessionRepository).deleteById("session-del-1");
verify(sessionRepository).deleteById("session-del-2");
}
@SuppressWarnings("unchecked")
@Test
void revokeAllSessions_deletes_all_sessions_for_principal() {
var sessions = new HashMap<String, Object>();
sessions.put("session-1", null);
sessions.put("session-2", null);
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
int count = adapter.revokeAllSessions("user@test.de");
assertThat(count).isEqualTo(2);
verify(sessionRepository).deleteById("session-1");
verify(sessionRepository).deleteById("session-2");
}
}

View File

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

View File

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

View File

@@ -44,12 +44,10 @@ import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(DocumentController.class) @WebMvcTest(DocumentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -216,14 +214,14 @@ class DocumentControllerTest {
@Test @Test
void createDocument_returns401_whenUnauthenticated() throws Exception { void createDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents").with(csrf())) mockMvc.perform(multipart("/api/documents"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void createDocument_returns403_whenMissingWritePermission() throws Exception { void createDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents").with(csrf())) mockMvc.perform(multipart("/api/documents"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -237,7 +235,7 @@ class DocumentControllerTest {
.build(); .build();
when(documentService.createDocument(any(), any())).thenReturn(doc); when(documentService.createDocument(any(), any())).thenReturn(doc);
mockMvc.perform(multipart("/api/documents").with(csrf())) mockMvc.perform(multipart("/api/documents"))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -246,7 +244,7 @@ class DocumentControllerTest {
@Test @Test
void updateDocument_returns401_whenUnauthenticated() throws Exception { void updateDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .with(req -> { req.setMethod("PUT"); return req; }))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -254,7 +252,7 @@ class DocumentControllerTest {
@WithMockUser @WithMockUser
void updateDocument_returns403_whenMissingWritePermission() throws Exception { void updateDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID()) mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .with(req -> { req.setMethod("PUT"); return req; }))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -271,7 +269,7 @@ class DocumentControllerTest {
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc); when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
mockMvc.perform(multipart("/api/documents/" + id) mockMvc.perform(multipart("/api/documents/" + id)
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf())) .with(req -> { req.setMethod("PUT"); return req; }))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -280,7 +278,7 @@ class DocumentControllerTest {
@Test @Test
void deleteDocument_returns401_whenUnauthenticated() throws Exception { void deleteDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()).with(csrf())) .delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -288,7 +286,7 @@ class DocumentControllerTest {
@WithMockUser @WithMockUser
void deleteDocument_returns403_whenMissingWritePermission() throws Exception { void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()).with(csrf())) .delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -297,7 +295,7 @@ class DocumentControllerTest {
void deleteDocument_returns204_whenHasWritePermission() throws Exception { void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id).with(csrf())) .delete("/api/documents/" + id))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@@ -305,14 +303,14 @@ class DocumentControllerTest {
@Test @Test
void quickUpload_returns401_whenUnauthenticated() throws Exception { void quickUpload_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void quickUpload_returns403_whenMissingWritePermission() throws Exception { void quickUpload_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -328,7 +326,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("scan001")) .andExpect(jsonPath("$.created[0].title").value("scan001"))
.andExpect(jsonPath("$.updated").isEmpty()) .andExpect(jsonPath("$.updated").isEmpty())
@@ -347,7 +345,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1}); new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief")) .andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
@@ -362,7 +360,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("files", "report.docx", new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1}); "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.errors[0].filename").value("report.docx")) .andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
@@ -492,7 +490,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception { void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated").isEmpty()) .andExpect(jsonPath("$.updated").isEmpty())
@@ -642,7 +640,7 @@ class DocumentControllerTest {
@Test @Test
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception { void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -651,7 +649,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception { void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -661,7 +659,7 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception { void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf()) mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}")) .content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
@@ -673,7 +671,7 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception { void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf()) mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}")) .content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
@@ -684,7 +682,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception { void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}")) .content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -698,7 +696,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf())) mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -715,7 +713,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf())) mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id.toString())) .andExpect(jsonPath("$.id").value(id.toString()))
.andExpect(jsonPath("$.status").value("UPLOADED")); .andExpect(jsonPath("$.status").value("UPLOADED"));
@@ -728,7 +726,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile( new org.springframework.mock.web.MockMultipartFile(
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes()); "file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf())) mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@@ -745,7 +743,7 @@ class DocumentControllerTest {
org.springframework.mock.web.MockMultipartFile file = org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1}); new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf())) mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@@ -802,7 +800,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes()); ("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.created.length()").value(3)) .andExpect(jsonPath("$.created.length()").value(3))
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString())) .andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
@@ -829,7 +827,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes()); ("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty()) .andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString())) .andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
@@ -861,7 +859,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes()); "{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("Alpha")) .andExpect(jsonPath("$.created[0].title").value("Alpha"))
.andExpect(jsonPath("$.created[1].title").value("Beta")) .andExpect(jsonPath("$.created[1].title").value("Beta"))
@@ -885,7 +883,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes()); "{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@@ -906,7 +904,7 @@ class DocumentControllerTest {
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json", new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes()); "{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf())) mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk()); .andExpect(status().isOk());
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames()) org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
@@ -928,7 +926,7 @@ class DocumentControllerTest {
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1})); "files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
} }
mockMvc.perform(builder.with(csrf())) mockMvc.perform(builder)
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE")); .andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
} }
@@ -947,7 +945,7 @@ class DocumentControllerTest {
@Test @Test
void patchBulk_returns401_whenUnauthenticated() throws Exception { void patchBulk_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString()))) .content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -956,7 +954,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void patchBulk_returns403_forReadAllUser() throws Exception { void patchBulk_returns403_forReadAllUser() throws Exception {
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString()))) .content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -967,7 +965,7 @@ class DocumentControllerTest {
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception { void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"documentIds\":[]}")) .content("{\"documentIds\":[]}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -978,7 +976,7 @@ class DocumentControllerTest {
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception { void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build()); when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -992,7 +990,7 @@ class DocumentControllerTest {
String[] ids = new String[501]; String[] ids = new String[501];
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString(); for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids))) .content(bulkBody(ids)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -1011,7 +1009,7 @@ class DocumentControllerTest {
String tooLong = "x".repeat(256); String tooLong = "x".repeat(256);
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}"; String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -1027,7 +1025,7 @@ class DocumentControllerTest {
String[] ids = new String[500]; String[] ids = new String[500];
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString(); for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids))) .content(bulkBody(ids)))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1044,7 +1042,7 @@ class DocumentControllerTest {
// Same id sent three times — controller should dedupe and call the // Same id sent three times — controller should dedupe and call the
// service exactly once, returning updated=1, not 3. // service exactly once, returning updated=1, not 3.
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id.toString(), id.toString(), id.toString()))) .content(bulkBody(id.toString(), id.toString(), id.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1063,7 +1061,7 @@ class DocumentControllerTest {
when(documentService.applyBulkEditToDocument(any(), any(), any())) when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build()); .thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id1.toString(), id2.toString()))) .content(bulkBody(id1.toString(), id2.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1139,7 +1137,7 @@ class DocumentControllerTest {
void batchMetadata_returns401_whenUnauthenticated() throws Exception { void batchMetadata_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf())) .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -1148,7 +1146,7 @@ class DocumentControllerTest {
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception { void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf())) .content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -1157,7 +1155,7 @@ class DocumentControllerTest {
void batchMetadata_returns400_whenIdsEmpty() throws Exception { void batchMetadata_returns400_whenIdsEmpty() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[]}").with(csrf())) .content("{\"ids\":[]}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@@ -1174,7 +1172,7 @@ class DocumentControllerTest {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(sb.toString()).with(csrf())) .content(sb.toString()))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS")); .andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
} }
@@ -1189,7 +1187,7 @@ class DocumentControllerTest {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata") mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + id + "\"]}").with(csrf())) .content("{\"ids\":[\"" + id + "\"]}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString())) .andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Brief")) .andExpect(jsonPath("$[0].title").value("Brief"))
@@ -1210,7 +1208,7 @@ class DocumentControllerTest {
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
"evil\r\nFAKE LOG ENTRY: admin logged in")); "evil\r\nFAKE LOG ENTRY: admin logged in"));
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(badId.toString()))) .content(bulkBody(badId.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1234,7 +1232,7 @@ class DocumentControllerTest {
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound( .thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId)); org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
mockMvc.perform(patch("/api/documents/bulk").with(csrf()) mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(okId.toString(), badId.toString()))) .content(bulkBody(okId.toString(), badId.toString())))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -1339,16 +1337,4 @@ class DocumentControllerTest {
DocumentStatus.REVIEWED, DocumentStatus.REVIEWED,
org.raddatz.familienarchiv.tag.TagOperator.AND))); org.raddatz.familienarchiv.tag.TagOperator.AND)));
} }
// ─── CSRF protection ──────────────────────────────────────────────────────
@Test
@WithMockUser
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
mockMvc.perform(post("/api/documents")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
}
} }

View File

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

View File

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

View File

@@ -12,9 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate; import java.time.LocalDate;
@@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(PostgresContainerConfig.class) @Import(PostgresContainerConfig.class)
@Transactional @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class DocumentSearchPagedIntegrationTest { class DocumentSearchPagedIntegrationTest {
private static final int FIXTURE_SIZE = 120; private static final int FIXTURE_SIZE = 120;

View File

@@ -31,7 +31,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(AnnotationController.class) @WebMvcTest(AnnotationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -68,7 +67,7 @@ class AnnotationControllerTest {
@Test @Test
void createAnnotation_returns401_whenUnauthenticated() throws Exception { void createAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -77,7 +76,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -93,7 +92,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
@@ -102,7 +101,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception { void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@@ -116,7 +115,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -134,7 +133,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
@@ -144,28 +143,28 @@ class AnnotationControllerTest {
@Test @Test
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception { void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception { void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@@ -175,7 +174,7 @@ class AnnotationControllerTest {
@Test @Test
void patchAnnotation_returns401_whenUnauthenticated() throws Exception { void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -184,7 +183,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void patchAnnotation_returns403_withoutPermission() throws Exception { void patchAnnotation_returns403_withoutPermission() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -200,7 +199,7 @@ class AnnotationControllerTest {
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf()) mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -218,7 +217,7 @@ class AnnotationControllerTest {
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf()) mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -230,7 +229,7 @@ class AnnotationControllerTest {
when(annotationService.updateAnnotation(any(), any(), any())) when(annotationService.updateAnnotation(any(), any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found")); .thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON)) .content(PATCH_JSON))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -239,7 +238,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception { void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"x\":-0.1,\"y\":0.3}")) .content("{\"x\":-0.1,\"y\":0.3}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -248,7 +247,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception { void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"width\":0.005}")) .content("{\"width\":0.005}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -257,7 +256,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception { void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"height\":0.005}")) .content("{\"height\":0.005}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -266,7 +265,7 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns400_withXAboveMaximum() throws Exception { void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"x\":1.1}")) .content("{\"x\":1.1}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -277,7 +276,7 @@ class AnnotationControllerTest {
@Test @Test
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception { void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
// authentication == null → resolveUserId returns null // authentication == null → resolveUserId returns null
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -295,7 +294,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
@@ -313,7 +312,7 @@ class AnnotationControllerTest {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf()) mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON)) .content(ANNOTATION_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());

View File

@@ -27,7 +27,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(CommentController.class) @WebMvcTest(CommentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -71,7 +70,7 @@ class CommentControllerTest {
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.blockId").value(blockId.toString())); .andExpect(jsonPath("$.blockId").value(blockId.toString()));
@@ -80,7 +79,7 @@ class CommentControllerTest {
@Test @Test
void postBlockComment_returns401_whenUnauthenticated() throws Exception { void postBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID(); UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -89,7 +88,7 @@ class CommentControllerTest {
@WithMockUser @WithMockUser
void postBlockComment_returns403_whenMissingPermission() throws Exception { void postBlockComment_returns403_whenMissingPermission() throws Exception {
UUID blockId = UUID.randomUUID(); UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -102,7 +101,7 @@ class CommentControllerTest {
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -117,7 +116,7 @@ class CommentControllerTest {
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build(); .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf()) mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -128,7 +127,7 @@ class CommentControllerTest {
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception { void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID" mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
+ "/comments/" + COMMENT_ID + "/replies").with(csrf()) + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@@ -137,7 +136,7 @@ class CommentControllerTest {
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception { void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID(); UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies").with(csrf()) + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -152,7 +151,7 @@ class CommentControllerTest {
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies").with(csrf()) + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -167,7 +166,7 @@ class CommentControllerTest {
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies").with(csrf()) + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
} }
@@ -176,7 +175,7 @@ class CommentControllerTest {
@Test @Test
void editComment_returns401_whenUnauthenticated() throws Exception { void editComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -188,7 +187,7 @@ class CommentControllerTest {
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -200,7 +199,7 @@ class CommentControllerTest {
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()) mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -209,14 +208,14 @@ class CommentControllerTest {
@Test @Test
void deleteComment_returns401_whenUnauthenticated() throws Exception { void deleteComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())) mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception { void deleteComment_returns204_whenAuthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())) mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
} }

View File

@@ -28,7 +28,6 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(TranscriptionBlockController.class) @WebMvcTest(TranscriptionBlockController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -144,7 +143,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
void createBlock_returns401_whenUnauthenticated() throws Exception { void createBlock_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post(URL_BASE).with(csrf()) mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -153,7 +152,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception { void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(post(URL_BASE).with(csrf()) mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -165,7 +164,7 @@ class TranscriptionBlockControllerTest {
when(userService.findByEmail(any())).thenReturn(mockUser()); when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock()); when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
mockMvc.perform(post(URL_BASE).with(csrf()) mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -178,7 +177,7 @@ class TranscriptionBlockControllerTest {
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception { void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null); when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(post(URL_BASE).with(csrf()) mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON)) .content(CREATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -193,7 +192,7 @@ class TranscriptionBlockControllerTest {
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID() + "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
+ "\",\"displayName\":\"" + longName + "\"}]}"; + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(post(URL_BASE).with(csrf()) mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -207,7 +206,7 @@ class TranscriptionBlockControllerTest {
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\"," String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; + "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(post(URL_BASE).with(csrf()) mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -218,7 +217,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
void updateBlock_returns401_whenUnauthenticated() throws Exception { void updateBlock_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_BLOCK).with(csrf()) mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -227,7 +226,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception { void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_BLOCK).with(csrf()) mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -244,7 +243,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any())) when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
.thenReturn(updated); .thenReturn(updated);
mockMvc.perform(put(URL_BLOCK).with(csrf()) mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -260,7 +259,7 @@ class TranscriptionBlockControllerTest {
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\"" String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}"; + UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(put(URL_BLOCK).with(csrf()) mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -273,7 +272,7 @@ class TranscriptionBlockControllerTest {
when(userService.findByEmail(any())).thenReturn(mockUser()); when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}"; String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(put(URL_BLOCK).with(csrf()) mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -287,7 +286,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.updateBlock(any(), any(), any(), any())) when(transcriptionService.updateBlock(any(), any(), any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
mockMvc.perform(put(URL_BLOCK).with(csrf()) mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -298,7 +297,7 @@ class TranscriptionBlockControllerTest {
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception { void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null); when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_BLOCK).with(csrf()) mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON)) .content(UPDATE_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -308,28 +307,28 @@ class TranscriptionBlockControllerTest {
@Test @Test
void deleteBlock_returns401_whenUnauthenticated() throws Exception { void deleteBlock_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete(URL_BLOCK).with(csrf())) mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception { void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK).with(csrf())) mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception { void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK).with(csrf())) mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception { void deleteBlock_returns204_whenAuthorised() throws Exception {
mockMvc.perform(delete(URL_BLOCK).with(csrf())) mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@@ -340,7 +339,7 @@ class TranscriptionBlockControllerTest {
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")) DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
.when(transcriptionService).deleteBlock(any(), any()); .when(transcriptionService).deleteBlock(any(), any());
mockMvc.perform(delete(URL_BLOCK).with(csrf())) mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@@ -348,7 +347,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
void reorderBlocks_returns401_whenUnauthenticated() throws Exception { void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REORDER).with(csrf()) mockMvc.perform(put(URL_REORDER)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON)) .content(REORDER_JSON))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -357,7 +356,7 @@ class TranscriptionBlockControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception { void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REORDER).with(csrf()) mockMvc.perform(put(URL_REORDER)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON)) .content(REORDER_JSON))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -368,7 +367,7 @@ class TranscriptionBlockControllerTest {
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock())); when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
mockMvc.perform(put(URL_REORDER).with(csrf()) mockMvc.perform(put(URL_REORDER)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON)) .content(REORDER_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -435,7 +434,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed); when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review", mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
DOC_ID, BLOCK_ID).with(csrf())) DOC_ID, BLOCK_ID))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.reviewed").value(true)); .andExpect(jsonPath("$.reviewed").value(true));
} }
@@ -446,14 +445,14 @@ class TranscriptionBlockControllerTest {
@Test @Test
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception { void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception { void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -470,7 +469,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of(b1, b2)); .thenReturn(List.of(b1, b2));
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].reviewed").value(true)) .andExpect(jsonPath("$[0].reviewed").value(true))
@@ -484,7 +483,7 @@ class TranscriptionBlockControllerTest {
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any())) when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of()); .thenReturn(List.of());
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty()); .andExpect(jsonPath("$").isEmpty());
@@ -495,7 +494,7 @@ class TranscriptionBlockControllerTest {
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception { void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null); when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf())) mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
} }

View File

@@ -1,33 +0,0 @@
package org.raddatz.familienarchiv.exception;
import io.sentry.Sentry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
@InjectMocks
private GlobalExceptionHandler handler;
@Test
void handleGeneric_captures_exception_in_sentry_and_returns_500() {
RuntimeException ex = new RuntimeException("unexpected failure");
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
sentryMock.verify(() -> Sentry.captureException(ex));
assertThat(response.getStatusCode().value()).isEqualTo(500);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
}
}
}

View File

@@ -36,7 +36,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(GeschichteController.class) @WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -131,7 +130,7 @@ class GeschichteControllerTest {
@Test @Test
void create_returns401_whenUnauthenticated() throws Exception { void create_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten").with(csrf()) mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}")) .content("{\"title\":\"x\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -140,7 +139,7 @@ class GeschichteControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void create_returns403_whenLackingBlogWrite() throws Exception { void create_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten").with(csrf()) mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}")) .content("{\"title\":\"x\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -156,7 +155,7 @@ class GeschichteControllerTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New"); dto.setTitle("New");
mockMvc.perform(post("/api/geschichten").with(csrf()) mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto))) .content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -168,7 +167,7 @@ class GeschichteControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void update_returns403_whenLackingBlogWrite() throws Exception { void update_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()) mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -181,7 +180,7 @@ class GeschichteControllerTest {
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated")); .thenReturn(published(id, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf()) mockMvc.perform(patch("/api/geschichten/{id}", id)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}")) .content("{\"status\":\"PUBLISHED\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -193,7 +192,7 @@ class GeschichteControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void delete_returns403_whenLackingBlogWrite() throws Exception { void delete_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -202,7 +201,7 @@ class GeschichteControllerTest {
void delete_returns204_withBlogWrite() throws Exception { void delete_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf())) mockMvc.perform(delete("/api/geschichten/{id}", id))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
verify(geschichteService).delete(id); verify(geschichteService).delete(id);

View File

@@ -19,9 +19,9 @@ import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import java.util.List; import java.util.List;
@@ -32,7 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(PostgresContainerConfig.class) @Import(PostgresContainerConfig.class)
@Transactional @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class GeschichteServiceIntegrationTest { class GeschichteServiceIntegrationTest {
@MockitoBean @MockitoBean

View File

@@ -20,13 +20,7 @@ import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.xml.sax.SAXParseException;
import java.io.File; import java.io.File;
import java.io.OutputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.LocalDate; import java.time.LocalDate;
@@ -35,8 +29,6 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -58,7 +50,6 @@ class MassImportServiceTest {
void setUp() { void setUp() {
service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner); service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
ReflectionTestUtils.setField(service, "bucketName", "test-bucket"); ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
ReflectionTestUtils.setField(service, "importDir", "/import");
ReflectionTestUtils.setField(service, "colIndex", 0); ReflectionTestUtils.setField(service, "colIndex", 0);
ReflectionTestUtils.setField(service, "colBox", 1); ReflectionTestUtils.setField(service, "colBox", 1);
ReflectionTestUtils.setField(service, "colFolder", 2); ReflectionTestUtils.setField(service, "colFolder", 2);
@@ -78,64 +69,20 @@ class MassImportServiceTest {
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE); assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
} }
@Test
void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
}
// ─── runImportAsync ─────────────────────────────────────────────────────── // ─── runImportAsync ───────────────────────────────────────────────────────
@Test @Test
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() { void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
// /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL // /import directory doesn't exist in test environment → findSpreadsheetFile throws
service.runImportAsync(); service.runImportAsync();
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED); assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
}
@Test
void runImportAsync_readsFromConfiguredImportDir(@TempDir Path tempDir) {
// Empty temp dir → findSpreadsheetFile throws "no spreadsheet" with the
// configured path in the message. Proves the field, not a constant,
// drives the lookup.
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
service.runImportAsync();
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
assertThat(service.getStatus().message()).contains(tempDir.toString());
}
@Test
void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
service.runImportAsync();
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
}
@Test
void runImportAsync_setsStatusCode_IMPORT_DONE_whenSpreadsheetHasNoDataRows(@TempDir Path tempDir) throws Exception {
Path xlsx = tempDir.resolve("import.xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook()) {
wb.createSheet("Sheet1");
try (OutputStream out = Files.newOutputStream(xlsx)) {
wb.write(out);
}
}
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
service.runImportAsync();
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_DONE");
} }
@Test @Test
void runImportAsync_throwsConflict_whenAlreadyRunning() { void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus( MassImportService.ImportStatus running = new MassImportService.ImportStatus(
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now()); MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running); ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync()) assertThatThrownBy(() -> service.runImportAsync())
@@ -154,76 +101,9 @@ class MassImportServiceTest {
.build(); .build();
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing)); when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001"); service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentService, never()).save(any()); verify(documentService, never()).save(any());
assertThat(result).isPresent().contains("ALREADY_EXISTS");
}
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
@Test
void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception {
// Document already exists with status UPLOADED (not PLACEHOLDER).
// A physical PDF file is also present on disk (valid magic bytes).
// Expected: ALREADY_EXISTS is returned and no S3 upload is attempted —
// the guard fires before any file I/O, so no partial processing occurs.
Document existing = Document.builder()
.id(UUID.randomUUID())
.originalFilename("present.pdf")
.status(DocumentStatus.UPLOADED)
.build();
when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing));
Path physicalFile = tempDir.resolve("present.pdf");
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(physicalFile, pdfHeader);
Optional<String> result = service.importSingleDocument(
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
assertThat(result).isPresent().contains("ALREADY_EXISTS");
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentService, never()).save(any());
}
// ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────
@Test
void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
doThrow(new RuntimeException("S3 unavailable"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
}
@Test
void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
buildMinimalImportXlsx(tempDir, "existing.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
Document existing = Document.builder()
.id(UUID.randomUUID())
.originalFilename("existing.pdf")
.status(DocumentStatus.UPLOADED)
.build();
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly("ALREADY_EXISTS");
} }
// ─── importSingleDocument — create new document (metadata only) ─────────── // ─── importSingleDocument — create new document (metadata only) ───────────
@@ -275,7 +155,7 @@ class MassImportServiceTest {
} }
@Test @Test
void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception { void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("fail.pdf"); Path tempFile = tempDir.resolve("fail.pdf");
Files.write(tempFile, "data".getBytes()); Files.write(tempFile, "data".getBytes());
@@ -283,11 +163,10 @@ class MassImportServiceTest {
doThrow(new RuntimeException("S3 error")) doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
Optional<String> result = service.importSingleDocument( service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail"); minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentService, never()).save(any()); verify(documentService, never()).save(any());
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
} }
// ─── importSingleDocument — sender handling ─────────────────────────────── // ─── importSingleDocument — sender handling ───────────────────────────────
@@ -393,8 +272,8 @@ class MassImportServiceTest {
@Test @Test
void processRows_returnsZero_whenOnlyHeaderRow() { void processRows_returnsZero_whenOnlyHeaderRow() {
List<List<String>> rows = List.of(List.of("header", "col1")); List<List<String>> rows = List.of(List.of("header", "col1"));
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(0); assertThat(result).isEqualTo(0);
} }
@Test @Test
@@ -403,8 +282,8 @@ class MassImportServiceTest {
List.of("header"), List.of("header"),
minimalCells("") // blank index minimalCells("") // blank index
); );
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(0); assertThat(result).isEqualTo(0);
verify(documentService, never()).findByOriginalFilename(any()); verify(documentService, never()).findByOriginalFilename(any());
} }
@@ -417,9 +296,9 @@ class MassImportServiceTest {
List.of("header"), List.of("header"),
minimalCells("doc001") // no dot → appends ".pdf" minimalCells("doc001") // no dot → appends ".pdf"
); );
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1); assertThat(result).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc001.pdf"); verify(documentService).findByOriginalFilename("doc001.pdf");
} }
@@ -432,9 +311,9 @@ class MassImportServiceTest {
List.of("header"), List.of("header"),
minimalCells("doc002.pdf") // has dot → used as-is minimalCells("doc002.pdf") // has dot → used as-is
); );
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1); assertThat(result).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc002.pdf"); verify(documentService).findByOriginalFilename("doc002.pdf");
} }
@@ -593,86 +472,6 @@ class MassImportServiceTest {
assertThat(result).isEqualTo("hello"); assertThat(result).isEqualTo("hello");
} }
// ─── PDF magic byte validation regression ─────────────────────────────────
@Test
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
}
@Test
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename)
.contains("fake.pdf");
}
@Test
void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
buildMinimalImportXlsx(tempDir, "tiny.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
}
@Test
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
MassImportService spyService = spy(service);
doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
spyService.runImportAsync();
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
assertThat(spyService.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly("FILE_READ_ERROR");
}
// ─── readOds — XXE security regression ───────────────────────────────────
// Security regression — do not remove.
@Test
void readOds_rejects_xxe_doctype_payload(@TempDir Path tempDir) throws Exception {
File malicious = buildXxeOds(tempDir, "file:///etc/hostname");
assertThatThrownBy(() -> service.readOds(malicious))
.isInstanceOf(SAXParseException.class)
.hasMessageContaining("DOCTYPE is disallowed");
}
@Test
void readOds_parses_valid_ods_correctly(@TempDir Path tempDir) throws Exception {
File valid = buildValidOds(tempDir, "Mustermann");
List<List<String>> rows = service.readOds(valid);
assertThat(rows).isNotEmpty();
assertThat(rows.get(0)).contains("Mustermann");
}
// ─── helpers ────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────
/** /**
@@ -706,72 +505,4 @@ class MassImportServiceTest {
"" // 13: transcription "" // 13: transcription
); );
} }
/** Creates a minimal ODS ZIP containing a content.xml with an XXE payload. */
private File buildXxeOds(Path dir, String entityTarget) throws Exception {
String xml = "<?xml version=\"1.0\"?>"
+ "<!DOCTYPE foo [<!ENTITY xxe SYSTEM \"" + entityTarget + "\">]>"
+ "<office:document-content"
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
+ "<office:body><office:spreadsheet>"
+ "<table:table><table:table-row><table:table-cell>"
+ "<text:p>&xxe;</text:p>"
+ "</table:table-cell></table:table-row></table:table>"
+ "</office:spreadsheet></office:body>"
+ "</office:document-content>";
return writeOdsZip(dir.resolve("malicious.ods"), xml);
}
/** Creates a minimal valid ODS ZIP containing a content.xml with the given cell value.
* cellValue must not contain XML metacharacters ({@code < > &}). */
private File buildValidOds(Path dir, String cellValue) throws Exception {
String xml = "<?xml version=\"1.0\"?>"
+ "<office:document-content"
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
+ "<office:body><office:spreadsheet>"
+ "<table:table><table:table-row><table:table-cell>"
+ "<text:p>" + cellValue + "</text:p>"
+ "</table:table-cell></table:table-row></table:table>"
+ "</office:spreadsheet></office:body>"
+ "</office:document-content>";
return writeOdsZip(dir.resolve("valid.ods"), xml);
}
private File writeOdsZip(Path destination, String contentXml) throws Exception {
try (OutputStream fos = Files.newOutputStream(destination);
ZipOutputStream zip = new ZipOutputStream(fos)) {
zip.putNextEntry(new ZipEntry("content.xml"));
zip.write(contentXml.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
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);
}
}
}
} }

View File

@@ -35,7 +35,6 @@ import static org.mockito.Mockito.when;
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(NotificationController.class) @WebMvcTest(NotificationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -142,7 +141,7 @@ class NotificationControllerTest {
@Test @Test
void markAllRead_returns401_whenUnauthenticated() throws Exception { void markAllRead_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/notifications/read-all").with(csrf())) mockMvc.perform(post("/api/notifications/read-all"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -152,7 +151,7 @@ class NotificationControllerTest {
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build(); AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user); when(userService.findByEmail("testuser")).thenReturn(user);
mockMvc.perform(post("/api/notifications/read-all").with(csrf())) mockMvc.perform(post("/api/notifications/read-all"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
verify(notificationService).markAllRead(USER_ID); verify(notificationService).markAllRead(USER_ID);
@@ -162,7 +161,7 @@ class NotificationControllerTest {
@Test @Test
void markOneRead_returns401_whenUnauthenticated() throws Exception { void markOneRead_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf())) mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@@ -177,7 +176,7 @@ class NotificationControllerTest {
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours")) org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
.when(notificationService).markRead(notifId, USER_ID); .when(notificationService).markRead(notifId, USER_ID);
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf())) mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -257,7 +256,7 @@ class NotificationControllerTest {
.notifyOnReply(true).notifyOnMention(true).build(); .notifyOnReply(true).notifyOnMention(true).build();
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated); when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf()) mockMvc.perform(put("/api/users/me/notification-preferences")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}")) .content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -276,7 +275,7 @@ class NotificationControllerTest {
.notifyOnReply(true).notifyOnMention(false).build(); .notifyOnReply(true).notifyOnMention(false).build();
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated); when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf()) mockMvc.perform(put("/api/users/me/notification-preferences")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}")) .content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -338,7 +337,7 @@ class NotificationControllerTest {
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId)) doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
.when(notificationService).markRead(notifId, USER_ID); .when(notificationService).markRead(notifId, USER_ID);
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf())) mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
} }

View File

@@ -39,7 +39,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(OcrController.class) @WebMvcTest(OcrController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -67,7 +66,7 @@ class OcrControllerTest {
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId); when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf()) mockMvc.perform(post("/api/documents/{id}/ocr", docId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto))) .content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -81,7 +80,7 @@ class OcrControllerTest {
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean())) when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded")); .thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf()) mockMvc.perform(post("/api/documents/{id}/ocr", docId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -128,7 +127,7 @@ class OcrControllerTest {
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId); when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
mockMvc.perform(post("/api/ocr/batch").with(csrf()) mockMvc.perform(post("/api/ocr/batch")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto))) .content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -180,14 +179,14 @@ class OcrControllerTest {
@Test @Test
void triggerTraining_returns401_whenUnauthenticated() throws Exception { void triggerTraining_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/ocr/train").with(csrf())) mockMvc.perform(post("/api/ocr/train"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void triggerTraining_returns403_whenNotAdmin() throws Exception { void triggerTraining_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/ocr/train").with(csrf())) mockMvc.perform(post("/api/ocr/train"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -197,7 +196,7 @@ class OcrControllerTest {
when(ocrTrainingService.triggerTraining(any())) when(ocrTrainingService.triggerTraining(any()))
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running")); .thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
mockMvc.perform(post("/api/ocr/train").with(csrf())) mockMvc.perform(post("/api/ocr/train"))
.andExpect(status().isConflict()); .andExpect(status().isConflict());
} }
@@ -210,7 +209,7 @@ class OcrControllerTest {
.blockCount(10).documentCount(3).modelName("german_kurrent").build(); .blockCount(10).documentCount(3).modelName("german_kurrent").build();
when(ocrTrainingService.triggerTraining(any())).thenReturn(run); when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
mockMvc.perform(post("/api/ocr/train").with(csrf())) mockMvc.perform(post("/api/ocr/train"))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("DONE")) .andExpect(jsonPath("$.status").value("DONE"))
.andExpect(jsonPath("$.blockCount").value(10)); .andExpect(jsonPath("$.blockCount").value(10));
@@ -366,7 +365,7 @@ class OcrControllerTest {
@Test @Test
@WithMockUser(authorities = "ADMIN") @WithMockUser(authorities = "ADMIN")
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception { void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) mockMvc.perform(post("/api/ocr/train-sender")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":null}")) .content("{\"personId\":null}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -374,7 +373,7 @@ class OcrControllerTest {
@Test @Test
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception { void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) mockMvc.perform(post("/api/ocr/train-sender")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -383,7 +382,7 @@ class OcrControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception { void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) mockMvc.perform(post("/api/ocr/train-sender")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -396,7 +395,7 @@ class OcrControllerTest {
when(senderModelService.triggerManualSenderTraining(unknownId)) when(senderModelService.triggerManualSenderTraining(unknownId))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found")); .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) mockMvc.perform(post("/api/ocr/train-sender")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + unknownId + "\"}")) .content("{\"personId\":\"" + unknownId + "\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -411,7 +410,7 @@ class OcrControllerTest {
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) mockMvc.perform(post("/api/ocr/train-sender")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + personId + "\"}")) .content("{\"personId\":\"" + personId + "\"}"))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -427,7 +426,7 @@ class OcrControllerTest {
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) mockMvc.perform(post("/api/ocr/train-sender")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + personId + "\"}")) .content("{\"personId\":\"" + personId + "\"}"))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
@@ -443,7 +442,7 @@ class OcrControllerTest {
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build(); .personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run); when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
mockMvc.perform(post("/api/ocr/train-sender").with(csrf()) mockMvc.perform(post("/api/ocr/train-sender")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"personId\":\"" + personId + "\"}")) .content("{\"personId\":\"" + personId + "\"}"))
.andExpect(status().isAccepted()); .andExpect(status().isAccepted());

View File

@@ -36,7 +36,6 @@ import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(PersonController.class) @WebMvcTest(PersonController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -218,7 +217,7 @@ class PersonControllerTest {
@Test @Test
void createPerson_returns401_whenUnauthenticated() throws Exception { void createPerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -227,7 +226,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception { void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -236,7 +235,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -245,7 +244,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsMissing() throws Exception { void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -254,7 +253,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsBlank() throws Exception { void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -266,7 +265,7 @@ class PersonControllerTest {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -279,7 +278,7 @@ class PersonControllerTest {
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build(); Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}")) .content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -294,7 +293,7 @@ class PersonControllerTest {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(captor.capture())).thenReturn(saved); when(personService.createPerson(captor.capture())).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -308,7 +307,7 @@ class PersonControllerTest {
when(personService.createPerson(any())).thenThrow( when(personService.createPerson(any())).thenThrow(
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type")); DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}")) .content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -319,7 +318,7 @@ class PersonControllerTest {
@Test @Test
void updatePerson_returns401_whenUnauthenticated() throws Exception { void updatePerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -328,7 +327,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -337,7 +336,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsNull() throws Exception { void updatePerson_returns400_whenLastNameIsNull() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -350,7 +349,7 @@ class PersonControllerTest {
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build(); Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
when(personService.updatePerson(eq(id), any())).thenReturn(updated); when(personService.updatePerson(eq(id), any())).thenReturn(updated);
mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -361,7 +360,7 @@ class PersonControllerTest {
@Test @Test
void mergePerson_returns401_whenUnauthenticated() throws Exception { void mergePerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -370,7 +369,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception { void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -379,7 +378,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception { void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\" \"}")) .content("{\"targetPersonId\":\" \"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -391,7 +390,7 @@ class PersonControllerTest {
UUID sourceId = UUID.randomUUID(); UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID(); UUID targetId = UUID.randomUUID();
mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf()) mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + targetId + "\"}")) .content("{\"targetPersonId\":\"" + targetId + "\"}"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
@@ -403,7 +402,7 @@ class PersonControllerTest {
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsBlank() throws Exception { void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -419,7 +418,7 @@ class PersonControllerTest {
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build(); .alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved); when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + "\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
@@ -437,7 +436,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception { void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
String oversizedNotes = "x".repeat(5001); String oversizedNotes = "x".repeat(5001);
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -448,7 +447,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception { void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
String oversizedFirstName = "x".repeat(101); String oversizedFirstName = "x".repeat(101);
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf()) mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -459,7 +458,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception { void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons").with(csrf()) mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -468,7 +467,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -477,7 +476,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf()) mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -508,7 +507,7 @@ class PersonControllerTest {
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build(); .id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
when(personService.addAlias(eq(personId), any())).thenReturn(saved); when(personService.addAlias(eq(personId), any())).thenReturn(saved);
mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf()) mockMvc.perform(post("/api/persons/{id}/aliases", personId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -518,7 +517,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void addAlias_returns403_withoutWritePermission() throws Exception { void addAlias_returns403_withoutWritePermission() throws Exception {
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}")) .content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -532,7 +531,7 @@ class PersonControllerTest {
UUID personId = UUID.randomUUID(); UUID personId = UUID.randomUUID();
UUID aliasId = UUID.randomUUID(); UUID aliasId = UUID.randomUUID();
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf())) mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
verify(personService).removeAlias(personId, aliasId); verify(personService).removeAlias(personId, aliasId);
@@ -541,14 +540,14 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void removeAlias_returns403_withoutWritePermission() throws Exception { void removeAlias_returns403_withoutWritePermission() throws Exception {
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void addAlias_returns400_whenLastNameIsBlank() throws Exception { void addAlias_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}")) .content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -557,7 +556,7 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void addAlias_returns400_whenTypeIsNull() throws Exception { void addAlias_returns400_whenTypeIsNull() throws Exception {
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf()) mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"de Gruyter\"}")) .content("{\"lastName\":\"de Gruyter\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());

View File

@@ -8,9 +8,9 @@ import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(PostgresContainerConfig.class) @Import(PostgresContainerConfig.class)
@Transactional @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class PersonServiceIntegrationTest { class PersonServiceIntegrationTest {
@MockitoBean S3Client s3Client; @MockitoBean S3Client s3Client;

View File

@@ -28,7 +28,6 @@ import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(RelationshipController.class) @WebMvcTest(RelationshipController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -68,7 +67,7 @@ class RelationshipControllerTest {
@Test @Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"}) @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception { void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -77,14 +76,14 @@ class RelationshipControllerTest {
@Test @Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"}) @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception { void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"}) @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception { void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf()) mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"familyMember\":true}")) .content("{\"familyMember\":true}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -126,7 +125,7 @@ class RelationshipControllerTest {
@Test @Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception { void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}")) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -142,7 +141,7 @@ class RelationshipControllerTest {
RelationType.PARENT_OF, null, null, null); RelationType.PARENT_OF, null, null, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created); when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf()) mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}")) .content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -155,7 +154,7 @@ class RelationshipControllerTest {
UUID relId = UUID.randomUUID(); UUID relId = UUID.randomUUID();
doNothing().when(relationshipService).deleteRelationship(any(), any()); doNothing().when(relationshipService).deleteRelationship(any(), any());
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())) mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
} }

View File

@@ -0,0 +1,134 @@
package org.raddatz.familienarchiv.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* The filter must turn a browser-side {@code Cookie: auth_token=Basic%20<base64>}
* into {@code Authorization: Basic <base64>} (URL-decoded) so that Spring's
* Basic-auth filter accepts it. Skips when the request already has an explicit
* {@code Authorization} header, or when no {@code auth_token} cookie is present.
*
* <p>See #520.
*/
class AuthTokenCookieFilterTest {
private final AuthTokenCookieFilter filter = new AuthTokenCookieFilter();
@Test
void promotes_url_encoded_auth_token_cookie_to_decoded_Authorization_header() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/users/me");
req.setCookies(new Cookie("auth_token", "Basic%20YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ%3D"));
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(req, res, chain);
ArgumentCaptor<HttpServletRequest> captor = ArgumentCaptor.forClass(HttpServletRequest.class);
verify(chain, times(1)).doFilter(captor.capture(), org.mockito.ArgumentMatchers.any(HttpServletResponse.class));
HttpServletRequest forwarded = captor.getValue();
assertThat(forwarded.getHeader("Authorization"))
.as("Authorization must be URL-decoded so Spring's Basic parser sees a literal space")
.isEqualTo("Basic YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ=");
}
@Test
void preserves_explicit_Authorization_header_and_ignores_cookie() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/users/me");
req.addHeader("Authorization", "Basic explicit-header-wins");
req.setCookies(new Cookie("auth_token", "Basic%20cookie-would-have-promoted"));
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(req, res, chain);
// Forwards the original request unchanged — same instance, no wrapping.
verify(chain).doFilter(req, res);
}
@Test
void passes_through_when_no_cookies_at_all() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/users/me");
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(req, res, chain);
verify(chain).doFilter(req, res);
}
@Test
void passes_through_when_auth_token_cookie_is_absent() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/users/me");
req.setCookies(new Cookie("some_other_cookie", "value"));
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(req, res, chain);
verify(chain).doFilter(req, res);
}
@Test
void passes_through_when_auth_token_cookie_is_empty() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/users/me");
req.setCookies(new Cookie("auth_token", ""));
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(req, res, chain);
verify(chain).doFilter(req, res);
}
@Test
void passes_through_unchanged_when_request_is_outside_api_scope() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
// /actuator/health and similar must NOT receive a promoted Authorization
// header — they have their own access rules and should never be authed
// via the cookie.
req.setRequestURI("/actuator/health");
req.setCookies(new Cookie("auth_token", "Basic%20YWR=="));
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(req, res, chain);
// Forwards the original request unchanged — same instance, no wrapping.
verify(chain).doFilter(req, res);
}
@Test
void passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/users/me");
// Lone "%" without two hex digits → URLDecoder throws → filter must
// refuse to forward a bogus Authorization header.
req.setCookies(new Cookie("auth_token", "Basic%2"));
MockHttpServletResponse res = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(req, res, chain);
// Forwards the original request unchanged — Spring Security treats it
// as unauthenticated rather than crashing on bad input.
verify(chain).doFilter(req, res);
}
}

View File

@@ -29,7 +29,6 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(TagController.class) @WebMvcTest(TagController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -62,7 +61,7 @@ class TagControllerTest {
@Test @Test
void updateTag_returns401_whenUnauthenticated() throws Exception { void updateTag_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}")) .content("{\"name\": \"New\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -71,7 +70,7 @@ class TagControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception { void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}")) .content("{\"name\": \"New\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -83,7 +82,7 @@ class TagControllerTest {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build(); Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
when(tagService.update(any(), any())).thenReturn(tag); when(tagService.update(any(), any())).thenReturn(tag);
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}")) .content("{\"name\": \"New\"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -117,7 +116,7 @@ class TagControllerTest {
@Test @Test
void mergeTag_returns401_whenUnauthenticated() throws Exception { void mergeTag_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -126,7 +125,7 @@ class TagControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception { void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -135,7 +134,7 @@ class TagControllerTest {
@Test @Test
@WithMockUser(authorities = "ADMIN_TAG") @WithMockUser(authorities = "ADMIN_TAG")
void mergeTag_returns400_whenTargetIdIsNull() throws Exception { void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -147,7 +146,7 @@ class TagControllerTest {
when(tagService.mergeTags(any(), any())) when(tagService.mergeTags(any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found")); .thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -160,7 +159,7 @@ class TagControllerTest {
Tag target = Tag.builder().id(targetId).name("Target").build(); Tag target = Tag.builder().id(targetId).name("Target").build();
when(tagService.mergeTags(any(), any())).thenReturn(target); when(tagService.mergeTags(any(), any())).thenReturn(target);
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf()) mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"targetId\": \"" + targetId + "\"}")) .content("{\"targetId\": \"" + targetId + "\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -172,21 +171,21 @@ class TagControllerTest {
@Test @Test
void deleteSubtree_returns401_whenUnauthenticated() throws Exception { void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception { void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "ADMIN_TAG") @WithMockUser(authorities = "ADMIN_TAG")
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception { void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf())) mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
} }
@@ -194,21 +193,21 @@ class TagControllerTest {
@Test @Test
void deleteTag_returns401_whenUnauthenticated() throws Exception { void deleteTag_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser @WithMockUser
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception { void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(authorities = "ADMIN_TAG") @WithMockUser(authorities = "ADMIN_TAG")
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception { void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
} }

View File

@@ -27,7 +27,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(AdminController.class) @WebMvcTest(AdminController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -41,57 +40,16 @@ class AdminControllerTest {
@MockitoBean ThumbnailBackfillService thumbnailBackfillService; @MockitoBean ThumbnailBackfillService thumbnailBackfillService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/admin/import-status ─────────────────────────────────────────
@Test
@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, List.of(), null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.state").value("IDLE"))
.andExpect(jsonPath("$.statusCode").value("IMPORT_IDLE"))
.andExpect(jsonPath("$.processed").value(0));
}
@Test
@WithMockUser(authorities = "ADMIN")
void importStatus_messageField_notPresentInApiResponse() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").doesNotExist());
}
@Test
void importStatus_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void importStatus_returns403_whenUserLacksAdminPermission() throws Exception {
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isForbidden());
}
@Test @Test
void backfillVersions_returns401_whenUnauthenticated() throws Exception { void backfillVersions_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) mockMvc.perform(post("/api/admin/backfill-versions"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(roles = "USER") @WithMockUser(roles = "USER")
void backfillVersions_returns403_whenNotAdmin() throws Exception { void backfillVersions_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) mockMvc.perform(post("/api/admin/backfill-versions"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -101,7 +59,7 @@ class AdminControllerTest {
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build())); when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1); when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf())) mockMvc.perform(post("/api/admin/backfill-versions"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(1)); .andExpect(jsonPath("$.count").value(1));
} }
@@ -110,14 +68,14 @@ class AdminControllerTest {
@Test @Test
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception { void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(roles = "USER") @WithMockUser(roles = "USER")
void backfillFileHashes_returns403_whenNotAdmin() throws Exception { void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -126,7 +84,7 @@ class AdminControllerTest {
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception { void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
when(documentService.backfillFileHashes()).thenReturn(3); when(documentService.backfillFileHashes()).thenReturn(3);
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf())) mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3)); .andExpect(jsonPath("$.count").value(3));
} }
@@ -135,14 +93,14 @@ class AdminControllerTest {
@Test @Test
void generateThumbnails_returns401_whenUnauthenticated() throws Exception { void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(roles = "USER") @WithMockUser(roles = "USER")
void generateThumbnails_returns403_whenNotAdmin() throws Exception { void generateThumbnails_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -153,7 +111,7 @@ class AdminControllerTest {
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now()); ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
when(thumbnailBackfillService.getStatus()).thenReturn(status); when(thumbnailBackfillService.getStatus()).thenReturn(status);
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf())) mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
.andExpect(jsonPath("$.state").value("RUNNING")) .andExpect(jsonPath("$.state").value("RUNNING"))
.andExpect(jsonPath("$.total").value(10)); .andExpect(jsonPath("$.total").value(10));

View File

@@ -30,7 +30,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(AuthController.class) @WebMvcTest(AuthController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -118,7 +117,7 @@ class AuthControllerTest {
req.setFirstName("Max"); req.setFirstName("Max");
req.setLastName("Muster"); req.setLastName("Muster");
mockMvc.perform(post("/api/auth/register").with(csrf()) mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -135,7 +134,7 @@ class AuthControllerTest {
req.setEmail("dupe@test.com"); req.setEmail("dupe@test.com");
req.setPassword("password123"); req.setPassword("password123");
mockMvc.perform(post("/api/auth/register").with(csrf()) mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isConflict()); .andExpect(status().isConflict());
@@ -151,7 +150,7 @@ class AuthControllerTest {
req.setEmail("new@test.com"); req.setEmail("new@test.com");
req.setPassword("abc"); req.setPassword("abc");
mockMvc.perform(post("/api/auth/register").with(csrf()) mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -167,7 +166,7 @@ class AuthControllerTest {
req.setEmail("new@test.com"); req.setEmail("new@test.com");
req.setPassword("password123"); req.setPassword("password123");
mockMvc.perform(post("/api/auth/register").with(csrf()) mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
@@ -184,7 +183,7 @@ class AuthControllerTest {
req.setPassword("password123"); req.setPassword("password123");
// No WithMockUser — must still succeed (no auth challenge) // No WithMockUser — must still succeed (no auth challenge)
mockMvc.perform(post("/api/auth/register").with(csrf()) mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated()); .andExpect(status().isCreated());

View File

@@ -20,20 +20,16 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(InviteController.class) @WebMvcTest(InviteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -104,7 +100,7 @@ class InviteControllerTest {
@Test @Test
void createInvite_returns401_whenUnauthenticated() throws Exception { void createInvite_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/invites").with(csrf()) mockMvc.perform(post("/api/invites")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -113,7 +109,7 @@ class InviteControllerTest {
@Test @Test
@WithMockUser(username = "user@test.com") @WithMockUser(username = "user@test.com")
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/invites").with(csrf()) mockMvc.perform(post("/api/invites")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -143,7 +139,7 @@ class InviteControllerTest {
req.setLabel("Für Familie"); req.setLabel("Für Familie");
req.setMaxUses(1); req.setMaxUses(1);
mockMvc.perform(post("/api/invites").with(csrf()) mockMvc.perform(post("/api/invites")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))) .content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
@@ -151,42 +147,18 @@ class InviteControllerTest {
.andExpect(jsonPath("$.label").value("Für Familie")); .andExpect(jsonPath("$.label").value("Für Familie"));
} }
@Test
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
void createInvite_forwardsGroupIdsToService() throws Exception {
UUID groupId = UUID.randomUUID();
AppUser admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
when(userService.findByEmail("admin@test.com")).thenReturn(admin);
InviteToken savedToken = InviteToken.builder()
.id(UUID.randomUUID()).code("ABCDE12345").useCount(0).build();
when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken);
when(inviteService.toListItemDTO(any(), anyString()))
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
mockMvc.perform(post("/api/invites").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated());
ArgumentCaptor<CreateInviteRequest> captor = ArgumentCaptor.forClass(CreateInviteRequest.class);
verify(inviteService).createInvite(captor.capture(), eq(admin));
assertThat(captor.getValue().getGroupIds()).containsExactly(groupId);
}
// ─── DELETE /api/invites/{id} ───────────────────────────────────────────── // ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
@Test @Test
void revokeInvite_returns401_whenUnauthenticated() throws Exception { void revokeInvite_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(username = "user@test.com") @WithMockUser(username = "user@test.com")
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -195,7 +167,7 @@ class InviteControllerTest {
void revokeInvite_returns204_whenSuccessful() throws Exception { void revokeInvite_returns204_whenSuccessful() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/invites/" + id).with(csrf())) mockMvc.perform(delete("/api/invites/" + id))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
verify(inviteService).revokeInvite(id); verify(inviteService).revokeInvite(id);

View File

@@ -156,35 +156,6 @@ class InviteServiceTest {
assertThat(result.getGroupIds()).contains(g.getId()); assertThat(result.getGroupIds()).contains(g.getId());
} }
@Test
void createInvite_throwsGroupNotFound_whenSubmittedGroupIdDoesNotExist() {
UUID unknownGroupId = UUID.randomUUID();
when(userService.findGroupsByIds(anyList())).thenReturn(List.of());
CreateInviteRequest req = new CreateInviteRequest();
req.setGroupIds(List.of(unknownGroupId));
assertThatThrownBy(() -> inviteService.createInvite(req, admin))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.GROUP_NOT_FOUND);
}
@Test
void createInvite_doesNotThrowGroupNotFound_whenDuplicateGroupIdsSubmitted() {
UUID groupId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(groupId).name("Familie").build();
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
when(userService.findGroupsByIds(anyList())).thenReturn(List.of(group));
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
CreateInviteRequest req = new CreateInviteRequest();
req.setGroupIds(List.of(groupId, groupId)); // same UUID submitted twice
// before deduplication: size(groups)==1 != size(submitted)==2 → false GROUP_NOT_FOUND
assertThatCode(() -> inviteService.createInvite(req, admin)).doesNotThrowAnyException();
}
// ─── redeemInvite ───────────────────────────────────────────────────────── // ─── redeemInvite ─────────────────────────────────────────────────────────
@Test @Test

View File

@@ -1,78 +0,0 @@
package org.raddatz.familienarchiv.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class InviteTokenRepositoryIntegrationTest {
@Autowired InviteTokenRepository inviteTokenRepository;
@Autowired UserGroupRepository userGroupRepository;
@Autowired AppUserRepository appUserRepository;
private UserGroup group;
private AppUser admin;
@BeforeEach
void setUp() {
inviteTokenRepository.deleteAll();
userGroupRepository.deleteAll();
appUserRepository.deleteAll();
admin = appUserRepository.save(AppUser.builder().email("admin@test.com").password("pw").build());
group = userGroupRepository.save(UserGroup.builder().name("Familie").build());
}
// ─── existsActiveWithGroupId ──────────────────────────────────────────────
@Test
void existsActiveWithGroupId_returnsTrueForActiveInviteLinkedToGroup() {
inviteTokenRepository.save(token(t -> t));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isTrue();
}
@Test
void existsActiveWithGroupId_returnsFalseWhenInviteIsRevoked() {
inviteTokenRepository.save(token(t -> t.revoked(true)));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
}
@Test
void existsActiveWithGroupId_returnsFalseWhenInviteIsExpired() {
inviteTokenRepository.save(token(t -> t.expiresAt(LocalDateTime.now().minusDays(1))));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
}
@Test
void existsActiveWithGroupId_returnsFalseWhenInviteIsExhausted() {
inviteTokenRepository.save(token(t -> t.maxUses(1).useCount(1)));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
}
// ─── helpers ─────────────────────────────────────────────────────────────
private InviteToken token(java.util.function.UnaryOperator<InviteToken.InviteTokenBuilder> customizer) {
InviteToken.InviteTokenBuilder builder = InviteToken.builder()
.code(UUID.randomUUID().toString().replace("-", "").substring(0, 10))
.groupIds(new java.util.HashSet<>(Set.of(group.getId())))
.createdBy(admin);
return customizer.apply(builder).build();
}
}

View File

@@ -27,7 +27,6 @@ import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.raddatz.familienarchiv.auth.AuthService;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -37,10 +36,8 @@ class PasswordResetServiceTest {
@Mock PasswordResetTokenRepository tokenRepository; @Mock PasswordResetTokenRepository tokenRepository;
@Mock PasswordEncoder passwordEncoder; @Mock PasswordEncoder passwordEncoder;
@Mock JavaMailSender mailSender; @Mock JavaMailSender mailSender;
@Mock AuthService authService;
@InjectMocks PasswordResetService service; @InjectMocks PasswordResetService service;
private AppUser makeUser(String email) { private AppUser makeUser(String email) {
return AppUser.builder() return AppUser.builder()
.id(UUID.randomUUID()) .id(UUID.randomUUID())
@@ -179,27 +176,6 @@ class PasswordResetServiceTest {
verify(mailSender).send(any(SimpleMailMessage.class)); verify(mailSender).send(any(SimpleMailMessage.class));
} }
@Test
void resetPassword_revokes_all_sessions_after_password_reset() {
AppUser user = makeUser("user@example.com");
PasswordResetToken token = PasswordResetToken.builder()
.id(UUID.randomUUID())
.token("validtoken123")
.user(user)
.expiresAt(LocalDateTime.now().plusHours(1))
.used(false)
.build();
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
when(passwordEncoder.encode(any())).thenReturn("hashed");
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("validtoken123");
req.setNewPassword("newpass");
service.resetPassword(req);
verify(authService).revokeAllSessions("user@example.com");
}
// ─── cleanupExpiredTokens ───────────────────────────────────────────────── // ─── cleanupExpiredTokens ─────────────────────────────────────────────────
@Test @Test

View File

@@ -1,8 +1,6 @@
package org.raddatz.familienarchiv.user; package org.raddatz.familienarchiv.user;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.auth.AuthService;
import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
@@ -12,7 +10,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -20,8 +17,6 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID; import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -29,7 +24,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(UserController.class) @WebMvcTest(UserController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -38,8 +32,6 @@ class UserControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockitoBean UserService userService; @MockitoBean UserService userService;
@MockitoBean AuthService authService;
@MockitoBean AuditService auditService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/users/me ──────────────────────────────────────────────────────── // ─── GET /api/users/me ────────────────────────────────────────────────────────
@@ -91,7 +83,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception { void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
mockMvc.perform(post("/api/users").with(csrf()) mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -100,7 +92,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailContainsColon() throws Exception { void createUser_returns400_whenEmailContainsColon() throws Exception {
mockMvc.perform(post("/api/users").with(csrf()) mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -109,7 +101,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailIsBlank() throws Exception { void createUser_returns400_whenEmailIsBlank() throws Exception {
mockMvc.perform(post("/api/users").with(csrf()) mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -120,7 +112,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "reader@example.com") @WithMockUser(username = "reader@example.com")
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/users").with(csrf()) mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -129,7 +121,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "reader@example.com") @WithMockUser(username = "reader@example.com")
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
@@ -138,7 +130,7 @@ class UserControllerTest {
@Test @Test
@WithMockUser(username = "reader@example.com") @WithMockUser(username = "reader@example.com")
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@@ -146,7 +138,7 @@ class UserControllerTest {
@Test @Test
void createUser_returns401_whenUnauthenticated() throws Exception { void createUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/users").with(csrf()) mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -154,7 +146,7 @@ class UserControllerTest {
@Test @Test
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception { void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf()) mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
@@ -162,92 +154,7 @@ class UserControllerTest {
@Test @Test
void deleteUser_returns401_whenUnauthenticated() throws Exception { void deleteUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf())) mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
// ─── POST /api/users/me/password (changePassword + session revocation) ────
@Test
@WithMockUser(username = "user@example.com")
void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build();
when(userService.findByEmail("user@example.com")).thenReturn(user);
when(authService.revokeOtherSessions(any(), any())).thenReturn(1);
mockMvc.perform(post("/api/users/me/password").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
.andExpect(status().isNoContent());
verify(authService).revokeOtherSessions(any(), eq("user@example.com"));
}
@Test
void changePassword_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/users/me/password").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "user@example.com")
void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
mockMvc.perform(post("/api/users/me/password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
}
// ─── POST /api/users/{id}/force-logout ────────────────────────────────────
@Test
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
void forceLogout_returns200_and_revokes_target_sessions() throws Exception {
UUID targetId = UUID.randomUUID();
AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build();
AppUser target = AppUser.builder().id(targetId).email("target@example.com").build();
when(userService.findByEmail("admin@example.com")).thenReturn(actor);
when(userService.getById(targetId)).thenReturn(target);
when(authService.revokeAllSessions("target@example.com")).thenReturn(2);
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2));
}
@Test
void forceLogout_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void forceLogout_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN_USER")
void forceLogout_returns404_whenUserNotFound() throws Exception {
UUID targetId = UUID.randomUUID();
when(userService.getById(targetId)).thenThrow(
org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found"));
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
}
} }

View File

@@ -36,7 +36,6 @@ class UserServiceTest {
@Mock AppUserRepository userRepository; @Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository; @Mock UserGroupRepository groupRepository;
@Mock InviteTokenRepository inviteTokenRepository;
@Mock PasswordEncoder passwordEncoder; @Mock PasswordEncoder passwordEncoder;
@Mock AuditService auditService; @Mock AuditService auditService;
@InjectMocks UserService userService; @InjectMocks UserService userService;
@@ -904,29 +903,6 @@ class UserServiceTest {
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL"); assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
} }
// ─── deleteGroup ──────────────────────────────────────────────────────────
@Test
void deleteGroup_throwsConflict_whenActiveInviteReferencesGroup() {
UUID groupId = UUID.randomUUID();
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(true);
assertThatThrownBy(() -> userService.deleteGroup(groupId))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.GROUP_HAS_ACTIVE_INVITES);
}
@Test
void deleteGroup_deletesGroup_whenNoActiveInviteReferencesGroup() {
UUID groupId = UUID.randomUUID();
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(false);
userService.deleteGroup(groupId);
verify(groupRepository).deleteById(groupId);
}
@Test @Test
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() { void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO(); org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();

View File

@@ -13,18 +13,3 @@ spring:
password: test password: test
mail: mail:
host: localhost host: localhost
# Disable OTel SDK entirely in tests — prevents auto-configuration from loading resource providers
# (e.g. AzureAppServiceResourceProvider) that fail against the semconv version used here.
otel:
sdk:
disabled: true
# Disable trace export in tests — prevents OTLP connection attempts when no Tempo is running.
# Sampling probability 0.0 means no spans are created, so no export is attempted.
management:
server:
port: 0 # random port per context — prevents TIME_WAIT conflicts when @DirtiesContext restarts the context
tracing:
sampling:
probability: 0.0

View File

@@ -1,2 +0,0 @@
logging.level.root=WARN
logging.level.org.raddatz=INFO

View File

@@ -1,266 +0,0 @@
# Observability stack — Grafana LGTM + GlitchTip
#
# Requires the main stack to be running first:
# docker compose up -d # creates archiv-net
# docker compose -f docker-compose.observability.yml up -d
#
# To validate without starting:
# docker compose -f docker-compose.observability.yml config
services:
# --- Metrics: Prometheus ---
prometheus:
image: prom/prometheus:v3.4.0
container_name: obs-prometheus
restart: unless-stopped
volumes:
- ./infra/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
- '--web.enable-lifecycle'
ports:
- "127.0.0.1:${PORT_PROMETHEUS:-9090}:9090"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 5s
retries: 3
networks:
- archiv-net
- obs-net
node-exporter:
image: prom/node-exporter:v1.9.0
container_name: obs-node-exporter
restart: unless-stopped
# pid: host — required for process-level CPU/memory metrics; cgroup isolation applies
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
# $$ is YAML Compose escaping for a literal $ in the regex alternation
- '--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|host|etc)($$|/)'
expose:
- "9100"
networks:
- obs-net
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
container_name: obs-cadvisor
restart: unless-stopped
# privileged: true — required for cgroup and namespace metrics, see cAdvisor docs.
# Accepted risk: cAdvisor is pinned, on Renovate, and not exposed outside obs-net.
privileged: true
volumes:
- /:/rootfs:ro
# /var/run/docker.sock mounted read-only — sufficient for container metadata discovery
- /var/run/docker.sock:/var/run/docker.sock:ro
- /sys:/sys:ro
- /var/lib/docker:/var/lib/docker:ro
expose:
- "8080"
networks:
- obs-net
# --- Logs: Loki + Promtail ---
loki:
image: grafana/loki:3.4.2
container_name: obs-loki
restart: unless-stopped
volumes:
- ./infra/observability/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
- loki_data:/loki
command: -config.file=/etc/loki/loki-config.yml
expose:
- "3100"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready | grep -q ready || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- obs-net
promtail:
image: grafana/promtail:3.4.2
container_name: obs-promtail
restart: unless-stopped
volumes:
- ./infra/observability/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
# :ro restricts file-system access but NOT Docker API permissions — a compromised Promtail has full daemon access. Accepted risk on single-operator self-hosted archive.
- /var/run/docker.sock:/var/run/docker.sock:ro
- promtail_positions:/tmp # persists positions.yaml across restarts — avoids duplicate log ingestion
command: -config.file=/etc/promtail/promtail-config.yml
networks:
- archiv-net # label discovery from application containers via Docker socket
- obs-net # log shipping to Loki
depends_on:
loki:
condition: service_healthy
# --- Traces: Tempo ---
tempo:
image: grafana/tempo:2.7.2
container_name: obs-tempo
restart: unless-stopped
volumes:
- ./infra/observability/tempo/tempo.yml:/etc/tempo.yml:ro
- tempo_data:/var/tempo
command: -config.file=/etc/tempo.yml
expose:
- "3200" # Grafana queries Tempo on this port (obs-net only)
- "4317" # OTLP gRPC — backend sends traces here (archiv-net)
- "4318" # OTLP HTTP — alternative transport (archiv-net)
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3200/ready | grep -q ready || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- archiv-net # backend (archive-backend) reaches tempo:4317 over this network
- obs-net # Grafana reaches tempo:3200 over this network
# --- Dashboards: Grafana ---
obs-grafana:
image: grafana/grafana-oss:11.6.1
container_name: obs-grafana
restart: unless-stopped
ports:
- "127.0.0.1:${PORT_GRAFANA:-3003}:3000"
environment:
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}
volumes:
- grafana_data:/var/lib/grafana
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q ok || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
prometheus:
condition: service_healthy
loki:
condition: service_healthy
tempo:
condition: service_healthy
networks:
- obs-net
# --- Error Tracking: GlitchTip ---
obs-redis:
image: redis:7-alpine
container_name: obs-redis
restart: unless-stopped
volumes:
- glitchtip_data:/data
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- obs-net
obs-glitchtip:
image: glitchtip/glitchtip:6.1.6
container_name: obs-glitchtip
restart: unless-stopped
depends_on:
obs-redis:
condition: service_healthy
obs-glitchtip-db-init:
condition: service_completed_successfully
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
REDIS_URL: redis://obs-redis:6379/0
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN:-http://localhost:3002}
DEFAULT_FROM_EMAIL: ${APP_MAIL_FROM:-noreply@familienarchiv.local}
EMAIL_URL: smtp://mailpit:1025
GLITCHTIP_MAX_EVENT_LIFE_DAYS: 90
ports:
- "127.0.0.1:${PORT_GLITCHTIP:-3002}:8000"
healthcheck:
test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/8000"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks:
- archiv-net
- obs-net
obs-glitchtip-worker:
image: glitchtip/glitchtip:6.1.6
container_name: obs-glitchtip-worker
restart: unless-stopped
command: ./bin/run-celery-with-beat.sh
depends_on:
obs-redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
REDIS_URL: redis://obs-redis:6379/0
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
networks:
- archiv-net
- obs-net
obs-glitchtip-db-init:
image: postgres:16-alpine
container_name: obs-glitchtip-db-init
restart: "no"
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
command: >
sh -c "psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -tc
\"SELECT 1 FROM pg_database WHERE datname = 'glitchtip'\" |
grep -q 1 ||
psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -c \"CREATE DATABASE glitchtip;\""
networks:
- archiv-net
networks:
# Shared network created by the main docker-compose.yml.
# The observability stack joins as a peer so Prometheus can scrape
# archive-backend by container name. The observability stack must NOT
# attempt to create this network — it will fail with a clear error if
# the main stack is not running yet.
archiv-net:
external: true
# Internal network for observability-service-to-service traffic
# (e.g. Grafana → Prometheus, Grafana → Loki, Grafana → Tempo).
obs-net:
driver: bridge
volumes:
prometheus_data:
loki_data:
promtail_positions:
tempo_data:
grafana_data:
glitchtip_data:

View File

@@ -26,20 +26,10 @@
# MAIL_HOST, MAIL_PORT, SMTP relay (production only; staging uses mailpit) # MAIL_HOST, MAIL_PORT, SMTP relay (production only; staging uses mailpit)
# MAIL_USERNAME, MAIL_PASSWORD # MAIL_USERNAME, MAIL_PASSWORD
# APP_MAIL_FROM sender address (e.g. noreply@raddatz.cloud) # APP_MAIL_FROM sender address (e.g. noreply@raddatz.cloud)
# IMPORT_HOST_DIR absolute host path holding ONLY the ODS
# spreadsheet and PDFs for /admin/system mass
# import — mounted read-only at /import inside
# the backend. Compose refuses to start when
# this var is unset, so staging and prod cannot
# accidentally share an import source. Must be
# readable by the backend container's UID
# (currently root via the OpenJDK image — any
# world-readable directory works).
networks: networks:
archiv-net: archiv-net:
driver: bridge driver: bridge
name: ${COMPOSE_NETWORK_NAME:-archiv-net}
volumes: volumes:
postgres-data: postgres-data:
@@ -128,23 +118,6 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# --- OCR: Volume bootstrap ---
# Ensures correct ownership and directory structure on ocr-cache / ocr-models
# before ocr-service starts. Handles pre-existing volumes (including those
# created before the non-root ocr user was introduced in commit 1aca4c4a)
# and guarantees /app/cache/.tmp exists for TMPDIR staging. See ADR-021.
ocr-volume-init:
image: alpine:3.21
command:
- sh
- -c
- "chown -R 1000:1000 /app/cache /app/models && mkdir -p /app/cache/.tmp && chown 1000:1000 /app/cache/.tmp"
volumes:
- ocr-models:/app/models
- ocr-cache:/app/cache
networks: []
restart: "no"
ocr-service: ocr-service:
build: build:
context: ./ocr-service context: ./ocr-service
@@ -159,14 +132,8 @@ services:
memswap_limit: ${OCR_MEM_LIMIT:-12g} memswap_limit: ${OCR_MEM_LIMIT:-12g}
volumes: volumes:
- ocr-models:/app/models - ocr-models:/app/models
- ocr-cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME) - ocr-cache:/root/.cache
environment: environment:
HF_HOME: /app/cache
XDG_CACHE_HOME: /app/cache
TORCH_HOME: /app/models/torch
TMPDIR: /app/cache/.tmp # Stage GB-scale Surya model downloads on SSD, not the 512 MB RAM tmpfs.
# /tmp keeps its small DoS cap; training ZIPs still unpack under /tmp
# but ZIP Slip protection (_validate_zip_entry) is unchanged. See ADR-021.
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
TRAINING_TOKEN: ${OCR_TRAINING_TOKEN} TRAINING_TOKEN: ${OCR_TRAINING_TOKEN}
OCR_CONFIDENCE_THRESHOLD: "0.3" OCR_CONFIDENCE_THRESHOLD: "0.3"
@@ -184,17 +151,6 @@ services:
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 120s start_period: 120s
depends_on:
ocr-volume-init:
condition: service_completed_successfully
read_only: true
tmpfs:
- /tmp:size=512m # training-ZIP unzip + transient PDF buffers only (small, RAM-friendly).
# GB-scale model downloads go to TMPDIR=/app/cache/.tmp instead. See ADR-021.
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
backend: backend:
image: familienarchiv/backend:${TAG:-nightly} image: familienarchiv/backend:${TAG:-nightly}
@@ -217,12 +173,6 @@ services:
# Bound to localhost only — Caddy fronts external traffic. # Bound to localhost only — Caddy fronts external traffic.
ports: ports:
- "127.0.0.1:${PORT_BACKEND}:8080" - "127.0.0.1:${PORT_BACKEND}:8080"
# Host path holding the ODS spreadsheet + PDFs for the mass-import endpoint.
# Read-only; MassImportService only reads (Files.list / Files.walk on /import).
# Required — no default — so staging and prod cannot accidentally share an
# import source. CI workflows pin this per-env (see .gitea/workflows/).
volumes:
- ${IMPORT_HOST_DIR:?Set IMPORT_HOST_DIR to a host path holding the mass-import payload (ODS + PDFs). See docs/DEPLOYMENT.md.}:/import:ro
environment: environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv SPRING_DATASOURCE_USERNAME: archiv
@@ -247,15 +197,10 @@ services:
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@raddatz.cloud} APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@raddatz.cloud}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-true} SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-true}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-true} SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-true}
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
OTEL_LOGS_EXPORTER: none
OTEL_METRICS_EXPORTER: none
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health | grep -q UP || exit 1"] test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -276,9 +221,6 @@ services:
# SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN} # SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN}
API_INTERNAL_URL: http://backend:8080 API_INTERNAL_URL: http://backend:8080
ORIGIN: https://${APP_DOMAIN} ORIGIN: https://${APP_DOMAIN}
# Enforce upload size limit in the adapter-node layer (fixes GHSA-2crg-3p73-43xp bypass).
# Must be ≤ client_max_body_size in the Caddy reverse proxy to avoid 413 mismatches.
BODY_SIZE_LIMIT: 50M
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:

View File

@@ -71,23 +71,6 @@ services:
networks: networks:
- archiv-net - archiv-net
# --- OCR: Volume bootstrap ---
# Ensures correct ownership and directory structure on ocr_cache / ocr_models
# before ocr-service starts. Handles pre-existing volumes (including those
# created before the non-root ocr user was introduced in commit 1aca4c4a)
# and guarantees /app/cache/.tmp exists for TMPDIR staging. See ADR-021.
ocr-volume-init:
image: alpine:3.21
command:
- sh
- -c
- "chown -R 1000:1000 /app/cache /app/models && mkdir -p /app/cache/.tmp && chown 1000:1000 /app/cache/.tmp"
volumes:
- ocr_models:/app/models
- ocr_cache:/app/cache
networks: []
restart: "no"
# --- OCR: Python microservice (Surya + Kraken) --- # --- OCR: Python microservice (Surya + Kraken) ---
# Single-node only: OCR training reloads the model in-process after each run. # Single-node only: OCR training reloads the model in-process after each run.
# Running multiple replicas would cause training conflicts and model-state divergence. # Running multiple replicas would cause training conflicts and model-state divergence.
@@ -104,14 +87,8 @@ services:
memswap_limit: 12g memswap_limit: 12g
volumes: volumes:
- ocr_models:/app/models - ocr_models:/app/models
- ocr_cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME) - ocr_cache:/root/.cache # Hugging Face / ketos model download cache — prevents re-downloads on container recreate
environment: environment:
HF_HOME: /app/cache
XDG_CACHE_HOME: /app/cache
TORCH_HOME: /app/models/torch
TMPDIR: /app/cache/.tmp # Stage GB-scale Surya model downloads on SSD, not the 512 MB RAM tmpfs.
# /tmp keeps its small DoS cap; training ZIPs still unpack under /tmp
# but ZIP Slip protection (_validate_zip_entry) is unchanged. See ADR-021.
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}" TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
OCR_CONFIDENCE_THRESHOLD: "0.3" OCR_CONFIDENCE_THRESHOLD: "0.3"
@@ -129,17 +106,6 @@ services:
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 120s start_period: 120s
depends_on:
ocr-volume-init:
condition: service_completed_successfully
read_only: true
tmpfs:
- /tmp:size=512m # training-ZIP unzip + transient PDF buffers only (small, RAM-friendly).
# GB-scale model downloads go to TMPDIR=/app/cache/.tmp instead. See ADR-021.
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# --- Backend: Spring Boot --- # --- Backend: Spring Boot ---
backend: backend:
@@ -181,20 +147,8 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false} SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
APP_OCR_BASE_URL: http://ocr-service:8000 APP_OCR_BASE_URL: http://ocr-service:8000
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}" APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
SENTRY_DSN: ${SENTRY_DSN:-}
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
# Tempo is defined in docker-compose.observability.yml (future issue).
# OTLP failures are non-fatal — backend starts cleanly without the observability stack.
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
# 10% sampling in this compose (dev + staging) — override locally to 1.0 if needed
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "0.1"
ports: ports:
- "${PORT_BACKEND}:8080" - "${PORT_BACKEND}:8080"
# Management port — Prometheus scrapes /actuator/prometheus from inside archiv-net.
# Not exposed to the host; Docker service-name DNS (backend:8081) is sufficient.
expose:
- "8081"
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:
@@ -228,9 +182,6 @@ services:
API_INTERNAL_URL: http://backend:8080 API_INTERNAL_URL: http://backend:8080
# Vite dev proxy forwards /api from browser to the backend container # Vite dev proxy forwards /api from browser to the backend container
API_PROXY_TARGET: http://backend:8080 API_PROXY_TARGET: http://backend:8080
# Upload size limit for adapter-node (production target). Not enforced by Vite dev server
# but kept here to match docker-compose.prod.yml and prevent config drift.
BODY_SIZE_LIMIT: 50M
ports: ports:
- "${PORT_FRONTEND}:5173" - "${PORT_FRONTEND}:5173"
networks: networks:

View File

@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own | | `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic | | `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities | | `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). | | `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` | | `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` | | `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers | | `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
@@ -117,7 +117,7 @@ Controllers never call repositories directly. Services never reach into another
### Permission system ### Permission system
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms. Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
Sessions use a Spring Session JDBC-backed cookie (`fa_session`, `httpOnly`, `SameSite=strict`, maxAge=86400 s). CSRF protection uses the double-submit cookie pattern: Spring Security sets an `XSRF-TOKEN` cookie (readable by JS); SvelteKit's `handleFetch` injects the value as `X-XSRF-TOKEN` on every mutating request; a missing or mismatched token returns `403 CSRF_TOKEN_MISSING`. See [ADR-022](adr/022-csrf-session-revocation-rate-limiting.md) and [docs/security-guide.md](security-guide.md) for the full security reference. Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
--- ---

View File

@@ -19,7 +19,6 @@ This doc is the Day-1 checklist and operational reference. It links to the canon
5. [Backup + recovery](#5-backup--recovery) 5. [Backup + recovery](#5-backup--recovery)
6. [Common operational tasks](#6-common-operational-tasks) 6. [Common operational tasks](#6-common-operational-tasks)
7. [Known limitations](#7-known-limitations) 7. [Known limitations](#7-known-limitations)
8. [Upgrade notes](#8-upgrade-notes)
--- ---
@@ -44,7 +43,6 @@ graph TD
- SSE notifications transit Caddy (browser → Caddy → backend); the backend is never reachable directly from the public internet. The SvelteKit SSR layer is bypassed for SSE, but Caddy is not. - SSE notifications transit Caddy (browser → Caddy → backend); the backend is never reachable directly from the public internet. The SvelteKit SSR layer is bypassed for SSE, but Caddy is not.
- The Caddyfile responds `404` on `/actuator/*` (defense in depth). Internal monitoring scrapes the backend on the docker network, not through Caddy. - The Caddyfile responds `404` on `/actuator/*` (defense in depth). Internal monitoring scrapes the backend on the docker network, not through Caddy.
- Production and staging cohabit on the same host via docker compose project names: `archiv-production` (ports 8080/3000) and `archiv-staging` (ports 8081/3001). - Production and staging cohabit on the same host via docker compose project names: `archiv-production` (ports 8080/3000) and `archiv-staging` (ports 8081/3001).
- An optional observability stack (Prometheus, Node Exporter, cAdvisor, Loki, Tempo, Grafana, GlitchTip) runs as a separate compose file. Configuration lives under `infra/observability/`. In production and CI, the stack is managed from `/opt/familienarchiv/` (CI copies it there on every nightly run) so bind mounts survive workspace wipes — see §4 for the ops procedure.
### OCR memory requirements ### OCR memory requirements
@@ -99,7 +97,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — | | `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — |
| `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — | | `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — |
| `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES | | `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES |
| `IMPORT_HOST_DIR` | Absolute host path holding the ODS spreadsheet + PDFs for the `/admin/system` mass-import card. Mounted read-only at `/import` inside the backend (compose-only — backend reads via `app.import.dir`). Compose refuses to start when unset, so staging and prod cannot accidentally share the source. Convention: `/srv/familienarchiv-staging/import` and `/srv/familienarchiv-production/import` | — | YES (prod compose) | — |
| `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — | | `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — |
| `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — | | `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — |
| `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES | | `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES |
@@ -108,12 +105,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — | | `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — | | `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — | | `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP endpoint for distributed traces (Tempo). Port 4318 = HTTP transport; port 4317 is gRPC-only and causes "Connection reset" with Spring Boot's HttpExporter. | `http://localhost:4318` | — | — |
| `OTEL_LOGS_EXPORTER` | Disable OTLP log export — Promtail captures Docker logs via the logging driver; Tempo does not accept logs. | `none` | — | — |
| `OTEL_METRICS_EXPORTER` | Disable OTLP metric export — Prometheus scrapes `/actuator/prometheus` via pull model; Tempo does not accept metrics. | `none` | — | — |
| `MANAGEMENT_METRICS_TAGS_APPLICATION` | Common tag added to every Micrometer metric. Required by Grafana's Spring Boot Observability dashboard (ID 17175) `label_values(application)` template variable. | `Familienarchiv` | — | — |
| `MANAGEMENT_TRACING_SAMPLING_PROBABILITY` | Micrometer tracing sample rate; overridden to `0.0` in test profile. | `0.1` (compose) / `1.0` (dev) | — | — |
| `SENTRY_DSN` | GlitchTip / Sentry DSN for backend error reporting. Leave empty to disable the SDK. Set after GlitchTip first-run (§4). | — | — | YES |
### PostgreSQL container ### PostgreSQL container
@@ -141,21 +132,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — | | `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — | | `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — | | `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
### Observability stack (`docker-compose.observability.yml`)
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `PORT_PROMETHEUS` | Host port for the Prometheus UI (bound to `127.0.0.1` only) | `9090` | — | — |
| `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 |
| `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 |
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite. Leave empty to disable. Set after GlitchTip first-run (§4). | — | — | YES |
--- ---
@@ -202,29 +178,6 @@ curl -fsSL https://tailscale.com/install.sh | sh && tailscale up
# files to disk during execution (cleaned up unconditionally on completion). # files to disk during execution (cleaned up unconditionally on completion).
# A multi-tenant runner would need to switch to stdin-piped env files. # A multi-tenant runner would need to switch to stdin-piped env files.
# (See https://docs.gitea.com/usage/actions/quickstart for the register step.) # (See https://docs.gitea.com/usage/actions/quickstart for the register step.)
# Runner workspace directory — required for DooD bind-mount resolution (ADR-015).
# act_runner stores job workspaces here so that docker compose bind mounts resolve
# to real host paths. The path must be identical on the host and inside job containers.
mkdir -p /srv/gitea-workspace
# Observability config permanent directory — the nightly CI job copies
# docker-compose.observability.yml and infra/observability/ here on every run.
# The obs stack is always started from this path, not from the workspace.
# See ADR-016 for why this directory is used instead of a server-pull approach.
mkdir -p /opt/familienarchiv/infra
# Both paths must also appear in the runner service volumes in ~/docker/gitea/compose.yaml:
# volumes:
# - /srv/gitea-workspace:/srv/gitea-workspace
# /opt/familienarchiv does NOT need to be in the runner container's volumes — job
# containers are spawned by the host daemon directly (DooD), so the host path is
# accessible to them as long as runner-config.yaml lists it in valid_volumes + options.
# See runner-config.yaml (workdir_parent + valid_volumes + options) and ADR-015/016.
# ⚠ IMPORTANT: after any change to runner-config.yaml (valid_volumes, options, workdir_parent),
# restart the Gitea Act runner for the new config to take effect:
# docker restart gitea-runner
# Until restarted, job containers are spawned with the old config and any new bind mounts
# (e.g. /opt/familienarchiv) will not be available inside job steps.
``` ```
### 3.2 DNS records ### 3.2 DNS records
@@ -255,10 +208,6 @@ git.raddatz.cloud A <server IP>
| `MAIL_PORT` | release.yml | typically `587` | | `MAIL_PORT` | release.yml | typically `587` |
| `MAIL_USERNAME` | release.yml | SMTP user | | `MAIL_USERNAME` | release.yml | SMTP user |
| `MAIL_PASSWORD` | release.yml | SMTP password | | `MAIL_PASSWORD` | release.yml | SMTP password |
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
| `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 |
### 3.4 First deploy ### 3.4 First deploy
@@ -286,9 +235,6 @@ Before the first deploy: rotate `PROD_APP_ADMIN_PASSWORD` to a strong value. Aft
## 4. Logs + observability ## 4. Logs + observability
> **Developer guide (where to look for what, LogQL queries, trace exploration) → [docs/OBSERVABILITY.md](./OBSERVABILITY.md).**
> This section covers the ops side: starting the stack, env vars, and CI wiring.
### First-response commands ### First-response commands
```bash ```bash
@@ -309,156 +255,9 @@ docker compose logs --tail=200 <service>
- **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping) - **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping)
- **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally. - **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally.
### Observability stack ### Future observability
An observability stack is available via `docker-compose.observability.yml`. Configuration lives under `infra/observability/`. Phase 7 of the Production v1 milestone adds Prometheus + Loki + Grafana. No monitoring infrastructure is in place yet.
#### Dev — start from the workspace
```bash
docker compose up -d # creates archiv-net
docker compose -f docker-compose.observability.yml up -d
```
#### Why the obs stack is managed differently from the main app stack
The main app stack (`docker-compose.prod.yml`) has no config-file bind mounts — its containers read config from env vars and image defaults. The workspace is wiped after each CI run but that does not affect running containers, because they hold no references to workspace paths.
The obs stack is different: `prometheus.yml`, `tempo.yml`, Loki config, Grafana provisioning files, and Promtail config are all bind-mounted from the host filesystem into their containers. If those source paths disappear (workspace wipe), the containers can restart fine until a `docker compose up` is run again — at that point Docker tries to re-resolve the bind-mount source and fails because the workspace path no longer exists.
The fix is to keep the obs compose file and config tree at a **permanent path** that CI copies to on every run but which survives between runs: `/opt/familienarchiv/` (see ADR-016).
#### Production — managed from `/opt/familienarchiv/`
Every CI run (nightly + release) copies `docker-compose.observability.yml` and `infra/observability/` to `/opt/familienarchiv/` before starting the stack. Bind mounts then resolve to `/opt/familienarchiv/infra/observability/…` — a stable path that outlasts any workspace wipe.
**Environment variables** follow the same two-source model as the main stack:
| Source | What it contains | Managed by |
|---|---|---|
| `infra/observability/obs.env` | All non-secret config (ports, URLs, hostnames) | Git — reviewed in PRs |
| `/opt/familienarchiv/obs-secrets.env` | Passwords and secret keys only | CI — written fresh from Gitea secrets on every deploy |
Both files are passed explicitly via `--env-file` to the compose command, so there is no implicit auto-read `.env` and no operator-managed file to keep in sync.
**Non-secret config** (`infra/observability/obs.env`):
| Key | Value | Notes |
|---|---|---|
| `PORT_GRAFANA` | `3003` | Avoids collision with staging frontend on port 3001 |
| `PORT_GLITCHTIP` | `3002` | |
| `PORT_PROMETHEUS` | `9090` | |
| `GF_SERVER_ROOT_URL` | `https://grafana.archiv.raddatz.cloud` | Required for alert email links and OAuth redirects |
| `GLITCHTIP_DOMAIN` | `https://glitchtip.archiv.raddatz.cloud` | Must match the Caddy vhost |
| `POSTGRES_HOST` | `archive-db` | Override if only the staging stack is running |
**Secret keys** (set in Gitea secrets, injected by CI into `obs-secrets.env`):
| Gitea secret | Notes |
|---|---|
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
To start or restart the obs stack manually on the server (after CI has run at least once):
```bash
docker compose \
-f /opt/familienarchiv/docker-compose.observability.yml \
--env-file /opt/familienarchiv/infra/observability/obs.env \
--env-file /opt/familienarchiv/obs-secrets.env \
up -d --wait --remove-orphans
```
> **Note (manual ops only):** CI clears the destination with `rm -rf` before copying, so deleted files are removed automatically on the next run. If you copy manually with `cp -r` without first removing the directory, stale files from deleted configs will persist until cleaned up:
> ```bash
> rm /opt/familienarchiv/infra/observability/<path-to-removed-file>
> ```
Current services:
| Service | Image | Purpose |
|---|---|---|
| `obs-prometheus` | `prom/prometheus:v3.4.0` | Scrapes metrics from backend management port 8081 (`/actuator/prometheus`), node-exporter, and cAdvisor |
| `obs-node-exporter` | `prom/node-exporter:v1.9.0` | Host-level CPU / memory / disk / network metrics |
| `obs-cadvisor` | `gcr.io/cadvisor/cadvisor:v0.52.1` | Per-container resource metrics |
| `obs-loki` | `grafana/loki:3.4.2` | Log aggregation — receives log streams from Promtail. Port 3100 is `expose`-only (not host-bound). |
| `obs-promtail` | `grafana/promtail:3.4.2` | Log shipping agent — reads all Docker container logs via the Docker socket and forwards them to Loki with `container_name`, `compose_service`, `compose_project`, and `job` labels. The `job` label is mapped from the Docker Compose service name (`com.docker.compose.service`) so that Grafana Loki dashboard queries (`{job="backend"}`, `{job="frontend"}`) work out of the box and the "App" variable dropdown is populated. |
| `obs-tempo` | `grafana/tempo:2.7.2` | Distributed trace storage — OTLP HTTP receiver on port 4318 (`archiv-net`-internal; backend sends traces here). Grafana queries traces on port 3200 (`obs-net`-internal). All ports are `expose`-only (not host-bound). |
| `obs-grafana` | `grafana/grafana-oss:11.6.1` | Unified observability UI — metrics dashboards, log exploration, trace viewer. Bound to `127.0.0.1:${PORT_GRAFANA:-3003}` on the host. |
| `obs-glitchtip` | `glitchtip/glitchtip:6.1.6` | Sentry-compatible error tracker. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces. Bound to `127.0.0.1:${PORT_GLITCHTIP:-3002}`. |
| `obs-glitchtip-worker` | `glitchtip/glitchtip:6.1.6` | Celery + beat worker — processes async GlitchTip tasks (event ingestion, notifications, cleanup). |
| `obs-redis` | `redis:7-alpine` | Celery task broker for GlitchTip. Internal to `obs-net`; no host port exposed. |
| `obs-glitchtip-db-init` | `postgres:16-alpine` | One-shot init container. Creates the `glitchtip` database on the existing `archive-db` PostgreSQL instance if it does not already exist. Runs at stack startup; exits cleanly once done. |
#### Grafana
| Item | Value |
|---|---|
| URL | `http://localhost:3003` (or `http://localhost:$PORT_GRAFANA`) |
| Username | `admin` |
| Password | `$GRAFANA_ADMIN_PASSWORD` (default: `changeme`**change before exposing to a network**) |
Datasources are auto-provisioned on first start (Prometheus, Loki, Tempo — no manual setup required). Three dashboards are pre-loaded:
| Dashboard | Grafana ID | Purpose |
|---|---|---|
| Node Exporter Full | 1860 | Host CPU, memory, disk, network |
| Spring Boot Observability | 17175 | JVM metrics, HTTP latency, error rate |
| Loki Logs | 13639 | Log exploration and filtering |
Tempo traces are accessible via Grafana Explore → Tempo datasource, and linked from Loki logs via the `traceId` derived field.
**Loki quick checks** (after ~60 s, run from inside the `obs-loki` container):
```bash
# Loki health
docker exec obs-loki wget -qO- http://localhost:3100/ready
# List labels
docker exec obs-loki wget -qO- 'http://localhost:3100/loki/api/v1/labels'
# Query logs by service (stable across dev and prod environments)
docker exec obs-loki wget -qO- \
'http://localhost:3100/loki/api/v1/query_range?query=%7Bcompose_service%3D%22backend%22%7D&limit=5'
```
**Prefer `compose_service` over `container_name` in LogQL queries**`container_name` differs between dev (`archive-backend`) and prod (`archiv-production-backend-1`), while `compose_service` is stable (`backend`, `db`, `minio`, etc.).
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.
#### GlitchTip
| Item | Value |
|---|---|
| URL | `http://localhost:3002` (or `http://localhost:$PORT_GLITCHTIP`) |
**Required env vars** — set in `.env` before first start:
```bash
GLITCHTIP_SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
GLITCHTIP_DOMAIN=http://localhost:3002 # change to your public URL in prod
PORT_GLITCHTIP=3002 # optional, defaults to 3002
```
**Database:** GlitchTip shares the existing `archive-db` PostgreSQL instance. The `obs-glitchtip-db-init` one-shot container creates a dedicated `glitchtip` database on first stack start — no manual step required.
**First-run steps** (one-time, after `docker compose -f docker-compose.observability.yml up -d`):
```bash
# 1. Create the Django superuser (interactive)
docker exec -it obs-glitchtip ./manage.py createsuperuser
# 2. Open the GlitchTip UI and log in
open http://localhost:3002
# 3. Create an organisation (e.g. "Familienarchiv")
# 4. Create two projects:
# - "familienarchiv-frontend" (platform: JavaScript / SvelteKit)
# - "familienarchiv-backend" (platform: Java / Spring Boot)
# 5. Copy each project's DSN from Settings → Projects → <project> → Client Keys
# 6. Wire the DSNs into the backend and frontend via env vars (separate issue)
```
--- ---
@@ -533,18 +332,9 @@ bash scripts/download-kraken-models.sh
### Trigger a mass import (Excel/ODS) ### Trigger a mass import (Excel/ODS)
**Dev:** drop the ODS spreadsheet + PDFs into `./import/` at the repo root — the dev compose bind-mounts it to `/import` automatically. 1. Place the import file in the `import/` bind mount on the backend container.
2. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission).
**Staging/production:** 3. The import runs asynchronously — poll `GET /api/admin/import-status` or watch backend logs.
1. Pre-stage the payload on the host. Convention: `/srv/familienarchiv-staging/import/` or `/srv/familienarchiv-production/import/`.
```bash
rsync -avh --progress ./import/ user@host:/srv/familienarchiv-staging/import/
```
2. Make sure `IMPORT_HOST_DIR=<host-path>` is set in `.env.staging` / `.env.production` (the nightly/release workflows already write this — see §3). Compose refuses to start without it.
3. Redeploy the stack so the bind mount picks up — or, if the mount is already in place, skip to step 4.
4. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission), or click the "Import starten" button on `/admin/system`.
5. The import runs asynchronously — poll `GET /api/admin/import-status`, watch `/admin/system`, or tail the backend logs.
--- ---
@@ -557,44 +347,3 @@ bash scripts/download-kraken-models.sh
| **No multi-region** | Single PostgreSQL + MinIO instance; no replication or failover | Deliberate scope decision | | **No multi-region** | Single PostgreSQL + MinIO instance; no replication or failover | Deliberate scope decision |
| **Max upload size** | 50 MB per file (500 MB per request for multi-file) | Configurable in `application.yaml` (`spring.servlet.multipart`) | | **Max upload size** | 50 MB per file (500 MB per request for multi-file) | Configurable in `application.yaml` (`spring.servlet.multipart`) |
| **No automated backup** | Phase 5 of Production v1 milestone is not yet implemented | See §5 above | | **No automated backup** | Phase 5 of Production v1 milestone is not yet implemented | See §5 above |
---
## 8. Upgrade notes
Version-specific one-time steps that must be run before or after upgrading to a given release. Each subsection is safe to skip on a fresh install.
### Upgrading to PR #615 — TMPDIR redirect + ocr-volume-init
`ocr-volume-init` is a new one-shot service in both compose files that runs before `ocr-service` on every `docker compose up`. It:
1. `chown -R 1000:1000 /app/cache /app/models` — corrects volume ownership so the non-root `ocr` user (uid 1000) can write to volumes that may have been created as root (including volumes from before PR #611).
2. `mkdir -p /app/cache/.tmp` — creates the TMPDIR staging directory that Surya uses for GB-scale model downloads. Without this directory, the first model download falls back to the 512 MB `/tmp` tmpfs and fails with ENOSPC. See ADR-021.
**Verify it succeeded:**
```bash
docker logs archiv-ocr-volume-init # dev
docker logs archiv-production-ocr-volume-init-1 # prod
```
Expected output: no error lines; exit code 0.
**Failure mode:** if `chown` is denied (e.g. the volume is mounted read-only), the container exits non-zero and `ocr-service` will not start (`depends_on: condition: service_completed_successfully`). Check `docker logs` for the `chown` error and verify the volume is writable.
### Upgrading to PR #611 — non-root OCR container
The OCR cache volume path changed from `/root/.cache` to `/app/cache` (PR #611 — CIS Docker §4.1 hardening). The existing volume was written as root and is inaccessible to the new non-root `ocr` user, causing a `PermissionError` on startup.
**Before starting the updated container stack**, drop the old root-owned volume. The volume name depends on the compose project name:
```bash
# Dev (docker-compose.yml — project name: familienarchiv)
docker volume rm familienarchiv_ocr_cache
# Production (docker-compose.prod.yml -p archiv-production)
docker volume rm archiv-production_ocr-cache
# Staging (docker-compose.prod.yml -p archiv-staging)
docker volume rm archiv-staging_ocr-cache
```
The volume is recreated automatically on `docker compose up`. The OCR service will re-download its model cache on first startup (approximately 12 GB, one-time cost).

View File

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

View File

@@ -1,180 +0,0 @@
# Observability Guide
> **Ops reference (starting the stack, env vars, CI wiring) → [DEPLOYMENT.md §4](./DEPLOYMENT.md#4-logs--observability).**
> This file is for developers: what signal lives where, how to reach it, and what to look for.
## Where to look for what
| I want to… | Go to |
|---|---|
| See the last N log lines from the backend | `docker compose logs --tail=100 backend` |
| Search logs by keyword across time | Grafana → Explore → Loki |
| Understand why an HTTP request failed | Grafana → Explore → Loki → filter by `traceId` → follow link to Tempo |
| See a full distributed trace (DB queries, HTTP calls) | Grafana → Explore → Tempo → search by service or trace ID |
| Check JVM heap / GC / thread count | Grafana → Dashboards → Spring Boot Observability |
| Check HTTP error rate or p95 latency | Grafana → Dashboards → Spring Boot Observability |
| Check host CPU / memory / disk | Grafana → Dashboards → Node Exporter Full |
| See grouped application errors with stack traces | GlitchTip |
| Check if the backend is healthy | `curl http://localhost:8081/actuator/health` (on the server) |
| Check what Prometheus is scraping | `curl http://localhost:9090/api/v1/targets` (on the server) |
## Access
| Tool | External URL | Who it's for |
|---|---|---|
| Grafana | `https://grafana.archiv.raddatz.cloud` | Logs, metrics, traces — the primary observability UI |
| GlitchTip | `https://glitchtip.archiv.raddatz.cloud` | Grouped errors with stack traces and release tracking |
Loki, Tempo, and Prometheus have no external URL. They are internal services, accessible only through Grafana (or via SSH tunnel — see below).
## Logs (Loki)
Logs reach Loki via Promtail, which reads all Docker container logs from the Docker socket and ships them with labels derived from Docker Compose metadata.
### Labels available in every log line
| Label | What it contains | Example |
|---|---|---|
| `job` | Compose service name | `backend`, `frontend`, `db` |
| `compose_service` | Same as `job` | `backend` |
| `compose_project` | Compose project name | `archiv-staging`, `archiv-production` |
| `container_name` | Docker container name | `archiv-staging-backend-1` |
| `filename` | Docker log source | `/var/lib/docker/containers/…` |
**Use `job` in LogQL queries** — it is stable across dev, staging, and production. `container_name` changes between environments.
### Common LogQL queries in Grafana Explore
```logql
# All backend logs
{job="backend"}
# Backend ERROR and WARN lines only
{job="backend"} |= "ERROR" or {job="backend"} |= "WARN"
# All logs for a specific request (paste a traceId from a log line)
{job="backend"} |= "3fa85f64-5717-4562-b3fc-2c963f66afa6"
# Log lines containing a specific exception class
{job="backend"} |~ "DomainException|NullPointerException"
# Frontend logs
{job="frontend"}
# Database (slow query log, if enabled)
{job="db"}
```
### Log → Trace correlation
Spring Boot writes the active `traceId` into every log line when a request is being processed:
```
2026-05-16 ... INFO [Familienarchiv,3fa85f64...,1b2c3d4e] o.r.f.document.DocumentService : ...
```
In Grafana Explore → Loki, log lines with a `traceId` field show a **Tempo** link. Clicking it opens the full trace in Explore → Tempo without copying and pasting IDs.
This linking is configured in the Loki datasource provisioning via the `traceId` derived field regex. No manual setup required.
## Traces (Tempo)
The backend sends traces to Tempo via OTLP HTTP (port 4318). Every inbound HTTP request and every JPA query produces a span. Spans are linked into traces by the propagated `traceId` header.
### Finding a trace in Grafana
**Option A — from a log line:**
1. Grafana → Explore → select *Loki* datasource
2. Query `{job="backend"}` and find the failing request
3. Click the **Tempo** link in the log line (appears when `traceId` is present)
**Option B — by service:**
1. Grafana → Explore → select *Tempo* datasource
2. Query type: **Search**
3. Service name: `familienarchiv-backend`
4. Filter by HTTP status, duration, or operation name as needed
**Option C — by trace ID:**
1. Grafana → Explore → select *Tempo* datasource
2. Query type: **TraceQL** or **Trace ID**
3. Paste the trace ID
### What each span type tells you
| Root span name pattern | What it covers |
|---|---|
| `GET /api/documents`, `POST /api/documents` | Full HTTP request lifecycle |
| `SELECT archiv.*` | A single JPA/JDBC query inside that request |
| `HikariPool.getConnection` | Connection pool wait time |
A slow `SELECT` span inside an otherwise fast HTTP span pinpoints a missing index. A slow `HikariPool.getConnection` span indicates connection pool exhaustion.
### Sampling rate
- **Dev**: 100% of requests are traced (`management.tracing.sampling.probability: 1.0` in `application.yaml`)
- **Staging / Production**: 10% (`MANAGEMENT_TRACING_SAMPLING_PROBABILITY=0.1` in `docker-compose.prod.yml`)
To find a trace for a specific request in staging/production, either increase the sampling rate temporarily or trigger the request multiple times.
## Metrics (Prometheus → Grafana)
Prometheus scrapes the backend management endpoint every 15 s:
```
Target: backend:8081/actuator/prometheus
Labels: job="spring-boot", application="Familienarchiv"
```
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
### Useful Prometheus queries (run on the server or via Grafana Explore → Prometheus)
```promql
# HTTP error rate (5xx) as a fraction of all requests
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count[5m]))
# p95 response time
histogram_quantile(0.95, sum by (le) (
rate(http_server_requests_seconds_bucket[5m])
))
# JVM heap used
jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
# Active DB connections
hikaricp_connections_active
```
## 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.
GlitchTip complements Loki: use GlitchTip when you need **grouped, de-duplicated errors with stack traces and release attribution**; use Loki when you need **raw log lines with full context** or want to search across all log levels.
## Direct API access (debugging only)
Loki and Tempo bind no host ports. To reach them directly from your laptop, use an SSH tunnel through the server:
```bash
# Loki API on localhost:3100 (then query via curl or logcli)
ssh -L 3100:172.20.0.x:3100 root@raddatz.cloud
# Replace 172.20.0.x with the obs-loki container IP:
# docker inspect obs-loki --format '{{.NetworkSettings.Networks.archiv-obs-net.IPAddress}}'
# Tempo API on localhost:3200 (then query via curl or tempo-cli)
ssh -L 3200:172.20.0.x:3200 root@raddatz.cloud
```
In practice, Grafana Explore covers all common debugging workflows without needing direct API access.
## Signal summary
| Signal | Source | Transport | Storage | UI |
|---|---|---|---|---|
| Application logs | Spring Boot stdout → Docker log driver | Promtail → Loki push API | Loki | Grafana Explore → Loki |
| Distributed traces | Spring Boot OTel agent | OTLP HTTP → Tempo:4318 | Tempo | Grafana Explore → Tempo |
| JVM + HTTP metrics | Spring Actuator `/actuator/prometheus` | Prometheus pull (15 s) | Prometheus | Grafana dashboards |
| Host metrics | node-exporter | Prometheus pull | Prometheus | Grafana → Node Exporter Full |
| Container metrics | cAdvisor | Prometheus pull | Prometheus | Grafana (via Prometheus datasource) |
| Application errors | Sentry SDK | HTTP POST → GlitchTip ingest | GlitchTip DB | GlitchTip UI |

View File

@@ -1,134 +0,0 @@
# ADR 012 — Browser-Mode Test Mocking Strategy
**Status:** Accepted
**Date:** 2026-05-11 (revised 2026-05-12)
**Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553)
---
## Context
Vitest browser-mode tests (the `client` project, run with `@vitest/browser-playwright` / Chromium) use a different module resolution path than Node-environment tests. When a spec calls `vi.mock('some-module', factory)`, vitest registers a `ManualMockedModule`. At runtime, every time Chromium requests that module, a playwright route handler intercepts the request and calls the Node worker over **birpc** (`resolveManualMock`) to evaluate the factory and return the module body.
This is safe for modules that are imported **statically** at spec module-eval time (e.g. `$app/navigation`, `$env/static/public`): those requests resolve before the first test runs and well before any teardown occurs.
It is **unsafe** for modules that are imported **dynamically** (e.g. inside an `async onMount`, inside a lazy-loaded chunk): Chromium may fetch the module after the worker's birpc channel has already closed, producing:
```
Error: [birpc] rpc is closed, cannot call "resolveManualMock"
ManualMockedModule.factory node_modules/@vitest/browser/dist/index.js:3221:34
```
This raises an unhandled rejection that exits the vitest process with code 1, even though every test in the run reported green.
`pdfjs-dist` and `pdfjs-dist/build/pdf.worker.min.mjs?url` are loaded via `await Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')])` inside `usePdfRenderer.svelte.ts::init()`, which is called from `onMount`. These dynamic imports triggered the race.
---
## Decision
**Prefer prop injection over `vi.mock(module, factory)` for any module that is loaded dynamically in browser-mode specs.**
### The libLoader pattern (for external rendering libraries)
When a component depends on a large external library loaded via dynamic import, extract the import into an injectable loader function with a production default:
```typescript
// usePdfRenderer.svelte.ts
type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
const defaultLibLoader: LibLoader = () =>
Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);
export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { ... }
```
The component threads the loader as an optional prop:
```svelte
<!-- PdfViewer.svelte -->
let { url, ..., libLoader = undefined } = $props();
const renderer = untrack(() => createPdfRenderer(libLoader));
```
Tests supply a synchronous fake — no `vi.mock` needed:
```typescript
const fakePdfjs = { GlobalWorkerOptions: ..., getDocument: vi.fn(), TextLayer: class {} };
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
render(PdfViewer, { url: '...', libLoader: fakeLoader });
```
### The test-host pattern (for component behaviour)
For components that fetch data or call services, the `*.test-host.svelte` pattern threads the dependency as a prop rather than mocking the module. See `PersonMentionEditor.test-host.svelte` for the canonical example.
---
## Binding invariant: factory bodies must be synchronous (#553)
The original revision of this ADR allowed `vi.mock(virtualModule, factory)` for SvelteKit/Vite virtual modules on the argument that their consumer imports were resolved at static-import time. **That reasoning is wrong.** What matters is what the **factory body** does, not where the mocked module is consumed.
`EnrichmentBlock.svelte.spec.ts` (issue #553) was statically imported and still produced the race: its `vi.mock('$app/stores', async () => { const mod = await import(...); return mod; })` factory performed a dynamic import in its body, and that body was invoked asynchronously when Chromium fetched the manually-mocked module — sometimes after the worker's birpc channel had already closed.
**Therefore: under `**/*.svelte.{test,spec}.ts`, every `vi.mock` factory body must be synchronous. No `await`, no `import(...)`.**
If a factory needs to share state with the spec (a mutable ref, a `vi.fn`, a writable store), use `vi.hoisted()` to lift the reference above `vi.mock`'s implicit hoist:
```ts
const { mockNavigating } = vi.hoisted(() => ({
mockNavigating: { type: null as string | null }
}));
vi.mock('$app/state', () => ({
get navigating() {
return mockNavigating;
}
}));
```
The getter defers the read until consumption time; `vi.hoisted` guarantees the reference is initialised before the (also hoisted) `vi.mock` factory runs. See `DropZone.svelte.spec.ts:9`, `NotificationBell.svelte.spec.ts:6-10`, and `EnrichmentBlock.svelte.spec.ts` for canonical examples.
### Architectural follow-on: prefer `$app/state` over `$app/stores`
`$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the *need* to mock a store at all in that spec.
**Pattern note:** When an overlay or dropdown triggers a navigation action, use `<button type="button">` with an `onclick` handler that calls `goto(path)` — do **not** use `<a href="…">` with `e.preventDefault()`. SvelteKit registers its link interceptor as a capture-phase `document` listener, so it fires before the component's bubble-phase `onclick`. By the time `e.preventDefault()` runs the router has already initiated navigation, which tears down the vitest-browser Playwright orchestrator iframe. A `<button>` carries no `href`, so the capture-phase interceptor never fires. See `NotificationDropdown.svelte` for the canonical example.
**Pattern note (#553):** Browser-mode tests run with `data-sveltekit-preload-data="off"` (set in `src/test-setup.ts` via the client project's `setupFiles`). Hover-prefetch otherwise fires real fetch requests for route loader chunks; those requests go through the same Playwright route handler that serves mocked modules. An in-flight prefetch landing after iframe teardown can hit the handler with a closed birpc channel, raising an unhandled rejection.
---
## Binding invariant: one canonical ID per mocked module (#553 — duplicate-id hazard)
The sync-factory invariant above closes one named trigger of the `[birpc] rpc is closed` race. Investigation of a follow-up flake revealed a second, independent trigger: **the same resolved module URL mocked under two distinct ID strings** across or within spec files.
`@vitest/browser-playwright` registers a Playwright `page.context().route(...)` handler per `vi.mock` call. The predicate matches on the module's resolved URL. When two `vi.mock` calls reference the same module under different IDs — for example `'$lib/foo.svelte'` and `'$lib/foo.svelte.js'` (both resolve to the same Svelte rune-module URL) — the registry stores both predicates but the cleanup map only tracks the latest. The orphan route survives session teardown. When the next session loads the same module, the orphan fires, calls `await module.resolve()` against a closed birpc channel, and crashes the run.
This is fixed upstream in [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267) (issue [#9957](https://github.com/vitest-dev/vitest/issues/9957)). Until that fix reaches a published `@vitest/browser-playwright` release, we close the gap from two sides:
**The rule.** Every mocked module must be referenced under exactly one ID string across the entire client test suite. Pick the spelling production code uses. For Svelte 5 rune modules (`*.svelte.ts`), the canonical form is the no-extension import (`'$lib/foo.svelte'`) — matches the source file basename and matches Svelte 5 convention. Never mix `.svelte.js` and `.svelte` for the same module across specs.
**Enforcement layers** (added in #553's second cycle, extending the four-layer chain above):
5. **In-suite meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` globs `src/**/*.svelte.{test,spec}.ts`, extracts every `vi.mock` first-arg string, canonicalises by stripping a trailing `.js`/`.ts` after `.svelte`, and fails if any canonical ID is referenced under two or more distinct spellings. Same shape as `no-async-mock-factories.test.ts`.
6. **`patch-package` backport** of PR #10267 at `frontend/patches/@vitest+browser-playwright+4.1.0.patch`. Applied automatically by the `postinstall` hook. Closes the race at the route-handler level — even if a contributor reintroduces a duplicate-ID, the patched `register` handler unroutes the existing predicate before installing the new one.
**When to remove the patch.** Once `@vitest/browser-playwright` ships a release containing PR #10267, delete `patches/@vitest+browser-playwright+4.1.0.patch`. Bump the dependency to the version containing the fix. The in-suite meta-test stays — it's a cheap permanent guard against the contributor-facing pattern, independent of upstream library version.
---
## Consequences
- New browser-mode specs that need to stub an external library **must not** use `vi.mock(externalLib, factory)`. Add a loader/factory parameter to the underlying hook or service instead.
- The CI `unit-tests` job includes a permanent grep guard that fails the build if `rpc is closed` appears in any coverage run log. This catches regressions before they reach the acceptance criterion.
- Acceptance criterion for #535: 60 consecutive green `workflow_dispatch` CI runs against `main` after the fix is merged, with zero `rpc is closed` lines in any log.
- **Enforcement (six layers, defence in depth):**
1. **ESLint `no-restricted-syntax`** in `eslint.config.js` (scoped to `**/*.{spec,test}.ts`) flags two patterns: (a) the literal `vi.mock('pdfjs-dist', ...)` — enforces the libLoader pattern — and (b) any `vi.mock(..., async () => { ... await import(...) ... })` — enforces the synchronous-factory invariant. Both messages point at this ADR. Failure surfaces at save time.
2. **CI grep guard** in `.gitea/workflows/ci.yml` runs before the test suite launches. Mirrors the ESLint patterns with `grep -Pzn`. ~10s round-trip.
3. **In-suite meta-test** at `frontend/src/__meta__/no-async-mock-factories.test.ts` globs `src/**/*.svelte.{test,spec}.ts` and asserts none match the banned pattern. Catches at every vitest invocation — the layer hardest to disable.
4. **CI birpc assert** runs after the coverage step and fails the build if `[birpc] rpc is closed` appears in any log line. Catches the symptom even if all the upstream layers were bypassed.
5. **In-suite duplicate-ID meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` enforces the one-canonical-ID-per-module rule from the duplicate-id-hazard section above.
6. **`patch-package` backport** at `frontend/patches/@vitest+browser-playwright+4.1.0.patch` closes the upstream race itself, applied via `postinstall`. To be removed when `@vitest/browser-playwright` releases [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
- **Acceptance verification:** `coverage-flake-probe.yml` is a `workflow_dispatch`-triggered matrix workflow that runs the coverage suite 20× in parallel against a single SHA and asserts zero birpc lines. One fire, parallel cost, deterministic signal — replaces accumulating 20 sequential push events.
- **When to revisit the LibLoader home:** If three or more components adopt this pattern, consider extracting a shared `$lib/types/lib-loader.ts` or a generic `DynamicImportLoader<T>` type to avoid parallel type definitions across modules.

View File

@@ -1,92 +0,0 @@
# ADR 013 — Client-Project Branch Coverage Threshold
**Status:** Accepted
**Date:** 2026-05-14
**Issues:** [#556 — threshold drop](https://git.raddatz.cloud/marcel/familienarchiv/issues/556) · [#496 — long-tail-grind tracking](https://git.raddatz.cloud/marcel/familienarchiv/issues/496)
---
## Context
The browser-mode component test suite (`vitest.client-coverage.config.ts`) enforces Istanbul coverage thresholds across `lines`, `functions`, `branches`, and `statements`. The `branches` metric was set to 80%, but the codebase sits at **75%** — below the gate — causing every CI run of `unit-tests` and `coverage-flake-probe` to fail on this check alone, even when all tests are green.
**Measured baseline (2026-05-14, branch `feat/issue-553-birpc-async-mock-factory`, head `2e6cc346`):**
```
branches: 75% (below the 80% gate — reason for this ADR)
lines: ≥ 80%
functions: ≥ 80%
statements: ≥ 80%
```
Reproducer:
```bash
cd frontend && npm ci && npx vitest run -c vitest.client-coverage.config.ts --coverage
```
### The long-tail-grind problem
In Istanbul's branch accounting, when a child component gains test coverage its branches are added to the parent's denominator. A child moving from 40% → 80% coverage can drag a parent from 78% → 72% because more branches in the call graph become reachable and must be covered. This is not a bug — it is how branch accounting works — but it means that on a large SvelteKit application the denominator grows with every coverage improvement, making an arbitrary 80% ceiling a constant grind. Per #496, the expected cost to reach 80% branches from 75% is 30100+ commits with no guarantee of stability.
### Why this layer is different
The 80% branch floor used for backend unit/integration tests is appropriate for Java service code and permission logic. Browser-mode component coverage measures Svelte template branches: conditional class bindings, `{#if}` blocks, empty/loaded/error state guards. These branches have a fundamentally different accounting model and a higher inherent denominator. This ADR **only** lowers the browser-mode component gate; the backend test coverage gates are unaffected.
### Security-relevant uncovered components
The following auth/permission-boundary components currently have low or zero branch coverage. When ratchet-up work begins (see below), these are the highest-priority targets:
- `src/routes/login/+page.svelte`
- `src/routes/forgot-password/+page.svelte`
- `src/routes/reset-password/+page.svelte`
- `src/routes/register/+page.svelte`
Note: the 75% figure already reflects the absence of coverage on these files. Lowering the gate does not create this gap — it makes the existing state legible.
---
## Decision
Drop the `branches` threshold from `80``75` in `frontend/vitest.client-coverage.config.ts`. Leave `lines`, `functions`, and `statements` at `80`.
The 75% figure matches the measured current state, allowing CI to pass while deliberate coverage improvement work (tracked in #496) continues without blocking other PRs. The asymmetry in the thresholds block is intentional and documented with an inline comment pointing here.
---
## Ratchet Rule
The branches threshold ratchets **up by 3 percentage points** when the rolling 3-PR-average client-project branches figure on `main` stays at or above `threshold + 3pp` for ≥ 30 consecutive days. Direction is **up-only** — never lower the floor below 75 without a new ADR superseding this one. Manual today (verify before any `vitest.client-coverage.config.ts` edit); a future automation issue may codify the check.
Concretely:
- When `main` sustains ≥ 78% branches across 3 consecutive PRs for 30 days → raise gate to 78%
- When `main` sustains ≥ 81% branches across 3 consecutive PRs for 30 days → raise gate back to 80%
---
## Non-goals
- **Not** raising actual branch coverage — that is #496's job, tracked separately.
- **Not** touching the server-project coverage configuration (`vitest.config.ts`) — only the client project hits the long-tail-grind pattern.
- **Not** removing or relaxing any existing test files, `skipIf` guards, or axe-playwright accessibility runs.
---
## Consequences
**Easier:**
- CI unblocked — `unit-tests` and `coverage-flake-probe` jobs pass when all tests are green
- The ratchet rule creates a concrete, observable path back to 80%
**Harder:**
- The gate now has near-zero headroom — any branch regression that drops below 75% will fail CI immediately
- The 75% floor must not be treated as a permanent ceiling; the ratchet discipline requires active attention
---
## References
- [#496 — Branch coverage long-tail grind](https://git.raddatz.cloud/marcel/familienarchiv/issues/496)
- [#556 — This threshold drop](https://git.raddatz.cloud/marcel/familienarchiv/issues/556)
- [ADR 012 — Browser-Mode Test Mocking Strategy](./012-browser-test-mocking-strategy.md)
- `frontend/vitest.client-coverage.config.ts` — thresholds block (lines 4451)

View File

@@ -1,122 +0,0 @@
# ADR 014 — Pin actions/upload-artifact to v3 (Gitea act_runner v4 protocol incompatibility)
**Status:** Accepted
**Date:** 2026-05-14
**Issues:** [#557 — re-regression](https://git.raddatz.cloud/marcel/familienarchiv/issues/557) · [#14 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/14)
---
## Context
`actions/upload-artifact` is available in two incompatible major versions. The v4 client
uploads via a GitHub-specific artifact API that is **not implemented** in Gitea's
`act_runner` (the self-hosted CI substrate established by ADR-011). When a workflow step
uses `actions/upload-artifact@v4` on this runner, `act_runner` returns a non-zero exit
code from the v4 client even when all tests pass, producing:
> green test suite — red job status — no artifact uploaded
The failure lands in the upload step, _after_ the test output, making it hard to diagnose
from the build log.
### Incident history
| Date | Commit | Event |
|---|---|---|
| 2026-03-19 | `9f3f022e` | Original downgrade: `upload-artifact@v4 → v3` |
| 2026-03-19 | `4142c7cd` | Rationale committed; closes #14 |
| 2026-05-05 | `410b91e2` | Re-regression: upgraded back to v4 without referencing #14 |
| 2026-05-14 | this PR | Second downgrade + ADR + grep guard |
The root cause of the re-regression was institutional-memory failure: the original
rationale was captured only in a commit body, invisible at the point of change (the
`uses:` line). This ADR, the inline comments, and the grep guard are the three
defence layers that replace that missing breadcrumb.
---
## Decision
**Pin all `actions/upload-artifact` and `actions/download-artifact` call sites to `@v3`.**
Both action families share the same v4 protocol incompatibility with `act_runner`.
Pinning to the major tag (`@v3`) keeps us on the latest v3 patch without Renovate noise.
Three call sites are pinned:
- `.gitea/workflows/ci.yml` — "Upload coverage reports" step
- `.gitea/workflows/ci.yml` — "Upload screenshots" step
- `.gitea/workflows/coverage-flake-probe.yml` — "Upload coverage log on failure" step
Each pinned `uses:` line carries a load-bearing inline comment:
```yaml
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
- uses: actions/upload-artifact@v3
```
A CI grep guard enforces the constraint automatically (see below).
---
## Consequences
### Enforcement layers (defence in depth)
1. **Inline comments** on every `uses:` line — visible at the point of change.
2. **CI grep guard** in `.gitea/workflows/ci.yml` ("Assert no (upload|download)-artifact
past v3") — fails the build if a future commit re-introduces `@v4` or higher on any
workflow file. Anchored to YAML `uses:` lines to avoid false positives on embedded
shell strings. Includes a self-test that proves the regex catches v4+ before scanning
the repo.
3. **This ADR** — canonical rationale; cross-referenced by comments and guard message.
### How to spot the symptom
- Test suite output shows green (vitest, surefire, pytest all exit 0)
- CI job status shows red
- Artifacts section of the run is empty
- Build log shows a non-zero exit from the `Upload …` step immediately after green tests
### `@v3` maintenance-mode status
GitHub placed `actions/upload-artifact@v3` in maintenance mode (no new features) but it
has not been removed and carries no known unpatched CVE as of this writing. If GitHub
publishes a v3-specific security advisory, that is an additional trigger to re-evaluate
(see upgrade conditions below).
### When to remove this pin
Re-evaluate pinning **when either condition is met:**
1. `gitea/act_runner` ships a release with v4 artifact protocol support. Track upstream:
<https://gitea.com/gitea/act_runner>
2. `actions/upload-artifact@v3` acquires an unpatched CVE that cannot be mitigated
at the runner level.
When upgrading: remove the grep guard step, update all three `uses:` lines, remove the
inline comments, and update this ADR's status to Superseded.
---
## Alternatives
### SHA pinning (`uses: actions/upload-artifact@<sha>`)
More secure against action repository compromise, but adds Renovate update friction
and is disproportionate for a self-hosted, single-tenant Gitea instance with one
trusted contributor (ADR-011). Rejected.
### Minor/patch pinning (`@v3.4.0`)
Avoids Renovate PRs but freezes us on a specific patch. The v3 major track is in
maintenance mode — minor pinning has no benefit and would require manual updates
for any v3 security patches. Rejected.
### Renovate `packageRules` bypass
Would prevent automated PRs from proposing v4. Not needed while Renovate is not
configured for this repository. Revisit if Renovate is introduced.
### Migrating the runner to a v4-compatible Gitea release
Out of scope for this issue. A separate decision; tracked in #557's non-goals.

View File

@@ -1,69 +0,0 @@
# ADR-015: DooD workspace bind mount for Compose file bind-mount resolution
## Status
Accepted
## Context
The deploy workflows (`.gitea/workflows/nightly.yml`, `release.yml`) run job steps inside Docker containers via Docker-out-of-Docker (DooD): the Gitea runner mounts the host Docker socket, and act_runner spawns sibling containers for each job.
When a job step calls `docker compose -f docker-compose.observability.yml up`, Docker Compose resolves relative bind-mount sources against `$(pwd)` inside the job container and passes the resulting absolute paths to the **host** daemon. For example, `./infra/observability/prometheus/prometheus.yml` becomes `/some/path/infra/observability/prometheus/prometheus.yml`, and the host daemon tries to bind-mount that path from the **host filesystem**.
In the default DooD setup (`runner-config.yaml` with only `valid_volumes: ["/var/run/docker.sock"]`), job container workspaces live in the act_runner overlay2 layer. The host has no corresponding directory at the job container's `$(pwd)` path, so the daemon auto-creates an empty directory in its place. The container then fails to start because the mount target was expected to be a file, not a directory:
```
error mounting "…/prometheus/prometheus.yml" to rootfs at "/etc/prometheus/prometheus.yml": not a directory
```
This affected all five config file bind mounts in `docker-compose.observability.yml`.
## Decision
Configure act_runner to store job workspaces on a real host path (`/srv/gitea-workspace`) and mount that path into both the runner container and every job container at the **same absolute path**. The identity of the host path and container path is the key constraint: Compose resolves to an absolute path and hands it to the host daemon, which looks for that exact path on the host filesystem.
**runner-config.yaml changes:**
```yaml
container:
workdir_parent: /srv/gitea-workspace
valid_volumes:
- "/var/run/docker.sock"
- "/srv/gitea-workspace"
options: "-v /srv/gitea-workspace:/srv/gitea-workspace"
```
**Runner compose.yaml change** (host side — not in this repo):
```yaml
runner:
volumes:
- /srv/gitea-workspace:/srv/gitea-workspace
```
With this in place, `$(pwd)` inside a job container resolves to `/srv/gitea-workspace/<owner>/<repo>/`, which is a real directory on the host. Compose-managed bind mounts from that directory work without any additional steps.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| **overlay2 `MergedDir` sync via privileged nsenter** (the previous approach, see PR #599 v1) | Required `--privileged --pid=host` (effective root on the host) plus fragile overlay2 driver assumption. Introduced stale-file risk on the host and a second stable path (`/srv/familienarchiv-*/obs-configs`) to maintain separately from the source tree. Replaced by this ADR. |
| **Build configs into a dedicated Docker image** (pattern used for MinIO bootstrap, see `infra/minio/Dockerfile`) | Viable for static files that change infrequently. Requires a build step and an image rebuild every time a config changes. Appropriate for bootstrap scripts; too heavy for frequently-tuned observability configs. |
| **Add workspace directory to runner-config `valid_volumes` only** (without `workdir_parent`) | `valid_volumes` whitelists paths that workflow steps may reference, but does not change where act_runner stores workspaces. Without `workdir_parent`, the workspace would still be in overlay2 and the bind-mount resolution problem would remain. |
| **Map workspace under a different host path than container path** (e.g. host `/srv/workspace`, container `/workspace`) | Compose resolves to the container-internal path (e.g. `/workspace/…`) and passes that to the host daemon. The host daemon interprets the source as a host path. If host `/workspace` does not exist, the daemon creates an empty directory — the original bug. The paths must be identical. |
## Consequences
- `/srv/gitea-workspace` must exist on the VPS before the runner starts. The directory was created as part of this change; it is not created automatically.
- The runner container's `compose.yaml` (maintained outside this repo at `~/docker/gitea/compose.yaml` on the VPS) must include the `- /srv/gitea-workspace:/srv/gitea-workspace` volume line. This is an out-of-band operational dependency; the prerequisite is documented in `runner-config.yaml`.
- `workdir_parent` applies to all jobs on this runner. Any future workflow that calls `docker compose` with relative bind mounts benefits automatically without further configuration.
- Job workspaces persist across runs under `/srv/gitea-workspace`. act_runner manages per-run subdirectory cleanup. Orphaned directories from interrupted runs should be cleaned up manually if disk space becomes a concern.
- Workflows that previously relied on `OBS_CONFIG_DIR` env var or the `obs-configs` stable path on the host no longer need those. Both were removed in this PR.
- This pattern does **not** apply to the `nsenter`-based Caddy reload step (ADR-012), which manages a host systemd service — a different problem class with no bind-mount equivalent.
## References
- ADR-011 — single-tenant runner trust model
- ADR-012 — nsenter via privileged container for host service management
- Issue #598 — original observability stack bind-mount failure
- `runner-config.yaml``workdir_parent`, `valid_volumes`, `options`

View File

@@ -1,57 +0,0 @@
# ADR-016: Observability stack co-location at `/opt/familienarchiv/` with CI-push config sync
## Status
Accepted
## Context
Issue #601 established that the observability stack must survive Gitea CI workspace wipes between nightly runs. When the nightly job completes, act_runner deletes the job workspace. Any Docker container that bind-mounts a config file from a workspace path (`/srv/gitea-workspace/…/infra/observability/prometheus/prometheus.yml`) then references a path that no longer exists on the host. On the next nightly run, Docker Compose either auto-creates an empty directory in its place (causing the container to fail to start because a file mount receives a directory) or finds a stale file from a previous run if the workspace happened to land at the same path.
ADR-015 solved the workspace bind-mount resolution problem: job workspaces are stored at `/srv/gitea-workspace` so `$(pwd)` inside the job container maps to a real host path. But it did not address persistence: the workspace is still wiped after the job, so bind mounts from workspace-relative paths remain fragile across runs.
### Decision drivers
1. Bind-mount sources must point to a host path that persists indefinitely, not to a path that disappears after each CI run.
2. Config files must reflect the committed state of the repo after every nightly run (no manual sync steps).
3. Secrets must not be written to the workspace or to any path managed by CI; they must survive independently of deployments.
4. The solution must not introduce new infrastructure dependencies (no SSH access from CI, no external registry, no additional server-side daemon).
### Alternatives considered
**A: Server-pull model** — a systemd timer or cron job on the server does `git pull` from the repo into `/opt/familienarchiv/` and then runs `docker compose up`. Rejected because: (1) requires git credentials on the server and a registered deploy key, (2) adds a second deployment mechanism that diverges from the CI-push model used for the main app stack, (3) timing coupling — the server pull must complete before CI's health checks run, requiring polling or a webhook.
**B: Separate directory (e.g. `/opt/obs/`)** — keeps obs configs isolated from the app stack. Rejected because: (1) the main app compose files are already in `/opt/familienarchiv/` (managed the same way), and (2) GlitchTip shares the `archive-db` PostgreSQL instance and `archiv-net` Docker network — it is architecturally part of the same deployment unit, not a separate one. Co-location reflects the actual coupling.
**C: Named Docker configs (Swarm)** — Docker Swarm supports first-class config objects that persist in the cluster. Rejected because the project does not use Swarm and introducing it solely for config persistence is a disproportionate dependency.
## Decision
The observability stack is co-located with the main application deployment at `/opt/familienarchiv/`:
- `docker-compose.observability.yml``/opt/familienarchiv/docker-compose.observability.yml`
- `infra/observability/``/opt/familienarchiv/infra/observability/`
Both the nightly CI job (`nightly.yml`) and the release job (`release.yml`) copy these files from the workspace checkout to `/opt/familienarchiv/` using `cp -r` on every run (CI-push model). Containers always read config from the permanent location; a workspace wipe has no effect on running containers.
Environment variables follow a two-source model:
- `infra/observability/obs.env` (git-tracked, non-secret): all non-sensitive config — host ports, public URLs (`GLITCHTIP_DOMAIN`, `GF_SERVER_ROOT_URL`), and the default `POSTGRES_HOST`. Changes go through PR review. No credentials.
- `/opt/familienarchiv/obs-secrets.env` (CI-written, per-deploy): passwords and secret keys only (`GRAFANA_ADMIN_PASSWORD`, `GLITCHTIP_SECRET_KEY`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`), injected fresh from Gitea secrets on every nightly and release deploy. Gitea is the single source of truth for secrets — rotating a secret takes effect on the next deploy without manual server action.
Both files are passed explicitly via `--env-file` to every obs compose command (config dry-run and `up`). There is no implicit auto-read `.env`. The required key inventory is documented in `docs/DEPLOYMENT.md §4`.
The CI runner mounts `/opt/familienarchiv` as a bind mount into job containers (see `runner-config.yaml`). This requires a one-time `mkdir -p /opt/familienarchiv/infra` on the server and a runner restart after updating `runner-config.yaml` (see ADR-015 and `docs/DEPLOYMENT.md §3.1`).
## Consequences
**Positive:**
- Bind-mount sources survive workspace wipes by definition — they are on a persistent host path.
- Config is always in sync with the repo after each nightly run.
- No new infrastructure dependencies; the CI-push model mirrors how the main app stack is deployed.
- Secret rotation requires no manual server action — Gitea secrets are the authoritative store; `obs-secrets.env` is rewritten from scratch on every deploy so a secret change takes effect on the next nightly or release run.
**Negative:**
- `cp -r` does not remove deleted files; a config file removed from the repo persists in `/opt/familienarchiv/infra/observability/` until manually deleted. Acceptable for this project's change frequency. A `rsync -a --delete` would give a clean mirror if this becomes a problem.
- Mounting `/opt/familienarchiv/` into CI job containers expands the blast radius of a compromised workflow step — a malicious step could overwrite app compose files and Caddy config. Acceptable because the runner is single-tenant (trusted code only). See `runner-config.yaml` security comment.
- Runner must be restarted (`systemctl restart gitea-runner`) after any change to `runner-config.yaml` for the new mount to take effect.

View File

@@ -1,48 +0,0 @@
# ADR-017: Spring Boot 4.0 management port shares the main security filter chain
## Status
Accepted
## Context
The Familienarchiv backend runs Spring Boot Actuator on a dedicated management port (8081) so that Caddy never proxies `/actuator/*` requests and Prometheus can reach the scrape endpoint directly inside `archiv-net`.
In earlier Spring Boot versions (< 4.0), the management server ran in an isolated child application context whose security was governed independently by `ManagementWebSecurityAutoConfiguration`. The main app's `SecurityConfig` filter chain (port 8080) never intercepted requests arriving on port 8081.
In Spring Boot 4.0 with Jetty, this isolation was removed. The management server now traverses the **same** Spring Security `FilterChainProxy` as the main application. Concretely:
- Any `SecurityFilterChain` bean in the application context is evaluated for requests arriving on the management port.
- There is no longer a separate "management security" child context.
This was discovered when Prometheus began receiving HTTP 401 responses from `/actuator/prometheus` despite the endpoint being exposed and the `micrometer-registry-prometheus` dependency being present. Prometheus rejected these responses with `received unsupported Content-Type "text/html"` because the main filter chain's form-login `DelegatingAuthenticationEntryPoint` was redirecting unauthenticated requests to `/login` (302 → HTML).
A secondary issue: Spring Boot 4.0 no longer auto-enables Prometheus metrics export — `management.prometheus.metrics.export.enabled` must be set explicitly, and the Prometheus scrape endpoint requires `spring-boot-starter-micrometer-metrics` (a new starter that was split out in Spring Boot 4.0).
## Decision
1. **Dedicated management `SecurityFilterChain`** scoped to `/actuator/**` at `@Order(1)` (highest precedence). This chain:
- `permitAll()` for `/actuator/health` and `/actuator/prometheus` — required for Docker health checks and unauthenticated Prometheus scraping.
- `authenticated()` for all other actuator endpoints — blocks `/actuator/metrics`, `/actuator/info`, etc. without credentials.
- Uses an explicit `401` entry point (not form-login redirect) so that API clients — including Prometheus — receive a machine-readable status code rather than an HTML redirect.
- No CSRF, no form login.
2. **Belt-and-suspenders `permitAll()` in the main `SecurityFilterChain`** for `/actuator/health` and `/actuator/prometheus`, in case a future configuration change causes these paths to escape the management chain's `securityMatcher`.
3. **Network isolation as the outer defense boundary.** Port 8081 is not published in `docker-compose.yml` and is not routed through Caddy. Only services inside `archiv-net` (primarily Prometheus and the Docker health checker) can reach the management port.
## Alternatives rejected
- **Exclude `ManagementWebSecurityAutoConfiguration`:** This auto-configuration no longer exists in Spring Boot 4.0. Exclusion is not applicable.
- **Keep `SecurityConfig` as the sole filter chain without `@Order(1)` management chain:** The main chain's form-login `DelegatingAuthenticationEntryPoint` redirects browser-like clients to `/login` (302). Prometheus and automated health check clients cannot follow this redirect, so the endpoint would be unreachable without a dedicated chain that returns plain 401 or 200.
- **Per-endpoint `@Order(1)` filter chain using `EndpointRequest.toAnyEndpoint()`:** The `spring-boot-security` artifact that provides `EndpointRequest` is not a transitive dependency of `spring-boot-starter-actuator` in Spring Boot 4.0. Using a path-based `securityMatcher("/actuator/**")` achieves the same scoping without an extra dependency.
## Consequences
- All actuator endpoints on port 8081 that are not explicitly `permitAll()`-ed require HTTP Basic credentials. Without valid credentials, the response is 401 (not a redirect).
- Adding a new actuator endpoint to `management.endpoints.web.exposure.include` implicitly protects it via `anyRequest().authenticated()` in the management chain — no additional `permitAll()` needed unless intentional.
- A regression test (`ActuatorPrometheusIT`) verifies:
- `/actuator/prometheus` returns 200 without credentials.
- `/actuator/metrics` returns 401 without credentials.
- Prometheus metric names are present in the response body.
- If port 8081 is ever accidentally published in `docker-compose.yml`, actuator endpoints other than health and prometheus are still protected by HTTP Basic. This reduces (but does not eliminate) the risk of inadvertent exposure.

View File

@@ -1,86 +0,0 @@
# ADR-018: GlitchTip frontend error tracking via @sentry/sveltekit
**Date:** 2026-05-17
**Status:** Accepted
**Deciders:** Marcel Raddatz
---
## Context
The Familienarchiv had no client-side error reporting. When a user encountered a crash
or unhandled error in the SvelteKit frontend, there was no way for the operator to
observe it — errors were invisible until a user manually reported them. A GlitchTip
instance (self-hosted, Sentry-compatible) was already running as part of the
observability stack (`docker-compose.observability.yml`). The backend already reported
server-side errors to it.
We needed a way to:
1. Capture frontend errors automatically and route them to GlitchTip.
2. Give users a visible error identifier they can include in a support message.
3. Do this without leaking personally identifiable information (PII) from the family
archive — documents contain personal histories, names, and relationships.
---
## Decision
Use `@sentry/sveltekit` (the official Sentry SDK for SvelteKit) to:
- Initialise with `sendDefaultPii: false` on both `hooks.server.ts` and `hooks.client.ts`.
- Pass a callback to `Sentry.handleErrorWithSentry()` that returns
`{ message, errorId }` where `errorId` is `Sentry.lastEventId()` when Sentry
captured the event, or a fresh `crypto.randomUUID()` as fallback.
- Display the `errorId` on the `+error.svelte` page so users can include it in a
report to the operator.
The SDK is initialised with `enabled: !!import.meta.env.VITE_SENTRY_DSN` so that
development and CI builds without a DSN configured do not send any events.
`VITE_SENTRY_DSN` is a write-only ingest key — it can POST events to GlitchTip but
cannot read them. It is safe to include in the client bundle per the Sentry security
model; it does not require rotation like a password.
---
## Alternatives considered
**Sentry SaaS** — rejected. The archive contains private family documents and personal
history. Sending error events with stack traces to a US-hosted third party is
inconsistent with the project's data-minimisation posture. Self-hosted GlitchTip on
the same Hetzner VPS keeps all data on infrastructure the operator controls.
**Custom error logging endpoint** — rejected. The @sentry/sveltekit SDK handles
SvelteKit's hook lifecycle, source-map upload, and event grouping automatically.
Reimplementing this would cost significant engineering time for no benefit.
**Log-only (no user-visible errorId)** — rejected. Without a visible error ID, users
can only describe what happened in natural language, making it hard to correlate a
report with a specific GlitchTip event. The `errorId` closes this gap at negligible UI
cost.
---
## Consequences
**Positive:**
- Frontend errors are now observable without requiring user reports.
- Users can provide an `errorId` that maps directly to a GlitchTip event.
- `sendDefaultPii: false` ensures names, IPs, and cookie values are not included in
captured events.
- `tracesSampleRate: 0.1` limits trace volume to 10% of transactions, keeping
GlitchTip load low on the shared VPS.
**Negative / trade-offs:**
- The `@sentry/sveltekit` SDK is now a production dependency. SDK updates must be
reviewed for changes to the default PII scrubbing behaviour.
- The `handleError` callback in both hooks returns a hardcoded English message
(`'An unexpected error occurred'`). This bypasses Paraglide i18n — the error page
will always show English text when the hooks are active, regardless of the user's
locale. This is acceptable because: (a) the error page is a last-resort fallback
not part of normal UX, (b) the `errorId` is the actionable information, not the
message text. A future ADR may address this if internationalised error messages
become a requirement.
- `Sentry.lastEventId()` returns `undefined` when Sentry did not capture the event
(e.g. DSN not configured). The `crypto.randomUUID()` fallback guarantees an `errorId`
is always present, but that UUID will not appear in GlitchTip.

View File

@@ -1,94 +0,0 @@
# ADR-019 — Container hardening baseline: non-root user + read-only filesystem
**Status:** Accepted
**Date:** 2026-05-17
**PR:** #611
---
## Context
The OCR service ran as `root` inside its container by default. This violated CIS Docker Benchmark §4.1 and CIS §4.6, and meant that any exploit in the OCR pipeline (untrusted PDF content, model deserialization, ZIP handling) could write to or execute anything inside the container without restriction.
The following risks were present before this baseline:
- A path-traversal in the ZIP-based training endpoint could overwrite arbitrary paths on the container filesystem (including Python source files and model files).
- A compromised dependency running at startup could persist itself to the image layers or model volumes.
- Misconfigured model downloads could overwrite `/etc/passwd` or similar via path-traversal — possible because root can write everywhere.
---
## Decision
All containers in this project that have no operational need for elevated privileges **must** apply the following hardening baseline:
### 1. Non-root user
Create a dedicated user with a fixed UID and no login shell:
```dockerfile
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1000 <service>
```
Set `HOME` explicitly to a path owned by this user. Do not rely on `~` expansion for any path resolution in application code.
### 2. Read-only container filesystem
```yaml
read_only: true
```
All paths the application writes to at runtime must be explicitly declared as either a named volume or a `tmpfs` mount. This turns any unexpected write attempt into an immediate, visible `PermissionError` rather than a silent success.
### 3. Per-path write carve-outs
Declare only the paths that are actually written at runtime:
```yaml
volumes:
- <service>_models:/app/models # persistent model storage
- <service>_cache:/app/cache # HuggingFace / ketos download cache
tmpfs:
- /tmp:size=512m # transient scratch space (ZIP extraction etc.)
```
Do not mount the home directory as a volume unless necessary — use `XDG_CACHE_HOME` and `TORCH_HOME` env vars to redirect library cache writes to the declared writable paths instead.
### 4. Dropped capabilities and privilege escalation prevention
```yaml
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
```
A Python/FastAPI service on port 8000+ requires no Linux capabilities. Dropping all and blocking privilege escalation via setuid prevents any capability regain even if a dependency contains a SUID binary.
### 5. Startup root canary
Log a warning during startup if the process is running as root. This catches misconfiguration (e.g., `USER` directive accidentally removed in a future Dockerfile edit) before it becomes a silent vulnerability:
```python
if os.getuid() == 0:
logger.warning("Running as root — CIS Docker §4.1 violation")
```
---
## Consequences
**Positive:**
- Any exploit that achieves code execution inside the container is confined: it cannot write outside the declared volumes, cannot acquire new capabilities, and cannot persist to the image filesystem.
- `PermissionError` on startup is an explicit, diagnosable failure rather than a silent privilege misuse.
- The startup canary catches accidental regressions in the non-root setup.
**Negative / operational cost:**
- Every new feature that writes to a new path (e.g., a new model cache directory, a new scratch path) must add a volume or tmpfs mount. The `read_only: true` flag makes this a hard constraint, not a suggestion.
- Library dependencies that write to `HOME` without respecting `XDG_CACHE_HOME` must be identified and redirected explicitly (see `TORCH_HOME`, `XDG_CACHE_HOME`, `HF_HOME` in `docker-compose.yml`).
- Existing named volumes written by root (pre-baseline) must be dropped and recreated before upgrading. See [DEPLOYMENT.md §8](../DEPLOYMENT.md#8-upgrade-notes).
---
## Applicability
This baseline applies to the OCR service (PR #611). It should be applied to any new container added to the project unless there is a documented, specific operational reason a capability or writable filesystem is required.

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